4. C++多线程系统编程精要

第4章 C++多线程系统编程精要

学习多线程编程面临的最大的思维方式的转变有两点: 1. 当前线程可能随时会被切换出去,或者说被抢占(preempt)了。 2. 多线程程序中事件的发生顺序不再有全局统一的先后关系。

当线程被切换回来继续执行下一条语句(指令)的时候,全局数据(包括当前进程在操作系统内核中的状态)可能已经被其他线程修改了。例如,在没有为指针P加锁的情况下,if (p && p->next) /*...*/ 有可能导致segfault,因为在逻辑与(&&)的前一个分支evaluate为true之后的一瞬间,P可能被其他线程置为NULL或是被释放,后一个分支就访问了非法地址。

在单CPU系统中,理论上我们可以通过记录CPU上执行的指令的先后顺序来推演多线程的实际交织(interweaving)运行的情况。在多核系统中,多个线程是并行执行的,我们甚至没有统一的全局时钟来为每个事件编号。在没有适当同步的情况下,多个CPU上运行的多个线程中的事件发生先后顺序是无法确定的。在引入适当同步后,事件之间才有了happens-before关系。

多线程程序的正确性不能依赖于任何一个线程的执行速度,不能通过原地等待(sleep())来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。无论线程执行得快与慢(被操作系统切换出去得越多,执行越慢),程序都应该能正常工作。例如下面这段代码就有这方面的问题。

1
2
3
4
5
6
7
8
9
10
11
12
bool running = false; // 全局标志
void threadFunc() {
while (running)
{ // get task from queue
}
}

void start() {
muduo::Thread t(threadFunc);
t.start();
running = true; // 应该放到t.start()之前。
}

这段代码暗中假定线程函数的启动慢于running变量的赋值,因此线程函数能进入while循环执行我们想要的功能。如果上机测试运行这段代码,十有八九会按我们预期的那样工作。但是,直到有一天,系统负载很高,Thread::start()调用pthread_create陷入内核后返回时,内核决定换另外一个就绪任务来执行。于是running的赋值就推迟了,这时线程函数就可能不进入while循环而直接退出了。

或许有人会认为在while之前加一小段延时(sleep)就能解决问题,但这是错的,无论加多大的延时,系统都有可能先执行while的条件判断,然后再执行running的赋值。正确的做法是把running的赋值放到t.start()之前,这样借助pthread_create()的happens-before语义来保证running的新值能被线程看到。

1. 基本线程原语的选用

我认为用C/C++编写跨平台的多线程程序不是普遍的需求,因此本书只谈现代Linux下的多线程编程。POSIX threads的函数有110多个,真正常用的不过十几个。而且在C++程序中通常会有更为易用的wrapper,不会直接调用Pthreads函数。这11个最基本的Pthreads函数是:

  • 2个:线程的创建和等待结束(join)。封装为muduo::Thread。
  • 4个:mutex的创建、销毁、加锁、解锁。封装为muduo::MutexLock。
  • 5个:条件变量的创建、销毁、等待、通知、广播。封装为muduo::Condition。

严格来说,全局running的赋值和读取应该用mutex或者memory barrier,但不影响这里的讨论。这些封装class都很直截了当,加起来也就一两百行代码,却已经构成了多线程编程的全部必备原语。用这三样东西(thread、mutex、condition)可以完成任何多线程编程任务。当然我们一般也不会直接使用它们(mutex除外),而是使用更高层的封装,例如mutex::ThreadPool和mutex::CountDownLatch等,见第2章。

除此之外,Pthreads还提供了其他一些原语,有些是可以使用、有些则是不推荐使用的。可以使用的有:

  • pthread_once,封装为muduo::Singleton<T>。其实不如直接用全局变量。
  • pthread_key*,封装为muduo::ThreadLocal<T>。可以考虑用__thread替换之。

不推荐使用的有:

  • pthread_rwlock,读写锁通常应慎用。muduo没有封装读写锁,这是有意的。
  • sem_*,避免用信号量(semaphore)。它的功能与条件变量重合,但容易用错。
  • pthread_cancel, kill。程序中出现了它们,则通常意味着设计出了问题。

不推荐使用读写锁的原因是它往往造成提高性能的错觉(允许多个线程并发读),实际上在很多情况下,与使用最简单的mutex相比,它实际上降低了性能。另外,写操作会阻塞读操作,如果要求优化读操作的延迟,用读写锁是不合适的。

多线程系统编程的难点不在于学习线程原语(primitives),而在于理解多线程与现有的C/C++库函数和系统调用的交互关系,以进一步学习如何设计并实现线程安全且高效的程序。

2. 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》。不过我认为不必担心这篇文章提到的问题,标准的滞后不会对实践构成影响。因为从操作系统开始支持多线程到现在已经有近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、strerror_r、strtok_r等等。
  • 传统的fork()并发模型不再适用于多线程程序(\(\S4.9\))。

现在Linux glibc把errno定义为一个宏,注意errno是一个Ivalue,因此不能简单定义为某个函数的返回值,而必须定义为对函数返回指针的dereference。

1
2
extern int* __errno_location(void);
#define errno (*__errno_location())

值得一提的是,操作系统支持多线程已有近20年,早先一些性能方面的缺陷都基本被弥补了。例如最早的SGI STL自已定制了内存分配器,而现在g++自带的STL已经直接使用malloc来分配内存,std::allocator已经变成了鸡肋。原先Google tcmalloc相对于glibc2.3中的ptmalloc2有很大的性能提升,现在最新的glibc中的ptmalloc3已经把差距大大缩小了。

我们不必担心系统调用的线程安全性,因为系统调用对于用户态程序来说是原子的。但是要注意系统调用对于内核状态的改变可能影响其他线程。

