当前位置:网站首页 > 技术博客 > 正文

线程安全的含义



本文主要介绍线程安全(thread-safe)的相关知识。

要介绍“线程安全”,那么就必须要提“线程不安全”,甚至可以说,是先出现了“线程不安全”这个问题,后来为了解决这个问题,才有了“线程安全”的概念。

“线程安全”和“线程不安全”的相关内容,都是在涉及多线程编程时才会用到,在单线程的场景下无需考虑。至于为何需要多线程编程,请参考此文。

在操作系统中,线程是由进程创建的,线程本身几乎不占有系统资源,线程用到的系统资源是属于进程的。一个进程可以创建多个线程,这些线程共享着进程中的资源。所以,当这些线程并发运行时,如果同时对一个数据(该数据属于进程,被该进程下的多个线程共享使用)进行修改,那么就可能造成该数据表现出不符合我们预期的变化,这就是所谓的线程不安全

与线程不安全对应,在拥有共享数据的多个线程并行执行的程序中,线程安全的代码会通过(自身实现的)同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

从代码角度来说,假设进程中有多个线程在同时运行,而这些线程可能会同时运行一段代码,如果这段代码在多线程并发情况下的运行结果与单线程运行时是一样的,并且其他变量的值也和预期一样,那么这段代码就是线程安全的。

从接口的角度来说,如果一个类(或者程序)提供的接口,对于(调用该接口的)线程来说是原子的,或者多个线程之间的切换不会导致该接口的执行结果存在二义性,这样在调用该接口时就无需额外考虑线程同步问题,那么该接口就是线程安全的。

线程安全问题都是由全局变量静态变量引起的。如果每个线程中对全局变量或静态变量只有读操作,而无写操作,那么这个全局变量或静态变量是线程安全的;如果有多个线程同时对全局变量或静态变量执行写操作,则一般都需要考虑线程同步,否则就可能影响线程安全。

线程安全的类,首先必须在单线程环境中有正确行为:如果一个类的实现正确(即符合规格说明),那么对这个类的对象的任何操作序列(读或写公共字段以及调用公共方法),都不会让该对象处于无效状态,或者违反类的任何不可变量、前置条件或者后置条件的情况。

此外,一个类要成为线程安全的,在被多个线程同时访问时,不管这些线程是怎样的时序安排或者交错,该类必须仍然具备上述的正确行为,并且调用代码不需要进行任何额外的线程同步操作。其效果是,在所有线程看来,对于(线程安全)对象的操作是以固定的、全局一致的顺序发生的。

1.3.1 示例1

例如,有一个ArrayList类,在添加一个元素的时候,包括两个步骤:

  1. 在Items[Size]位置存放此元素;
  2. 增大Size的值。

在单线程运行情况下,如果“Size = 0”,添加一个元素后,此元素在位置0,而“Size=1”。该类功能正常,数组变化与预期一致。

在多线程情况下,比如有两个线程(“A”和“B”),线程“A”先将元素存放在位置0。但是此时经CPU调度,线程“A”暂停(挂起),线程“B”开始运行,线程“B”也向ArrayList类的Items中添加元素,由于此时线程“B”获取的Size仍然为0(因为前面线程“A”仅仅完成了存放操作,尚未进行增大Size值操作),所以线程“B”也将元素存放在位置0,之后线程“A”和线程“B”继续运行,(无论“CPU”如何调度)线程“A”和线程“B”都会增加Size的值,导致Size值变为2,然后流程结束。

此时,Items中的元素数量实际上只有一个,存放在位置0,而Size却等于2了,这个结果是与我们的语气不符的,也就出现了“线程不安全”问题了。

1.3.2 示例2

例如,有变量“counter = 0”,counter在两线程“A”和“B”之间共享。假设线程“A”和线程“B”同时对counter进行递增计算(counter++),那么正常情况下,计算后的结果应该是2,但是在“线程不安全”的情况下,实际的结果却可能是1。

具体流程与示例1类似,递增计算一定要分为多步(本例为三步)进行,步骤如下:

  1. 先获取counter的值;
  2. 对counter进行递增计算;
  3. 将计算后的结果重新赋值给counter。

