muduo库学习之C++多线程系统编程精要02——C/C++系统库的线程安全性

原文链接:https://blog.csdn.net/qq_41453285/article/details/105047602

一、C/C++的线程库

  • 原先的C/C++标准(C89/C99/C++03)并没有涉及线程,新版的C/C++标准(C11和C++11)规定了程序在多线程下的语意,C++11还定义了一个线程库(std::thread)

内存模型(memory model)

  • 对于标准而言,关键的不是定义线程库,而是规定内存模型
  • 特别是规定一个线程对某个共享变量的修改何时能被其他线程看到,这称为内存序(memory ordering)或者内存能见度 (memory visibility)
  • 从理论上讲,如果没有合适的内存模型,编写正确的多线程程序属于撞大运行为,见Hans-J. Boehm的论文《Threads Cannot be Implemented as a Library》:http://www.hpl.hp.com/techreports/2004/HPL-2004-209.pdf。不过我认为不必担心这篇文章提到的问题,标准的滞后不会对实践构成影响。因为从操作系统开始支持多线程到现在已经过去了近20年,人们已经编写了不计其数的运行于关键生产环境的多线程程序,甚至Linux操作系统内核本身也可以是抢占的(preemptive)
  • 因此可以认为每个支持多线程的操作系统上自带的C/C++编译器对本平台的多线程支持都足够好。现在多线程程序工作不正常很难归结于编译器bug,毕竟POSIX threads线程标准在20世纪90年代中期就制定了。当然,新标准的积极意义在于让编写跨平台的多线程程序更有保障了

二、线程库的出现对标准库带来的冲击

  • Unix系统库(libc和系统调用)的接口风格是在20世纪70年代早期确立的,而第一个支持用户态线程的Unix操作系统出现在20世纪90年代早期

  • 线程的出现立刻给系统函数库带来了冲击,破坏了20年来一贯的编程传统和假定。

    例如:

    • **errno不再是一个全局变量,**因为每个线程可能会执行不同的系统库函数
    • **有些“纯函数”不受影响,**例如memset/strcpy/snprintf等等
    • **有些影响全局状态或者有副作用的函数可以通过加锁来实现线程安全,**例如malloc/free、printf、fread/fseek等等
    • **有些返回或使用静态空间的函数不可能做到线程安全,**因此要提供另外的版本,例如asctime_r/ctime_r/gmtime_r、stderror_r、strtok_r等等
    • 传统的fork()并发模型不再适用于多线程程序(参阅后面的“多线程与fork()”文章)
  • **现在Linux glibc把errno定义为一个宏,注意errno是一个lvalue,**因此不能简单定义为某个函数的返回值,而必须定义为对函数返回指针的dereference

img

三、大部分系统调用都是安全的

  • 值得一提的是,操作系统支持多线程已有近20年,

    早先一些性能方面的缺陷都基本被弥补了

    。例如:

    • 最早的SGI STL自己定制了内存分配器,而现在g++自带的STL已经直接使用malloc来分配内存, std::allocator已经变成了鸡肋(参阅后面的“C++经验谈之不要重载全局::operator new()文章”)
    • 原先Google tcmalloc相对于glibc 2.3中的ptmalloc2有很大的性能提升,现在最新的glibc中的ptmalloc3已经把差距大大缩小了
  • 我们不必担心系统调用的线程安全性,因为系统调用对于用户态程序来说是原子的。但是要注意系统调用对于内核状态的改变可能影响其他线程,这个话题留到后面“多线程与IO”文章中介绍

非线程安全函数的黑名单

  • 与直觉相反,POSIX标准列出的是一份非线程安全的函数的黑名单,而不是一份线程安全的函数的白名单(All functions defined by this volume of POSIX.1-2008 shall be thread-safe, except that the following functions need not be thread-safe)
  • 在这份黑名单中,system、 getenv/putenv/setenv等等函数都是不安全的
  • 黑名单参阅:http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_09
  • 因此,可以说现在glibc库函数大部分都是线程安全的。特别是FILE*系列函数是安全的,glibc甚至提供了非线程安全的版本(fread_unlocked、fwrite_unlocked等等,见man unlocked_stdio)以应对某 些特殊场合的性能需求

四、系统调用的使用不是线程安全的