与直觉相反,POSIX标准列出的是一份非线程安全的函数的黑名单,而不是一份线程安全的函数的白名单。在这份黑名单中,system、getenv/putenv/setenv等等函数都是不安全的。因此,可以说现在glibc库函数大部分都是线程安全的。特别是FILE*系列函数是安全的,glibc甚至提供了非线程安全的版本,以应对某些特殊场合的性能需求。尽管单个函数是线程安全的,但两个或多个函数放到一起就不再安全了。例如fseek()和fread()都是安全的,但是对某个文件“先seek再read”这两步操作中间有可能会被打断,其他线程有可能趁机修改了文件的当前位置,让程序逻辑无法正确执行。在这种情况下,我们可以用flockfile(FILE*)和funlockfile(FILE*)函数来显式地加锁。并且由于FILE*的锁是可重入的,加锁之后再调用fread()不会造成死锁。

如果程序直接使用lseek(2)和read(2)这两个系统调用来随机读取文件,也存在“先seek再read”这种race condition,但是似乎我们无法高效地对系统调用加锁。解决办法是改用pread(2)系统调用,它不会改变文件的当前位置。

由此可见,编写线程安全程序的一个难点在于线程安全是不可组合的(composable),一个函数foo()调用了两个线程安全的函数,而这个foo()函数本身很可能不是线程安全的。即便现在大多数glibc库函数是线程安全的,我们也不能像写单线程程序那样编写代码。例如,在单线程程序中,如果我们要临时转换时区,可以用tzset()函数,这个函数会改变程序全局的“当前时区”。

获取伦敦的当前时间:

1
2
3
4
5
6
7
8
string oldTz = getenv("TZ");	//save TZ,assumeing non-NULL
putenv("TZ=Europe/London"); // set TZ to London
tzset(); // load London time zone
struct tm localTimeInLN;
time_t now = time(NULL); // get time in UTC
localtime_r(&now, &localTimeInLN); // convert to London local time
setenv("TZ", oldTz.c_str(), 1); // restore old TZ
tzset(); // local old time zone

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

1
2
3
4
5
6
7
8
9
10
11
12
13
class TimeZone {
public:
explicit TimeZone(const char* zonefile);
struct tm toLocalTime(time_t secondsSinceEpoch) const;
time_t fromLocalTime(const struct tm&) const;
// default copy ctor/assignment/dtor are okay.
};

const TimeZone kNewYorkTz("/usr/share/zoneinfo/America/New_York");
const TimeZone kLondonTz("/usr/share/zoneinfo/Europe/London");
time_t now = time(NULL);
struct tm localTimeInNY = kNewYorkTz.toLocalTime(now);
struct tm localTimeInLN = kLondonTz.toLocalTime(now);

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

尽管C++03标准没有明说标准库的线程安全性,但我们可以遵循一个基本原则:凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到,那么它就是安全的。另外一个事实标准是:共享的对象的read-only操作是安全的,前提是不能有并发的写操作。例如两个线程各自访问自己的局部vector对象是安全的;同时访问共享的const vector对象也是安全的,但是这个vector不能被第三个线程修改。一旦有writer,那么read-only操作也必须加锁,例如vector::size()。

根据\(\S1.1.1\)对线程安全的定义,C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销,另一方面的原因是单个成员函数的线程安全并不具备可组合性(composable)。假设有safe_vector<T> class,它的接口与std::vector相同,不过每个成员函数都是线程安全的(类似Java的synchronized方法)。但是用safe_vector<T>并不一定能写出线程安全的代码。例如:

1
2
3
safe_vector<int> vec; // 全局可见
if (vec.empty()) // 没有加锁保护
int x = vec[0]; // 这两步在多线程下是不安全的

在if语句判断vec非空之后,别的线程可能清空其元素,从而造成vec[0]失效。

C++标准库中的绝大多数泛型算法是线程安全的,因为这些都是无状态纯函数。只要输入区间是线程安全的,那么泛型函数就是线程安全的。

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

1
std::cout << "Now is " << time(NULL);

等价于两个函数调用

1
2
std::cout.operator<< ("Now is ")
operator<< (time(NULL));

即便ostream::operator<<()做到了线程安全,也不能保证其他线程不会在两次函数调用之前向stdout输出其他字符。

对于“线程安全的stdout输出”这个需求,我们可以改用printf,以达到安全性和输出的原子性。但是这等于用了全局锁,任何时刻只能有一个线程调用printf,恐怕不见得高效。在多线程程序中高效的日志需要特殊设计,见第5章。

3. Linux上的线程标识

POSIX threads库提供了pthread_self函数用于返回当前进程的标识符,其类型为pthread_tpthread_t不一定是一个数值类型(整数或指针),也有可能是一个结构体,因此Pthreads专门提供了pthread_equal函数用于对比两个线程标识符是否相等。这就带来一系列问题,包括:

  • 无法打印输出pthread_t,因为不知道其确切类型。也就没法在日志中用它表示当前线程的id。
  • 无法比较pthread_t的大小或计算其hash值,因此无法用作关联容器的key。
  • 无法定义一个非法的pthread_t值,用来表示绝对不可能存在的线程id,因此MutexLock类没有办法有效判断当前线程是否已经持有本锁。
  • pthread_t值只在进程内有意义,与操作系统的任务调度之间无法建立有效关联。比方说在/proc文件系统中找不到pthread_t对应的task。
  • 另外,glibc的Pthreads实现实际上把pthread_t用作一个结构体指针(它的类型是unsigned long),指向一块动态分配的内存,而且这块内存是反复使用的。这就造成pthread_t的值很容易重复。Pthreads只保证同一进程之内,同一时刻的各个线程的不同;不能保证同一进程先后多个线程具有不同的,更不要说一台机器上多个进程之间的id唯一性了。

