网站建设施工图片,定制微信,自建网站服务器备案,加入网络营销公司第4章 C多线程系统编程精要
4.1 引言
学习多线程编程面临的最大的思维方式的转变有以下两点#xff1a;
当前线程可能随时会被切换出去#xff0c;或者说被抢占#xff08;preempt#xff09;了多线程程序中事件的发生顺序不再有全局统一的先后关系
多线程程序的正确性…第4章 C多线程系统编程精要
4.1 引言
学习多线程编程面临的最大的思维方式的转变有以下两点
当前线程可能随时会被切换出去或者说被抢占preempt了多线程程序中事件的发生顺序不再有全局统一的先后关系
多线程程序的正确性不能依赖于任何一个线程的执行速度不能通过原地等待sleep()来假定其他线程的事件已经发生而必须通过适当的同步来让当前线程能看到其他线程的事件的结果。无论线程执行得快与慢被操作系统切换出去得越多执行越慢程序都应该能正常工作。
例如下面这段代码就有这方面的问题。
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/C03并没有涉及线程。 新版的C/C标准C11和C11规定了程序在多线程下的语意C11还定义了一个线程库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。
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()函数这个函数会改变程序全局的“当前时区”。
// 保存当前的时区设置
string oldTz getenv(TZ);
// 设置时区为欧洲伦敦 (Europe/London)
putenv(TZEurope/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的这样用起来就不必为线程安全操心了。
尽管C03标准没有明说标准库的线程安全性但我们可以遵循
一个基本原则凡是非共享的对象都是彼此独立的如果一个对象从始至终只被一个线程用到那么它就是安全的。一个事实标准共享的对象的read-only操作是安全的前提是不能有并发的写操作。
例如
两个线程各自访问自己的局部vector对象是安全的同时访问共享的const vector对象也是安全的但是这个vector不能被第三个线程修改。一旦有writer那么read-only操作也必须加锁例如vector::size()。
C的标准库容器和std::string都不是线程安全的只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销另一方面的原因是单个成员函数的线程安全并不具备可组合性composable。
假设有safe_vectorclass它的接口与std::vector相同不过每个成员函数都是线程安全的类似Javasynchronized方法。但是用safe_vector并不一定能写出线程安全的代码。
例如
safe_vectorint 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恐怕不见得高效。
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唯一性了。
例如下面这段代码中先后两个线程的标识符是相同的
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这么做的好处有
它的类型是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拿到结果效率无忧。
未完待续。。。