从CPU调度角度来看,线程“A”执行完上述第1、2步之后,转而去调度线程“B”,当线程“B”也执行了上述第1、2步之后,无论后续CPU如何调度,最终计算出来的counter的值都是1(线程“A”的计算结果覆盖线程“B”,或者相反),这样就出现了“线程不安全”问题。

通过前面的讲述,已经大概知道了线程安全的原理,那么应该如何解决多线程并发情况下的线程安全问题呢?

通常可以通过使用锁(也称互斥锁,mutex)来保证线程安全。

有些文章在多线程编程中引入了“原子性”、“可见性”和“顺序性”概念,然后以保证这三者正常实现的方式来保证线程安全,这些方案中同样也包括了使用锁来保证这三者的正常实现,即,使用锁来保证线程安全性。

锁能使其保护的代码路径以串行形式被访问,因此可以通过锁可以实现:(对变量操作的)原子性、(完整的变量操作之后才释放锁,保证其他线程对于该变量操作后结果的)可见性、(锁之内的代码全部执行完成之前,其他线程不能进入该段代码逻辑,进行变量修改操作)顺序性。

此处提供一个示例程序,该程序会创建两个线程,这两个线程执行同一段代码,该段代码会对全局变量count进行修改(加5000次)并打印count的值。正常情况下,最终的count值应该为10000。

现在分别在线程不安全和线程安全场景下,编写并测试该程序。

线程不安全代码(thread_unsafe_test1.cpp)的内容如下:

编译并执行上述代码,(部分)运行结果可能如下:

关于上述示例程序,说明如下:

  1. 由于CPU调度的不确定性,上述示例程序每次的执行结果都可能会不同,因而最终的count值为不确定值;
  2. 在上述代码中,在读取count值(int val = count;)和把新值赋给count(count = val + 1;)之间,插入一个打印操作(cout << "count is: " << count << endl;),该操作会调用write系统调用,此时(程序)会从用户态进入内核态,为内核调度别的线程(执行)提供了一个很好的时机,所以就可能会导致线程的并发运行(CPU在线程间来回切换),进而产生线程不安全的问题;
  3. 程序的输出结果,证实了上述第1点结论。另外,为了从另一角度证实第1点结论,我们将打印操作从“读取与赋值语句之间”移动到“赋值语句之后”,再次编译执行程序,就没有出现线程不安全的问题,即最终的count值为10000;

通过上述几点说明,可以得出一种好的编程方法(或者说编程习惯):代码内容要尽量内聚,这里不仅仅是针对代码模块,更多的是针对代码行与行之间逻辑。就本例来说,需要把“打印操作”放到“取值与赋值操作”之前或之后,这样就保证了“取值与赋值操作”的内聚性(虽然仅仅是两行代码),而这样的操作同时也(误打误撞地)实现了线程安全,尽管我们的初衷并不是为了解决线程不安全的问题。可以说,提高代码内聚性,确实好处多多!

针对 2.1 节中遇到的线程不安全问题,可以通过锁机制来实现线程安全。

线程安全(thread_safe_test1.cpp)代码内容如下:

编译并执行上述代码,(多次执行的)运行结果如下:

通过上述执行结果可知,通过引入互斥锁(Mutex),保证了加锁的代码块会被一个线程“独占”,直到该线程执行完成这段代码,并进行解锁操作,其他线程才能执行该代码块,以此达到了线程安全的目的。

版权声明


相关文章:

  • 维度仪表有限公司2024-12-20 13:01:02
  • webrtc sfu开源2024-12-20 13:01:02
  • 神秘代码2024-12-20 13:01:02
  • 解决pipreqs中的UnicodeDecodeError错误2024-12-20 13:01:02
  • 新魔百和CM101S-2优盘强刷包带教程2024-12-20 13:01:02
  • css字体设置为宋体2024-12-20 13:01:02
  • chrome打断点调试2024-12-20 13:01:02
  • seekbar的使用android2024-12-20 13:01:02
  • 数据库中表的设计2024-12-20 13:01:02
  • 文档怎么弄框架2024-12-20 13:01:02