例如下面这段代码中先后两个线程的标识符是相同的:

1
2
3
4
5
6
7
8
9
10
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, threadFunc, NULL);
printf("%lx\n", t1);
pthread_join(t1, NULL);

pthread_create(&t2, NULL, threadFunc, NULL);
printf("%lx\n", t2);
pthread_join(t2, NULL);
}

一次运行结果如下:

1
2
3
./a.out
7fad11787700
7fad11787700

因此,pthread_t并不适合用作程序中对线程的标识符。

在Linux上,我建议使用gettid()系统调用的返回值作为线程id,这么做的好处有:

  • 它的类型是pid_t,其值通常是个小整数,便于在日志中输出。
  • 在现代Linux中,它直接表示内核的任务调度id,因此在/proc文件系统中可以轻易找到对应项:/proc/tid/proc/pid/task/tid
  • 在其他系统工具中也容易定位到具体某一个线程,例如在top(1)中我们可以按线程列出任务,然后找出CPU使用率最高的线程id,再根据程序日志判断到底是哪一个线程在耗用CPU。
  • 任何时刻都是全局唯一的,并且由于Linux分配新pid采用递增轮回办法,短时间内启动的多个线程也会具有不同的线程id。
  • 0是非法值,因为操作系统第一个进程init的pid是1。

但是glibc并没有封装这个系统调用,需要我们自己实现。封装gettid()很简单,但是每次都执行一次系统调用似乎有些浪费,如何才能做到更高效呢?muduo::CurrentThread::tid()采取的办法是用_thread变量来缓存gettid()的返回值,这样只有在本线程第一次调用的时候才进行系统调用,以后都是直接从thread local缓存的线程id拿到结果,效率无忧。多线程程序在打日志的时候可以不必担心有效率损失。还有一个小问题,万一程序执行了fork(),那么子进程会不会看到stale的缓存结果呢?解决办法是用pthread_atfork(注册一个回调,用于清空缓存的线程id。具体代码见muduo/base/CurrentThread.h和Thread.cc)

4. 线程的创建与销毁的守则

线程的创建和销毁是编写多线程程序的基本要素,线程的创建比销毁要容易得多,只需要遵循几条简单的原则:

  • 程序库不应该在未提前告知的情况下创建自己的“背景线程”。
  • 尽量用相同的方式创建线程,例如muduo::Thread。
  • 在进入main函数之前不应该启动线程
  • 程序中线程的创建最好能在初始化阶段全部完成。

以下分别谈一谈这几个观点。

线程是稀缺资源,一个进程可以创建的并发线程数目受限于地址空间的大小和内核参数,一台机器可以同时并行运行的线程数目受限于CPU的数目。因此我们在设计一个服务端程序的时候要精心规划线程的数目,特别是根据机器的CPU数目来设置工作线程的数目,并为关键任务保留足够的计算资源。如果程序库在背地里使用了额外的线程来执行任务,我们这种资源规划就漏算了。可能会导致高估系统的可用资源,结果处理关键任务不及时,达不到预设的性能指标。

还有一个重要原因是,一旦程序中有不止一个线程,就很难安全地fork()了\(\S4.9\))。因此“库”不能偷偷创建线程。如果确实有必要使用背景线程,至少应该让使用者知道。另外,如果有可能,可以让使用者在初始化库的时候传入线程池或event loop对象,这样程序可以统筹线程的数目和用途,避免低优先级的任务独占某个线程。

理想情况下,程序里的线程都是用同一个class创建的(muduo::Thread),这样容易在线程的启动和销毁阶段做一些统一的簿记工作。比如说调用一次muduo::CurrentThread::tid()把当前线程id缓存起来,以后再取线程id就不会陷入内核了。也可以统计当前有多少活动线程,进程一共创建了多少线程,每个线程的用途分别是什么。C/C++的线程不像Java线程那样有名字,但是我们可以通过Thread class实现类似的效果。如果每个线程都是通过muduo::Thread启动的,这些都不难做到。必要的话可以写一个ThreadManager singleton class,用它来记录当前活动线程,可以方便调试与监控。

但是这不是总能做到的,有些第三方库(C语言库)会自已启动线程,这样的“野生”线程就没有纳人全局的ThreadManager管理之中。muduo::CurrentThread::tid()必须要考虑被这种“野生”线程调用的可能,因此它必须每次都检查缓存的线程id是否有效,而不能假定在线程启动阶段已经缓存好了id,直接返回缓存值就行了。如果库提供异步回调,一定要明确说明会在哪个(哪些)线程调用用户提供的回调函数,这样用户可以知道在回调函数中能不能执行耗时的操作,会不会阻塞其他任务的执行。

在main()函数之前不应该启动线程,因为这会影响全局对象的安全构造。我们知道,C++保证在进入main()之前完成全局对象的构造。同时,各个编译单元之间的对象构造顺序是不确定的,我们也有一些办法来影响初始化顺序,保证在初始化某个全局对象时使用到的其他全局对象都是构造完成的。但无论如何这些全局对象的构造是依次进行的,都在主线程中完成,无须考虑并发与线程安全。如果其中一个全局对象创建了线程,那就危险了。因为这破坏了初始化全局对象的基本假设。万一将来代码改动之后造成该线程访问了未经初始化的全局对象,那么这种隐匿错误查起来就很费劲了。或许你想用锁来保证全局对象初始化完成,但是怎么保证这个全局的锁对象的构造能在线程启动之前完成呢?因此,全局对象不能创建线程。如果一个库需要创建线程,那么应该进入main函数之后再调用库的初始化函数去做。