线程安全是不可组合的

  • 尽管单个函数是线程安全的,但两个或多个函数放到一起就不再安全了

例如

  • 例如fseek()和fread()都是安全的,但是对某个文件“先seek再read”这两步操作中间有可能会被打断,其他线程有可能趁机修改了文件的当前位置,让程序逻辑无法正确执行
  • 在这种情况下,我们可以用flockfile(FILE*)和funlockfile(FILE*)函数来显式地加锁。并且由于FILE*的锁是可重入的,加锁之后再调用fread()不会造成死锁
  • 如果程序直接使用lseek和read这两个系统调用来随机读取文件,**也存在“先seek再read”这种race condition,但是似乎我们无法高效地对系统调用加锁。解决办法是改用pread系统调用,**它不会改变文件的当前位置
  • 由此可见,编写线程安全程序的一个难点在于线程安全是不可组合的(composable)(就跟C++异常安全也是不可组合的一样),一个函数foo()调用了两个线程安全的函数,而这个foo()函数本身很可能不是线程安全的。即便现在大多数glibc库函数是线程安全的,我们也不能像写单线程程序那样编写代码

演示案例

  • 例如,在单线程程序中,如果我们要临时转换时区,可以用tzset()函数,这个函数会改变程序全局的“当前时区”

img

  • 但是在多线程程序中,这么做不是线程安全的,即便tzset()本身是线程安全的。因为它改变了全局状态(当前时区),这有可能影响其他线程转换当前时间,或者被其他进行类似操作的线程影响
  • 解决办法是使用muduo::TimeZone class,**每个immutable instance(不可变的实例)对应一个时区,**这样时间转换就不需要修改全局状态了。例如:

img

  • 对于C/C++库的作者来说,如何设计线程安全的接口也成了一大考验,值得仿效的例子并不多。一个基本思路是尽量把class设计成 immutable(不可变的)的,这样用起来就不必为线程安全操心了

五、标准库的安全性

  • 标准库容器和string都不是线程安全的
  • 大多数泛型算法都是线程安全的
  • iostream不是线程安全的
  • 尽管C++03标准没有明说标准库的线程安全性,但我们:
    • *可以遵循一个基本原则:*凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到,那么它就是安全的**
    • **另外一个事实标准是:**共享的对象的read-only操作是安全的(这意味着标准库容器不能采用自调整的数据结构,比如splay tree,这种数据结构在read的时候也会修改状态,见http://www.cs.au.dk/~gerth/aa11/slides/selfadjusting.pdf),前提是不能有并发的写操作。例如两个线程各自访问自己的局部vector对象是安全的;同时访问共享的const vector对象也是安全的,但是这个vector不能被第三个线程修改。 一旦有writer,那么read-only操作也必须加锁,例如vector::size()

标准库容器和string都不是线程安全的

  • 根据第一篇文章中对线程安全的定义,C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的
  • 原因有两点:
    • 一方面的原因是为了避免不必要的性能开销
    • 另一方面的原因是单个成员函数的线程安全并不具备可组合性(composable)
  • 假设有safe_vectorclass,它的接口与std::vector相同,不过每个成员函数都是线程安全的(类似Java synchronized方法)。但是用safe_vector并不一定能写出线程安全的代码。例如,在if语句判断vec非空之后,别的线程可能清空其元素,从而造成vec[0] 失效:

img

大多数泛型算法都是线程安全的

  • C++标准库中的绝大多数泛型算法是线程安全的(std::random_shuffle()可能是个例外,它用到了随机数发生器),因为这些都是无状态纯函数
  • 只要输入区间是线程安全的,那么泛型函数就是线程安全的

iostream不是线程安全的

  • C++的iostream不是线程安全的,因为下面的流式输出:

img

  • 等价于两个函数调用:

img

  • 即便ostream::operator<<()做到了线程安全,也不能保证其他线程不会在两次函数调用之前向stdout输出其他字符
  • **对于“线程安全的stdout输出”这个需求,我们可以改用printf,**以达到安全性和输出的原子性。但是这等于用了全局锁,任何时刻只能有一个线程调用printf,恐怕不见得高效。在多线程程序中高效的日志需要特殊设计,参阅后面的“高效的多线程日志”专栏文章

猜你喜欢

转载自blog.csdn.net/qq_22473333/article/details/113521786