第4章 C++多线程系统编程精要
4.1 引言
学习多线程编程面临的最大的思维方式的转变有以下两点:
- 当前线程可能随时会被切换出去,或者说被抢占(preempt)了
- 多线程程序中事件的发生顺序不再有全局统一的先后关系
多线程程序的正确性不能依赖于任何一个线程的执行速度,不能通过原地等待(sleep())来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。无论线程执行得快与慢(被操作系统切换出去得越多,执行越慢),程序都应该能正常工作。
例如下面这段代码就有这方面的问题。
CPP
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的新值能被线程看到。
4.2 基本线程原语的选用
POSIX threads的函数有110多个,真正常用的不过十几个。而且在C++程序中通常会有更为易用的 wrapper,不会直接调用Pthreads函数。
这11个最基本的Pthreads函数是:
- 2个:线程的创建和等待结束(join)。封装为 muduo::Thread。
- 4个:mutex的创建、销毁、加锁、解锁。封装为 muduo::MutexLock。
- 5个:条件变量的创建、销毁、等待、通知、广播。封装为 muduo::Condition。
用这三样东西(thread、mutex、condition)可以完成任何多线程编程任务。当然我们一般也不会直接使用它们(mutex除外),而是使用更高层的封装,例如 mutex::ThreadPool 和 mutex::CountDownLatch 等。
除此之外,Pthreads还提供了其他一些原语,有些是可以酌情使用的,有些则是不推荐使用的。
可以酌情使用的有:
- pthread_once,封装为muduo::Singleton。其实不如直接用全局变量。
- pthread_key*,封装为muduo::ThreadLocal。可以考虑用__thread替换之。
不建议使用:
- pthread_rwlock,读写锁通常应慎用。muduo没有封装读写锁,这是有意的。
- sem_*,避免用信号量(semaphore)。它的功能与条件变量重合,但容易用错。
- pthread_{cancel, kill}。程序中出现了它们,则通常意味着设计出了问题。
不推荐使用读写锁的原因是它往往造成提高性能的错觉(允许多个线程并发读),实际上在很多情况下,与使用最简单的mutex相比,它实际上降低了性能。另外,写操作会阻塞读操作,如果要求优化读操作的延迟,用读写锁是不合适的。
多线程系统编程的难点不在于学习线程原语(primitives),而在于理解多线程与现有的C/C++库函数和系统调用的交互关系,以进一步学习如何设计并实现线程安全且高效的程序。
4.3 C/C++系统库的线程安全性
现行的C/C++标准(C89/C99/C++03)并没有涉及线程。
新版的C/C++标准(C11和C++11)规定了程序在多线程下的语意,C++11还定义了一个线程库(std::thread)。
对于标准而言,关键的不是定义线程库,而是规定内存模型(memory model)。特别是规定一个线程对某个共享变量的修改何时能被其他线程看到,这称为内存序(memory ordering)或者内存能见度(memory visibility)。
线程的出现给出现在20世纪90年代Unix操作系统的系统函数库带来了冲击,破坏了20年来一贯的编程传统和假定。
例如:
- errno不再是一个全局变量,因为每个线程可能会执行不同的系统库函数。
- 有些"纯函数"不受影响,例如memset/strcpy/snprintf等等。
- 有些影响全局状态或者有副作用的函数可以通过加锁来实现线程安全,例如malloc/free、printf、fread/fseek等等。
- 有些返回或使用静态空间的函数不可能做到线程安全,因此要提供另外的版本,例如asctime_r/ctime_r/gmtime_r、stderror_r、strtok_r等等。
- 传统的fork()并发模型不再适用于多线程程序
现在Linux glibc把errno定义为一个宏,注意errno是一个lvalue,因此不能简单定义为某个函数的返回值,而必须定义为对函数返回指针的dereference。
CPP
extern int *__errno_location(void);
#define errno (*__errno_location())
现在glibc库函数大部分都是线程安全的。特别是FILE*
系列函数是安全的,glibc甚至提供了非线程安全的版本以应对某些特殊场合的性能需求。
尽管单个函数是线程安全的,但两个或多个函数放到一起就不再安全了。
例如fseek()和fread()都是安全的
- 但是对某个文件"先seek再read"这两步操作中间有可能会被打断,其他线程有可能趁机修改了文件的当前位置,让程序逻辑无法正确执行。
- 在这种情况下,我们可以用
flockfile(FILE*)
和funlockfile(FILE*)
函数来显式地加锁。并且由于FILE*
的锁是可重入的,加锁之后再调用fread()
不会造成死锁。
如果程序直接使用lseek和read这两个系统调用来随机读取文件,也存在"先seek再read"这种race condition,但是似乎我们无法高效地对系统调用加锁。解决办法是改用pread系统调用,它不会改变文件的当前位置。
由此可见,编写线程安全程序的一个难点在于线程安全是不可组合的(composable),一个函数foo()调用了两个线程安全的函数,而这个foo()函数本身很可能不是线程安全的。即便现在大多数glibc库函数是线程安全的,我们也不能像写单线程程序那样编写代码。
例如,在单线程程序中,如果我们要临时转换时区,可以用tzset()函数,这个函数会改变程序全局的"当前时区"。
CPP
// 保存当前的时区设置
string oldTz = getenv("TZ");
// 设置时区为欧洲伦敦 (Europe/London)
putenv("TZ=Europe/London");
// 更新时区设置
tzset();
// 定义一个结构体用于存储伦敦的本地时间
struct tm localTimeInLN;
// 获取当前时间戳
time_t now = time(NULL);
// 将当前时间戳转换为伦敦时区的本地时间,并存储在localTimeInLN 中
localtime_r(&now, &localTimeInLN);
// 恢复之前保存的时区设置
setenv("TZ", oldTz.c_str(), 1);
// 更新时区设置,使其回到之前的设置
tzset();
但是在多线程程序中,这么做不是线程安全的,即便tzset()本身是线程安全的。
因为它改变了全局状态(当前时区),这有可能影响其他线程转换当前时间,或者被其他进行类似操作的线程影响。
解决办法是使用muduo::TimeZone class,每个immutable instance对应一个时区,这样时间转换就不需要修改全局状态了。
例如:
CPP
// 自定义 TimeZone 类
class TimeZone {
public:
// 构造函数,接受时区文件路径
explicit TimeZone(const char* zonefile);
// 将时间戳转换为特定时区的本地时间
struct tm toLocalTime(time_t secondsSinceEpoch) const;
// 将特定时区的本地时间转换为时间戳
time_t fromLocalTime(const struct tm&) const;
// 其他可能的成员函数...
};
// 定义常量表示纽约时区和伦敦时区
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);
一个基本思路是尽量把class设计成immutable的,这样用起来就不必为线程安全操心了。
尽管C++03标准没有明说标准库的线程安全性,但我们可以遵循
- 一个基本原则:凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到,那么它就是安全的。
- 一个事实标准:共享的对象的read-only操作是安全的,前提是不能有并发的写操作。
例如:
- 两个线程各自访问自己的局部vector对象是安全的;
- 同时访问共享的const vector对象也是安全的,但是这个vector不能被第三个线程修改。一旦有writer,那么read-only操作也必须加锁,例如vector::size()。
C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销,另一方面的原因是单个成员函数的线程安全并不具备可组合性(composable)。
假设有safe_vectorclass,它的接口与std::vector相同,不过每个成员函数都是线程安全的(类似Javasynchronized方法)。但是用safe_vector并不一定能写出线程安全的代码。
例如:
CPP
safe_vector<int> vec;//全局可见
if(!vec.empty()) { //没有加锁保护
int x = vec[0];//这两步在多线程下是不安全的
}
在if语句判断vec非空之后,别的线程可能清空其元素,从而造成vec[0]失效。
C++标准库中的绝大多数泛型算法是线程安全的,因为这些都是无状态纯函数。只要输入区间是线程安全的,那么泛型函数就是线程安全的。
C++的iostream不是线程安全的,因为流式输出
CPP
std::cout << "Now is " << time(NULL);
等价于两个函数调用
CPP
std::cout.operator<<("Now is ").operator<<(time(NULL));
即便ostream::operator<<()做到了线程安全,也不能保证其他线程不会在两次函数调用之前向stdout输出其他字符。
对于"线程安全的stdout输出"这个需求,我们可以改用printf,以达到安全性和输出的原子性。但是这等于用了全局锁,任何时刻只能有一个线程调用printf,恐怕不见得高效。
4.4 Linux上的线程标识
POSIX threads库提供了pthread_self函数用于返回当前进程的标识符,其类型为pthread_t。pthread_t不一定是一个数值类型(整数或指针),也有可能是一个结构体,因此Pthreads专门提供了pthread_equal函数用于对比两个线程标识符是否相等。
这就带来一系列问题,包括:
- 无法打印输出pthread_t,因为不知道其确切类型。也就没法在日志中用它表示当前线程的id。
- 无法比较pthread_t的大小或计算其hash值,因此无法用作关联容器的key。
- 无法定义一个非法的pthread_t值,用来表示绝对不可能存在的线程id,因此MutexLock class没有办法有效判断当前线程是否已经持有本锁。
- pthread_t值只在进程内有意义,与操作系统的任务调度之间无法建立有效关联。比方说在/proc文件系统中找不到pthread_t对应的task。
glibc的Pthreads实现实际上把pthread_t用作一个结构体指针(它的类型是unsigned long),指向一块动态分配的内存,而且这块内存是反复使用的。
这就造成pthread_t的值很容易重复。Pthreads只保证同一进程之内,同一时刻的各个线程的id不同;不能保证同一进程先后多个线程具有不同的id,更不要说一台机器上多个进程之间的id唯一性了。
例如下面这段代码中先后两个线程的标识符是相同的:
CPP
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);
}
bash
$ ./a.out
7fad11787700
7fad11787700
因此,pthread_t并不适合用作程序中对线程的标识符。
在Linux上,作者建议使用gettid系统调用的返回值作为线程id,这么做的好处有:
- 它的类型是pid_t,其值通常是一个小整数13,便于在日志中输出。
- 在现代Linux中,它直接表示内核的任务调度id,因此在/proc文件系统中可以轻易找到对应项:/proc/tid或/prod/pid/task/tid。
- 在其他系统工具中也容易定位到具体某一个线程,例如在top中我们可以按线程列出任务,然后找出CPU使用率最高的线程id,再根据程序日志判断到底哪一个线程在耗用CPU。
- 任何时刻都是全局唯一的,并且由于Linux分配新pid采用递增轮回办法,短时间内启动的多个线程也会具有不同的线程id。
- 0是非法值,因为操作系统第一个进程init的pid是1。
但是glibc并没有封装这个系统调用,需要我们自己实现。
作者封装的gettid的方法如下:
muduo::CurrentThread::tid()采取的办法是用__thread变量来缓存gettid的返回值,这样只有在本线程第一次调用的时候才进行系统调用,以后都是直接从thread local缓存的线程id拿到结果,效率无忧。
未完待续。。。