不要为了每个计算任务,每次请求去创建线程。一般也不会为每个网络连接创建线程,除非并发连接数与CPU数相近。一个服务程序的线程数目应该与当前负载无关,而应该与机器的CPU数目有关,即load average有比较小(最好不大于CPU数目)的上限。这样尽量避免出现thrashing,不会因为负载急剧增加而导致机器失去正常响应。这么做的重要原因是,在机器失去响应期间,我们无法探查它究竟在做什么,也没办法立刻终止有问题的进程,防止损害进一步扩大。如果有实时性方面的需求,线程数目不应该超过CPU数目,这样可以基本保证新任务总能及时得到执行,因为总有CPU是空闲的。最好在程序的初始化阶段创建全部工作线程,在程序运行期间不再创建或销毁线程。借助muduo::ThreadPool和muduo::EventLoop,我们很容易就能把计算任务和IO任务分配到已有的线程,代价只有新建线程的几分之一。

线程的销毁有几种方式:

  1. 自然死亡。从线程主函数返回,线程正常退出。
  2. 非正常死亡。从线程主函数抛出异常或线程触发segfault信号等非法操作。
  3. 自杀。在线程中调用pthread_exit来立刻退出线程。
  4. 他杀。其他线程调用pthread_cancel()来强制终止某个线程。

pthread_kill()是往线程发信号,留到\(\S4.10\)再讨论。

线程正常退出的方式只有一种,即自然死亡。任何从外部强行终止线程的做法和想法都是错的。佐证有:Java的Thread class把stop(), suspend(), destroy()等函数都废弃(deprecated)了,Boost.Threads根本就不提供thread::cancel()成员函数。因为强行终止线程的话(无论是自杀还是他杀),它没有机会清理资源,也没有机会释放已经持有的锁,其他线程如果再想对同一个mutex加锁,那么就会立刻死锁。因此我认为不用去研究cancellation point这种“鸡肋”概念。如果确实需要强行终止一个耗时很长的计算任务,而又不想在计算期间周期性地检查某个全局退出标志,那么可以考虑把那一部分代码fork()为新的进程,这样杀(kill())一个进程比杀本进程内的线程要安全得多。当然,fork()的新进程与本进程的通信方式也要慎重选取,最好用文件描述符(pipe()/socketpair()/TCP socket)来收发数据,而不要用共享内存和跨进程的互斥器等IPC,因为这样仍然有死锁的可能。

muduo::Thread不是传统意义上的RAII class,因为它析构的时候没有销毁持有的Pthreads线程句柄(pthread_t),也就是说Thread的析构不会等待线程结束。一般而言,我们会让Thread对象的生命期长于线程,然后通过Thread::join()来等待线程结束并释放线程资源。如果Thread对象的生命期短于线程,那么就没有机会释放pthread_t了。muduo::Thread没有提供detach()成员函数,因为我不认为这是必要的。

最后,我认为如果能做到前面提到的“程序中线程的创建最好能在初始化阶段全部完成”,则线程是不必销毁的,伴随进程一直运行,彻底避开了线程安全退出可能面临的各种困难,包括Thread对象生命期管理、资源释放等等。

4.1 pthread_cancel与C++

POSIX threads有cancellation point这个概念,意思是线程执行到这里有可能会被终止(cancel)(如果别的线程对它调用了pthread_cancel()的话)。POSIX标准列出了必须或者可能是cancellation point的函数。

在C++中,cancellation point的实现与C语言有所不同,线程不是执行到此函数就立刻终止,而是该函数会抛出异常。这样可以有机会执行stack unwind,析构栈上对象(特别是释放持有的锁)。如果一定要使用cancellation point,建议读一读Ulrich Drepper写的Cancellation and C++ Exceptions这篇短文。不过按我的观点,不应该从外部杀死线程。

4.2 exit(3)在C++中不是线程安全的

exit()函数在C++中的作用除了终止进程,还会析构全局对象和已经构造完的函数静态对象。这有潜在的死锁可能,考虑下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void someFunctionMayCallExit() {
exit(1);
}

class GlobalObject : boost::noncopyable {
public:
void doit() {
MutexLockGuard lock(mutex_);
someFunctionMayCallExit();
}
~GlobalObject() {

MutexLockGuard lock(mutex_); // 此处发生死锁
// cleanup

}
private:
MutexLock mutex_;
};

GlobalObject g_obj;
int main() {
g_obj.doit();
}

GlobalObject::doit()函数间接调用了exit(),从而触发了全局对象g_obj的析构。GlobalObject的析构函数会试图加锁mutex_,而此时mutex_已经被GlobalObject::doit()锁住了,于是造成了死锁。

再举一个调用纯虚函数导致程序崩溃的例子。假如有一个策略基类,在运行时我们会根据情况使用不同的无状态策略(派生类对象)。由于策略是无状态的,因此可以共享派生类对象,不必每次都新建。这里以日历(calendar)基类和不同国家的假日(AmericanCalendar和BritishCalendar)为例,factory函数返回某个全局对象的引用,而不是每次都创建新的派生类对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Calendar : boost::noncopyable {
public:
virtual bool isHoliday(muduo::Date d) const = 0;
virtual ~Calendar() {}
};

class AmericanCalendar : public Calendar {
public:
virtual bool isHoliday(muduo::Date d) const;
};

class BritishCalendar : public Calendar {
public:
virtual bool isHoliday(muduo::Date d) const;
};

AmericanCalendar americanCalendar; // 全局对象
BritishCalendar britishCalendar;

// factory method returns americanCalendar or britishCalendar
Calendar& getCalendar(const string& region);

void processRequest(const Request& req) {
Calendar& calendar = getCalendar(req.region);
if (calendar.isHoliday(req.settlement_date))
// do something
}

通常的使用方式是通过factory拿到具体国家的日历,再判断某一天是不是假期:这一切都工作得很好,直到有一天我们想主动退出这个服务程序,于是某个线程调用了exit(),析构了全局对象,结果造成另一个线程在调用Calendar::isHoliday时发生崩溃:

