C++线程安全

线程安全定义

多线程操作一个共享数据的时候,能够保证所有线程的行为是符合预期的。

STL线程安全

一般说来,stl对于多线程的支持仅限于下列两点:

  • 多个读取者是安全的。即多个线程可以同时读取一个容器中的内容。即此时多个线程调用 容器的不涉及到写的接口都可以 eg find, begin, end 等.
  • 对不同容器的多个写入者是安全的。即多个线程对不同容器的同时写入合法。但是对于同一容器当有线程写,有线程读时,如何保证正确? 需要程序员自己来控制,比如:线程A读容器某一项时,线程B正在移除该项。这会导致一下无法预知的错误。通常的解决方式是用开销较小的临界区(CRITICAL_SECTION)来做同步。

解决方案:

一、加锁

我们可以通过互斥锁、读写锁、条件变量等来做线程间同步,同样的,加锁时机也有要求

加锁的时机:

  • 每次调用容器的成员函数的期间需要锁定。
  • 每个容器容器返回迭代器的生存期需要锁定。
  • 每个容器在调用算法的执行期需要锁定。

二、容器定容

STL从设计当初为了效率的原因,并没有设置容器增删改查的原子操作,在多线程写时除了可能造成对同一资源的条件竞争外,还可能发生core dump的情况。

比如vector容器,当push_back一个元素时正好达到了vector预制大小,就会自动进行扩容并释放掉之前的内存。那么在此以后,之前涉及到的迭代器等均会失效,如果再次访问之前那段内存就会造成core dump。

所以如果我们可以事先准备好一个固定大小的容器,在此基础之上进行多线程读、单线程写,就不会出现这个问题了。

对于vector容器来说,我们可以使用stl提供的reserve或resize来进行预留空间的操作。虽然都是预留空间,但是它们之间还是有一些不同的。对于reserve来说,仅仅只是按照所给类型来计算并预留一定的空间,而对于resize来说,在预留空间的同时还会在此空间上调用构造函数。在这里因为我们需要调用push_back并且要防止扩容,所以我们尽量使用reserve来做空间预留。与此同时我们还需要对容器下标做原子操作。这样可以防止在多线程消费该容器内容时造成重复消费。

无锁队列

通过维护两个指针(读写指针)来对数组进行操作。当生产者向其中写入数据时,向尾部写入数据,更新写指针;当消费者读数据时,从头部读取数据,删除数据,更新读指针。当读写指针到达末端时,将其更新至数组首部。

如果是单消费者-单生产者的情况下,可以不使用原子操作就可以实现无锁队列。如果是多生产者-单消费者的情况下,写指针需要使用原子操作。

智能指针share_ptr线程安全

引用计数线程安全

首先需要了解一下share_ptr的计数原理。在share_ptr的模板类定义当中有两个指针。一个指向的是将来所要接管的内存地址,一个指向的是一个控制块的地址。包括引用计数、weak_ptr的数量、删除器、分配器等等。

在多线程的环境里,多个share_ptr对象所管理的同一个对象时的引用计数是线程安全的。因为在这里涉及到的一系列操作均为原子操作。

修改share_ptr指向线程安全

这里需要分两个情况来讨论,即不同线程中操作的是否为同一个share_ptr对象。

如果在开启线程时传入的是值而非引用,那么后续一定是线程安全的。如果传递进去的是引用,那么就不是线程安全的。

在share_ptr赋值的过程涉及到两个过程,引用计数的变换和原生指针的变换。前面已经讨论过了关于引用计数是原子操作,是线程安全的。而对于计数变换和指针变换并不是原子的,所以可能存在另一个share_ptr刚接管这一对象然后立马又切换到另一个线程,而此时的引用计数还没有加一。在另一线程当中假如对这一对象引用减一。那么就会立即释放掉相关资源,而回到第一个线程时该线程中的share_ptr就变成了悬挂指针。

总结一句话就是:智能指针的引用计数是线程安全的,开启线程时采取值传递的方式也是线程安全的。而如果是引用传递就不是线程安全的了。

C++锁

多线程中的锁主要有五类:互斥锁条件锁自旋锁读写锁递归锁。一般而言,所得功能与性能成反比。

互斥锁

互斥锁用于控制多个线程对它们之间共享资源互斥访问的一个信号量。也就是说为了避免多个线程在某一时刻同时操作一个共享资源,例如一个全局变量,任何一个线程都要使用初始锁互斥地访问,以避免多个线程同时访问发生错乱。

在某一时刻只有一个线程可以获得互斥锁,在释放互斥锁之前其它线程都不能获得互斥锁,以阻塞的状态在一个等待队列中等待。

条件变量

这个一般是配合上面的互斥锁使用的,当一个线程获取锁后,其他线程运行到这里就会进入休眠。直到该线程满足某个条件后就会唤醒一个或多个线程。

惊群效应

惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。

虚假唤醒

举个例子,我们现在有一个生产者-消费者队列和三个线程。

1) 1号线程从队列中获取了一个元素,此时队列变为空。

2) 2号线程也想从队列中获取一个元素,但此时队列为空,2号线程便只能进入阻塞(cond.wait()),等待队列非空。

3) 这时,3号线程将一个元素入队,并调用cond.notify()唤醒条件变量。

4) 处于等待状态的2号线程接收到3号线程的唤醒信号,便准备解除阻塞状态,执行接下来的任务(获取队列中的元素)。

5) 然而可能出现这样的情况:当2号线程准备获得队列的锁,去获取队列中的元素时,此时1号线程刚好执行完之前的元素操作,返回再去请求队列中的元素,1号线程便获得队列的锁,检查到队列非空,就获取到了3号线程刚刚入队的元素,然后释放队列锁。

6) 等到2号线程获得队列锁,判断发现队列仍为空,1号线程“偷走了”这个元素,所以对于2号线程而言,这次唤醒就是“虚假”的,它需要再次等待队列非空。

自旋锁

自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,只需要等一等(自旋),等到持有锁的线程释放锁后即可获取,避免用户进程和内核切换的消耗。

自旋锁避免了操作系统进程调度和线程切换,通常适用在时间极短的情况,因此操作系统的内核经常使用自旋锁。但如果长时间上锁,自旋锁会非常耗费性能。线程持有锁时间越长,则持有锁的线程被 OS调度程序中断的风险越大。如果发生中断情况,那么其它线程将保持旋转状态(反复尝试获取锁),而持有锁的线程并不打算释放锁,导致结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。

自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能,因此可以给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。

点赞

发表回复

电子邮件地址不会被公开。必填项已用 * 标注