学习多线程编程面临的最大的思维方式的转变有以下两点:
多线程程序的正确性不能依赖于任何一个线程的执行速度,不能通过原地等待(sleep())来假定其他线程的事件已经发生,而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。无论线程执行得快与慢(被操作系统切换出去得越多,执行越慢),程序都应该能正常工作。
例如下面这段代码就有这方面的问题。
bool running = false;//全局标志
void threadFunc() {
while(running){
//get task from queue
}
}
void start() {
muduo::Thread t(threadFunc);
t.start();
running = true;//应该放到t.start()之前
}
POSIX threads的函数有110多个,真正常用的不过十几个。而且在C++程序中通常会有更为易用的 wrapper,不会直接调用Pthreads函数。
这11个最基本的Pthreads函数是:
用这三样东西(thread、mutex、condition)可以完成任何多线程编程任务。当然我们一般也不会直接使用它们(mutex除外),而是使用更高层的封装,例如 mutex::ThreadPool 和 mutex::CountDownLatch 等。
除此之外,Pthreads还提供了其他一些原语,有些是可以酌情使用的,有些则是不推荐使用的。
可以酌情使用的有:
不建议使用:
不推荐使用读写锁的原因是它往往造成提高性能的错觉(允许多个线程并发读),实际上在很多情况下,与使用最简单的mutex相比,它实际上降低了性能。另外,写操作会阻塞读操作,如果要求优化读操作的延迟,用读写锁是不合适的。
多线程系统编程的难点不在于学习线程原语(primitives),而在于理解多线程与现有的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年来一贯的编程传统和假定。
例如:
现在Linux glibc把errno定义为一个宏,注意errno是一个lvalue,因此不能简单定义为某个函数的返回值,而必须定义为对函数返回指针的dereference。
extern int *__errno_location(void);
#define errno (*__errno_location())
现在glibc库函数大部分都是线程安全的。特别是FILE*
系列函数是安全的,glibc甚至提供了非线程安全的版本以应对某些特殊场合的性能需求。
尽管单个函数是线程安全的,但两个或多个函数放到一起就不再安全了。
例如fseek()和fread()都是安全的
flockfile(FILE*)
和funlockfile(FILE*)
函数来显式地加锁。并且由于FILE*
的锁是可重入的,加锁之后再调用fread()
不会造成死锁。如果程序直接使用lseek和read这两个系统调用来随机读取文件,也存在“先seek再read”这种race condition,但是似乎我们无法高效地对系统调用加锁。解决办法是改用pread系统调用,它不会改变文件的当前位置。
由此可见,编写线程安全程序的一个难点在于线程安全是不可组合的(composable),一个函数foo()调用了两个线程安全的函数,而这个foo()函数本身很可能不是线程安全的。即便现在大多数glibc库函数是线程安全的,我们也不能像写单线程程序那样编写代码。
例如,在单线程程序中,如果我们要临时转换时区,可以用tzset()函数,这个函数会改变程序全局的“当前时区”。
// 保存当前的时区设置
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对应一个时区,这样时间转换就不需要修改全局状态了。
例如:
// 自定义 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标准没有明说标准库的线程安全性,但我们可以遵循
例如:
C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销,另一方面的原因是单个成员函数的线程安全并不具备可组合性(composable)。
假设有safe_vectorclass,它的接口与std::vector相同,不过每个成员函数都是线程安全的(类似Javasynchronized方法)。但是用safe_vector并不一定能写出线程安全的代码。
例如:
safe_vector vec;//全局可见
if(!vec.empty()) { //没有加锁保护
int x = vec[0];//这两步在多线程下是不安全的
}
在if语句判断vec非空之后,别的线程可能清空其元素,从而造成vec[0]失效。
C++标准库中的绝大多数泛型算法是线程安全的,因为这些都是无状态纯函数。只要输入区间是线程安全的,那么泛型函数就是线程安全的。
C++的iostream不是线程安全的,因为流式输出
std::cout << "Now is " << time(NULL);
等价于两个函数调用
std::cout.operator<<("Now is ").operator<<(time(NULL));
即便ostream::operator<<()做到了线程安全,也不能保证其他线程不会在两次函数调用之前向stdout输出其他字符。
对于“线程安全的stdout输出”这个需求,我们可以改用printf,以达到安全性和输出的原子性。但是这等于用了全局锁,任何时刻只能有一个线程调用printf,恐怕不见得高效。
POSIX threads库提供了pthread_self函数用于返回当前进程的标识符,其类型为pthread_t。pthread_t不一定是一个数值类型(整数或指针),也有可能是一个结构体,因此Pthreads专门提供了pthread_equal函数用于对比两个线程标识符是否相等。
这就带来一系列问题,包括:
glibc的Pthreads实现实际上把pthread_t用作一个结构体指针(它的类型是unsigned long),指向一块动态分配的内存,而且这块内存是反复使用的。
这就造成pthread_t的值很容易重复。Pthreads只保证同一进程之内,同一时刻的各个线程的id不同;不能保证同一进程先后多个线程具有不同的id,更不要说一台机器上多个进程之间的id唯一性了。
例如下面这段代码中先后两个线程的标识符是相同的:
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);
}
$ ./a.out
7fad11787700
7fad11787700
因此,pthread_t并不适合用作程序中对线程的标识符。
在Linux上,作者建议使用gettid系统调用的返回值作为线程id,这么做的好处有:
但是glibc并没有封装这个系统调用,需要我们自己实现。
作者封装的gettid的方法如下:
muduo::CurrentThread::tid()采取的办法是用__thread变量来缓存gettid的返回值,这样只有在本线程第一次调用的时候才进行系统调用,以后都是直接从thread local缓存的线程id拿到结果,效率无忧。
未完待续。。。