1
2
3
pure virtual method called
terminate called without an active exception
Aborted (core dumped)

当然,这只是举例说明“用全局对象实现无状态策略”在多线程中析构可能有危险。在真实的项目中,Calendar应该在运行的时候从外部配置读入,而不能写死在代码中。

这其实不是exit()的过错,而是全局对象析构的问题。C++标准没有照顾全局对象在多线程环境下的析构,据我看似乎也没有更好的办法。如果确实需要主动结束线程,则可以考虑用_exit()系统调用。它不会试图析构全局对象,但是也不会执行其他任何清理工作,比如flush标准输出。

由此可见,安全地退出一个多线程的程序并不是一件容易的事情。何况这还没有涉及如何安全地退出其他正在运行的线程,这需要精心设计共享对象的析构顺序,防止各个线程在退出时访问已失效的对象。在编写长期运行的多线程服务程序的时候,可以不必追求安全地退出,而是让进程进入拒绝服务状态,然后就可以直接杀掉了。

5. 善用_thread关键字

_thread是GCC内置的线程局部存储设施(thread-local storage)。它的实现非常高效,比pthread_key_t快很多,见Ulrich Drepper写的《ELF Handling For Thread-Local Storage》。_thread变量的存取效率可与全局变量相比

1
2
int g_var; // 全局变量
_thread int t_var; // thread变量

_thread使用规则:

  • 只能用于修饰POD类型,不能修饰class类型,因为无法自动调用构造函数和析构函数。
  • _thread可以用于修饰全局变量、函数内的静态变量,但是不能用于修饰函数的局部变量或者class的普通成员变量。
  • _thread变量的初始化只能用编译期常量。

Tips:

POD(Plain Old Data)类型是C++中一个非正式的术语,用来描述那些满足以下条件的数据类型:

  1. 没有构造函数和析构函数:POD类型没有自定义的构造函数和析构函数,它们只有默认的构造、析构行为。

  2. 没有虚函数:POD类型不能包含虚函数,因为虚函数需要一个虚函数表(vtable),而POD类型不包含这样的额外信息。

  3. 没有成员函数:POD类型没有成员函数。

  4. 没有基类:POD类型不能有继承自其他类的基类。

  5. 没有非静态数据成员:POD类型的所有数据成员都必须是POD类型。

  6. 没有引用成员:POD类型不能包含引用类型的成员。

  7. 没有位字段:POD类型不能包含位字段。

  8. 标准布局:POD类型的布局必须是标准的,这意味着它的数据成员按照它们在类中的声明顺序排列,没有额外的填充。

在C++中,基本数据类型(如intfloatdouble等)和数组都是POD类型。此外,由这些基本数据类型组成的结构体或联合体,如果满足上述条件,也可以是POD类型。

需要注意的是,C++11标准引入了trivialstandard-layoutPODliteral类型的概念,其中POD类型是指那些可以用位拷贝(bitwise copy)和位比较(bitwise comparison)来操作的类型。在C++11中,POD类型是trivialstandard-layout类型的子集,意味着所有POD类型都是平凡的(trivial)和标准布局的(standard-layout),但并非所有平凡或标准布局的类型都是POD类型。


_thread变量是每个线程有一份独立实体,各个线程的变量值互不干扰。除了这个主要用途,它还可以修饰那些“值可能会变,带有全局性,但是又不值得用全局锁保护”的变量。muduo代码中用到了好几处_thread,简单列举如下:

  • muduo/base/Logging.cc缓存最近一条日志时间的年月日时分秒,如果一秒之内输出多条日志,可避免重复格式化。另外,muduo::strerror_tl把strerror_r做成如同strerror一样好用,而且是线程安全的。
  • muduo/base/ProcessInfo.cc用线程局部变量来简化::scandir()的使用。
  • muduo/base/Thread.cc缓存每个线程的id。
  • muduo/net/EventLoop.cc用于判断当前线程是否只有一个EventLoop对象。

以上例子都是_thread修饰POD类型的变量。

如果要用到thread-local的class对象,可以考虑使用muduo::ThreadLocal<T>和muduo::ThreadLocalSingleton<T>这两个class,它能在线程退出时销毁class对象。例如examples/asio/chat/server_threadedhighperformance.cc用ThreadLocalSingleton来保存每个EventLoop线程所管辖的客户连接,以实现高效的消息转发。

6. 多线程与IO

这可算是本章最为关键的一节。本书只讨论同步IO,包括阻塞与非阻塞,不讨论异步IO(AIO)。在进行多线程网络编程的时候,几个自然的问题是:如何处理IO?能否多个线程同时读写同一个socket文件描述符?我们知道用多线程同时处理多个socket通常可以提高效率,那么用多线程处理同一个socket也可以提高效率吗?

首先,操作文件描述符的系统调用本身是线程安全的,我们不用担心多个线程同时操作文件描述符会造成进程崩溃或内核崩溃。

但是,多个线程同时操作同一个socket文件描述符确实很麻烦,我认为是得不偿失的。需要考虑的情况如下:

  • 如果一个线程正在阻塞地read(2)某个socket,而另一个线程close(2)了此socket。
  • 如果一个线程正在阻塞地accept(2)某个listening socket,而另一个线程close(2)了此socket。
  • 更糟糕的是,一个线程正准备read(2)某个socket,而另一个线程close(2)了此socket;第三个线程恰好open(2)了另一个文件描述符,其fd号码正好与前面的socket相同。这样程序的逻辑就混乱了。

我认为以上这几种情况都反映了程序逻辑设计上有问题。

现在假设不考虑关闭文件描述符,只考虑读和写,情况也不见得多好。因为socket读写的特点是不保证完整性,读100字节有可能只返回20字节,写操作也是一样的。

  • 如果两个线程同时read同一个TCP socket,两个线程几乎同时各自收到一部分数据,如何把数据拼成完整的消息?如何知道哪部分数据先到达?
  • 如果两个线程同时write同一个TCP socket,每个线程都只发出去半条消息,那接收方收到数据如何处理?
  • 如果给每个TCP socket配一把锁,让同时只能有一个线程读或写此socket,似乎可以“解决”问题,但这样还不如直接始终让同一个线程来操作此socket来得简单。
  • 对于非阻塞IO,情况是一样的,而且收发消息的完整性与原子性几乎不可能用锁来保证,因为这样会阻塞其他IO线程。

如此看来,理论上只有read和write可以分到两个线程去,因为TCP socket是双向IO。问题是真的值得把read和write拆开成两个线程吗?

以上讨论的都是网络IO,那么多线程可以加速磁盘IO吗?首先要避免lseek(2)/read(2)的race condition。做到这一点之后,据我看,用多个线程read或write同一个文件也不会提速。不仅如此,多个线程分别read或write同一个磁盘上的多个文件也不见得能提速。因为每块磁盘都有一个操作队列,多个线程的读写请求到了内核是排队执行的。只有在内核缓存了大部分数据的情况下,多线程读这些热数据才可能比单线程快。多线程磁盘IO的一个思路是每个磁盘配一个线程,把所有针对此磁盘的IO都挪到同一个线程,这样或许能避免或减少内核中的锁争用。我认为应该用“显然是正确”的方式来编写程序,一个文件只由一个进程中的一个线程来读写,这种做法显然是正确的。

为了简单起见,我认为多线程程序应该遵循的原则是:每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition。一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。这一点不难做到,muduo网络库已经把这些细节封装了。

epoll也遵循相同的原则。Linux文档并没有说明:当一个线程正阻塞在epoll_wait()上时,另一个线程往此epollfd添加一个新的监视fd会发生什么。新fd上的事件会不会在此次epoll_wait()调用中返回?为了稳妥起见,我们应该把对同一个epollfd的操作(添加、删除、修改、等待)都放到同一个线程中执行,这正是我们需要muduo::EventLoop::wakeup()的原因。

当然,一般的程序不会直接使用epoll、read、write,这些底层操作都由网络库代劳了。

这条规则有两个例外:对于磁盘支持,在必要的时候多个线程可以同时调用pread(2)/pwrite(2)来读写同一个文件;对于UDP,由于协议本身保证消息的原子性,在适当的条件下(比如消息之间彼此独立)可以多个线程同时读写同一个UDP文件描述符。

7. 用RAII包装文件描述符

本节谈一谈在多线程程序中如何管理文件描述符。Linux的文件描述符(fd)是小整数,在程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。这时如果我们新打开一个文件,它的文件描述符会是3,因为POSIX标准要求每次新打开文件(含socket)的时候必须使用当前最小可用的文件描述符号码码。POSIX这种分配文件描述符的方式稍不注意就会造成串话。比如前面举过的例子,一个线程正准备read(2)某个socket,而第二个线程几乎同时close(2)了此socket;第三个线程又恰好open(2)了另一个文件描述符,其号码正好与前面的socket相同(因为比它小的号码都被占用了)。这时第一个线程可能会读到不属于它的数据,不仅如此,还把第三个线程的功能也破坏了,因为第一个线程把数据读走了(TCP连接的数据只能读一次,磁盘文件会移动当前位置)。另外一种情况,一个线程从fd=8收到了比较耗时的请求,它开始处理这个请求,并记住要把响应结果发给fd=8。但是在处理过程中,fd=8断开连接,被关闭了,又有新的连接到来,碰巧使用了相同的fd=8。当线程完成响应的计算,把结果发给fd=8时,接收方已经物是人非,后果难以预料。

在单线程程序中,或许可以通过某种全局表来避免串话:在多线程程序中,我不认为这种做法会是高效的(通常意味着每次读写都要对全局表加锁)。

在C++里解决这个问题的办法很简单:RAII。用Socket对象包装文件描述符,所有对此文件描述符的读写操作都通过此对象进行,在对象的析构函数里关闭文件描述符。这样一来,只要Socket对象还活着,就不会有其他Socket对象跟它有一样的文件描述符,也就不可能串话。剩下的问题就是做好多线程中的对象生命期管理,这在第1章已经完美解决了。

引申问题:为什么服务端程序不应该关闭标准输出(fd=1)和标准错误(fd=2)?因为有些第三方库在特殊紧急情况下会在stdout或stderr打印出错信息,如果我们的程序关闭了标准输出(fd=1)和标准错误(fd=2),这两个文件描述符有可能被网络连接占用,结果造成对方收到莫名其妙的数据。正确的做法是把stdout或stderr重定向到磁盘文件(最好不要是/dev/null),这样我们不至于丢失关键的诊断信息。当然,这应该由启动服务程序的看门狗进程完成,对服务程序本身是透明的。

现代C++的一个特点是对象生命期管理的进步,体现在不需要手工delete对象。在网络编程中,有的对象是长命的(例如TcpServer),有的对象是短命的(例如TcpConnection)。长命的对象的生命期往往和整个程序一样长,那就很容易处理,直接使用全局对象(或scoped_ptr)或者做成main()的栈上对象都行。对于短命的对象,其生命期不一定完全由我们控制,比如对方客户端断开了某个TCP socket,它对应的服务端进程中的TcpConnection对象(其必然是个heap对象,不可能是stack对象)的生命也即将走到尽头。但是这时我们并不能立刻delete这个对象,因为其他地方可能还持有它的引用,贸然delete会造成空悬指针。只有确保其他地方没有持有该对象的引用的时候,才能安全地销毁对象,这自然会用到引用计数。在多线程程序中,安全地销毁对象不是一件轻而易举的事情,见第1章。

在非阻塞网络编程中,我们常常要面临这样一种场景:从某个TCP连接A收到一个request,程序开始处理这个request;处理可能要花一定的时间,为了避免耽误(阻塞)处理其他request,程序记住了发来request的TCP连接,在某个线程池中处理这个请求;在处理完之后,会把response发回TCP连接A。但是,在处理request的过程中,客户端断开了TCP连接A,而另一个客户端刚好创建了新连接B。我们的程序不能只记住TCP连接A的文件描述符,而应该持有封装socket连接的TcpConnection对象,保证在处理request期间TCP连接A的文件描述符不会被关闭。或者持有TcpConnection对象的弱引用(weak_ptr),这样能知道socket连接在处理request期间是否已经关闭了,fd=8的文件描述符到底是“前世”还是“今生”。否则的话,旧的TCP连接A一断开,TcpConnection对象销毁,关闭了旧的文件描述符(RAII),而且新连接B的socket文件描述符有可能等于之前断开的TCP连接(这是完全可能的,POSIX要求每次新建文件描述符时选取当前最小的可用的整数。当程序处理完旧连接的request时,就有可能把response发给新的TCP连接B,造成串话。

为了应对这种情况,防止访问失效的对象或者发生网络串话,muduo使用shared_ptr来管理TcpConnection的生命期。这是唯一一个采用引用计数方式管理生命期的对象。如果不用shared_ptr,我想不出其他安全且高效的办法来管理多线程网络服务端程序中的并发连接。

8. RAII与fork()

在编写C++程序的时候,我们总是设法保证对象的构造和析构是成对出现的,否则就几乎一定会有内存泄漏。在现代C++中,这一点不难做到。利用这一特性,我们可以用对象来包装资源,把资源管理与对象生命期管理统一起来(RAII)。但是,假如程序会fork(),这一假设就会被破坏了。考虑下面这个例子,Foo对象构造了一次,但是析构了两次。

1
2
3
4
5
6
7
int main() {
Foo foo; // 调用构造函数
fork();
// fork为两个进程
foo.doit(); // 在父子进程中都使用foo
// 析构函数会被调用两次,父进程和子进程各一次
}

如果Foo类封装了某种资源,而这个资源没有被子进程继承,那么Foo::doit()的功能在子进程中是错误的。而我们没有办法自动预防这一点,总不能每次申请一个资源就去调用一次pthread_atfork()吧?

fork之后,子进程继承了父进程的几乎全部状态,但也有少数例外。子进程会继承地址空间和文件描述符,因此用于管理动态内存和文件描述符的RAII类都能正常工作。但是子进程不会继承:

  • 父进程的内存锁,mlock()、mlockall()。
  • 父进程的文件锁,fcntl()。
  • 父进程的某些定时器,setitimer()、alarm()、timer_create()等等。

通常我们会用RAII技法来管理以上种类的资源(加锁解锁、创建销毁定时器等等),但是在fork()出来的子进程中不一定正常工作,因为资源在fork()时已经被释放了。比方说用RAII技法封装timer_create()/timer_delete,在子进程中析构函数调用timer_delete可能会出错,因为试图释放一个不存在的资源。或者更糟糕地把其他对象持有的timer给释放了(如果碰巧新建的timer_t与之重复的话)。

因此,我们在编写服务端程序的时候,“是否允许fork()”是在一开始就应该慎重考虑的问题,在一个没有为fork()做好准备的程序中使用fork(),会遇到难以预料的问题。

9. 多线程与fork()

多线程与fork()的协作性很差。这是POSIX系列操作系统的历史包袱。因为长期以来进程都是单线程的,fork()运转正常。当20世纪90年代初期引入多线程之后,fork()的适用范围大为缩减,fork()一般不能在多线程程序中调用,因为Linux的fork()只克隆当前线程的thread of control,不克隆其他线程。fork()之后,除了当前线程之外,其他线程都消失了。也就是说不能一下子fork()出一个和父进程一样的多线程子进程。Linux没有forkall()这样的系统调用,forkall()其实也是很难办的(从语义上),因为其他线程可能等在condition variable上,可能阻塞在系统调用上,可能等着mutex以跨入临界区,还可能在密集的计算中,这些都不好全盘搬到子进程里。

Fork之后子进程中只有一个线程,其他线程都消失了,这就造成一个危险的局面。其他线程可能正好位于临界区之内,持有了某个锁,而它突然死亡,再也没有机会去解锁了。如果子进程试图再对同一个mutex加锁,就会立刻死锁。在fork()之后,子进程就相当于处于signal handler之中,你不能调用线程安全的函数(除非它是可重入的),而只能调用异步信号安全(async-signal-safe)的函数。比方说,fork()之后,子进程不能调用:

  • malloc()。因为malloc()在访问全局状态时几乎一定会加锁。
  • 任何可能分配或释放内存的函数,包括new、map::insert()、snprintf等等。
  • 任何Pthreads函数。你不能用pthread_cond_signal()去通知父进程,只能通过读写pipe()来同步。
  • printf()系列函数,因为其他线程可能恰好持有stdout/stderr的锁。
  • 除了man 7 signal中明确列出的"signal安全"函数之外的任何函数。

照此看来,唯一安全的做法是在fork()之后立即调用exec()执行另一个程序,彻底隔断子进程与父进程的联系。

不得不说,同样是创建进程,Windows的CreateProcess()函数的顾虑要少得多,因为它创建的进程跟当前进程关联较少。

10. 多线程与signal

Linux/Unix的信号(signal)与多线程可谓是水火不容。在单线程时代,编写信号处理函数(signal handler)就是一件棘手的事情,由于signal打断了正在运行的thread of control,在signal handler中只能调用async-signal-safe的函数,即所谓的“可重入(reentrant)”函数,就好比在DOS时代编写中断处理例程(ISR)一样。不是每个线程安全的函数都是可重入的,见\(\S4.9\)举的例子。

还有一点,如果signal handler中需要修改全局数据,那么被修改的变量必须是sig_atomic_t类型的,否则被打断的函数在恢复执行后很可能不能立刻看到signal handler改动后的数据,因为编译器有可能假定这个变量不会被他处修改,从而优化了内存访问。

在多线程时代,signal的语义更为复杂。信号分为两类:发送给某一线程(例如SIGSEGV),发送给进程中的任一线程(例如SIGTERM),还要考虑掩码(mask)对信号的屏蔽等。特别是在signal handler中不能调用任何Pthreads函数,不能通过condition variable来通知其他线程。

在多线程程序中,使用signal的第一原则是不要使用signal,包括:

  • 不要用signal作为IPC的手段,包括不要用SIGUSR1等信号来触发服务端的行为。如果确实需要,可以用\(\S9.5\)介绍的增加监听端口的方式来实现双向的、可远程访问的进程控制。
  • 也不要使用基于signal实现的定时函数,包括alarm/ualarm/setitimer/timer_create,sleep/usleep等等。
  • 不主动处理各种异常信号(SIGTERM、SIGINT等等),只用默认语义:结束进程。
  • 有一个例外:SIGPIPE,服务器程序通常的做法是忽略此信号,否则如果对方断开连接,而本机继续write的话,会导致程序意外终止。
  • 在没有别的替代方法的情况下(比方说需要处理SIGCHLD信号),把异步信号转换为同步的文件描述符事件。传统的做法是在signal handler里往一个特定的pipe写一个字节,在主程序中从这个pipe读取,从而纳入统一的IO事件处理框架中去。现代Linux的做法是采用signalfd()把信号直接转换为文件描述符事件,从而从根本上避免使用signal handler。

11. Linux新增系统调用的启示

本节的内容源自我的一篇同名博客,省略了signalfd、timerfd、eventfd等内容,对此感兴趣的读者可阅读原文。

大致从Linux内核2.6.27起,凡是会创建文件描述符的syscall一般都增加了额外的flag参数,可以直接指定O_NONBLOCK和FD_CLOEXEC,例如:

  • accept4 (2.6.28)
  • eventfd2 (2.6.27)
  • inotify_init1 (2.6.27)
  • pipe2 (2.6.27)
  • signalfd4 (2.6.27)
  • timerfd_create (2.6.25)

以上6个syscall,除了最后一个是2.6.25的新功能,其余的都是增强原有的调用,把数字尾号去掉就是原来的syscall。

O_NONBLOCK的功能是开启“非阻塞IO”,而文件描述符默认是阻塞的。这些创建文件描述符的系统调用能直接设定O_NONBLOCK选项,其或许能反映当前Linux(服务端)开发的风向,即我在\(\S3.3\)里推荐的one loop per thread + (non-blocking IO with IO multiplexing)。从这些内核改动来看,non-blocking IO已经主流到让内核增加syscall以节省一次fcntl()调用的程度了。

另外,以下新系统调用可以在创建文件描述符时开启FD_CLOEXEC选项:

  • dup3 (2.6.27)
  • epoll_create1 (2.6.27)
  • socket (2.6.27)

FD_CLOEXEC的功能是让程序exec()时,进程会自动关闭这个文件描述符。而文件描述符默认是被子进程继承的(这是传统Unix的一种典型IPC,比如用pipe()在父子进程间单向通信)。

以上8个新syscall都允许直接指定FD_CLOEXEC,或许说明fork()的主要目的已经不再是创建worker processes并通过共享的文件描述符和父进程保持通信,而是像Windows的CreateProcess那样创建“干净”的进程(fork()之后立刻exec(),其与父进程没有多少瓜葛)。为了回避fork()+exec()之间文件描述符泄漏的race condition,这才在几乎所有能新建文件描述符的系统调用上引入了FD_CLOEXEC参数,参见Ulrich Drepper的短文《Secure File Descriptor Handling》。

以上两个flags在我看来,说明Linux服务器开发的主流模型正在由fork() + worker processes模型转变为第3章推荐的多线程模型。fork()的使用频度会大大降低,将来或许只有专门负责启动别的进程的“看门狗程序”才会调用fork(),而一般的网络服务器程序不会再fork出子进程了。原因之一是,fork()一般不能在多线程程序中调用。

12. 总结

编写多线程C++程序的原则如下:

  • 线程是宝贵的,一个程序可以使用几个或十几个线程。一台机器上不应该同时运行几百个、几千个用户线程,这会大大增加内核scheduler的负担,降低整体性能。
  • 线程的创建和销毁是有代价的,一个程序最好在一开始创建所需的线程,并一直反复使用。不要在运行期间反复创建、销毁线程,如果必须这么做,其频度最好能降到1分钟1次(或更低)。
  • 每个线程应该有明确的职责,例如IO线程(运行EventLoop::loop(),处理IO事件)计算线程(位于ThreadPool中,负责计算)等等。
  • 线程之间的交互应该尽量简单,理想情况下,线程之间只用消息传递(例如BlockingQueue)方式交互。如果必须用锁,那么最好避免一个线程同时持有两把或更多的锁,这样可彻底防止死锁。
  • 要预先考虑清楚一个mutable shared对象将会暴露给哪些线程,每个线程是读还是写,读写有无可能并发进行。

4. C++多线程系统编程精要
http://binbo-zappy.github.io/2024/12/24/muduo多线程/4-C++多线程系统编程精要/
作者
Binbo
发布于
2024年12月24日
许可协议