当前位置: 首页 > article >正文

Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第四章 C++多线程系统编程精要

学习多线程编程面临的最大思维方式的转变有两点:
1.当前线程可能随时会被切换出去,或者说被抢占(preempt)了。

2.多线程程序中事件的发生顺序不再有全局统一的先后关系了。

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

在单CPU系统中,理论上我们可以通过记录CPU上执行的指令的先后顺序来推演多线程的实际交织(interweaving)运行的情况。在多核系统中,多个线程是并行执行的,我们甚至没有统一的全局时钟来为每个事件编号。在没有适当同步的情况下,多个CPU上运行的多个线程中的时间发生先后顺序是无法确定的,因为在多CPU机器上,假设主板上两个物理CPU的距离为15cm,CPU的主频是2.4 GHz,电信号在电路中的传播速度按2×10 8 ^8 8m/s估算,那么在1个时钟周期(0.42ns)之内,电信号不能从一个CPU到达另一个CPU,因此对于每个CPU自己这个观察者来说,它看到的事件发生的顺序没有全局一致性。在引入适当同步后,事件之间才有了happens-before关系。

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

// 全局标志
bool running = false;

void threadFunc()
{
    while (running)
    {
        // get task from queue
    }
}

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

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

或许有人认为在while前加一小段延时(sleep)就能解决问题,但这是错的,无论加多大的延时,系统都有可能先执行while的条件判断,然后再执行running的赋值。正确的做法是把running的赋值放到t.start()之前,这样借助pthread_create()的happens-before语意来保证running的新值能被线程看到。happens-before语意是Java内存模型(Java Memory Model,JMM)的一部分,它定义了以下几种事件之间的顺序关系:
1.程序顺序规则(Program Order Rule): 在一个线程中,程序代码的执行顺序被认为是严格有序的。如果操作A在程序中排在操作B之前,那么A发生在B之前。

2.监视器锁规则(Monitor Lock Rule): 当线程释放一个监视器锁时,随后获取该监视器锁的线程将看到释放线程之前的所有操作。这确保了线程间的同步。

3.volatile变量规则(Volatile Variable Rule): 在Java中,对一个volatile变量的写操作在之后的读操作中可见。这意味着volatile变量的操作具有顺序性。

4.传递性(Transitivity): 如果事件A发生在事件B之前,而事件B又发生在事件C之前,那么事件A必然发生在事件C之前。

作者认为用C/C++编写跨平台的多线程程序不是普遍的需求,因此本书只谈现代Linux下的多线程编程。POSIX threads的函数由110多个,真正常用的不过十几个。而且在C++程序中通常会有更为易用的wrapper,不会直接调用Pthreads函数。这11个最基本的Pthreads函数是:
1.2个:线程的创建和等待结束(join)。封装为muduo::Thread。

2.4个:mutex的创建、销毁、加锁、解锁。封装为muduo::MutexLock。

3.5个:条件变量的创建、销毁、等待、通知、广播。封装为muduo::Condition。

这些封装class都很直截了当,加起来也就一两百行代码,却已经构成了多线程变成的全部必备原语。用这三样东西(thread、mutex、condition)可以完成任何多线程编程任务。当然我们一般也不会直接使用它们(mutex除外),而是使用更高层的封装,例如mutex::ThreadPool和mutex::CountDownLatch等,见第二章。

除此之外,Pthreads还提供了其他一些原语,有些是可以酌情使用的,有些则是不推荐使用的。可以酌情使用的有:
1.pthread_once,封装为muduo::Singleton<T>。其实不如直接用全局变量。

2.pthread_key*,封装为muduo::ThreadLocal<T>。可以考虑用__thread替换之。在C++11之前,__thread是一种扩展,由一些C++编译器(例如GCC)提供,用于实现线程局部存储。在C++11标准中,引入了标准的线程局部存储(Thread-Local Storage)关键字thread_local,它提供了更加标准和可移植的方式来定义线程局部变量。

不建议使用:
1.pthread_rwlock,读写锁通常应慎用。muduo没有封装读写锁,这是有意的。

2.sem_*:避免用信号量(semaphore)。它的功能和条件变量重合,但容易出错。

3.pthread_{calcel,kill}。程序中出现了它们,则通常意味着设计出了问题。

不推荐使用读写锁的原因是它往往造成能提高性能的错觉(允许多个线程并发读),实际上在很多情况下,与使用最简单的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)。从理论上讲,如果没有合适的内存模型,编写正确的多线程程序属于撞大运行为,见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年来一贯的编程传统和假定。例如:
1.errno不再是一个全局变量,因为每个线程可能会执行不同的系统库函数。

2.有些“纯函数”不受影响,例如memset、strcpy、snprintf等。

3.有些影响全局状态或有副作用的函数可通过加锁来实现线程安全,例如malloc/free、printf、fread/fseek等。

4.有些返回或使用静态空间的函数不可能做到线程安全,因此要提供另外的版本,如asctime_r(用于将时间结构(struct tm)转换为一个表示人类可读时间的字符串)/ctime_r(用于将时间戳(即秒数)转换为表示人类可读时间的字符串)/gmtime_r(用于将时间戳(即秒数,通常从Unix纪元开始计算)转换为时间结构tm)、stderror、strtok_r(用于将一个字符串拆分成多个子字符串,根据指定的分隔符来完成这个操作)等。

5.传统的fork()并发模型不再适用于多线程程序。

现在Linux glibc(GNU C Library,通常简称glibc,是一个C标准库的实现,它是GNU计划的一部分,旨在提供Unix和Unix-like操作系统的核心C库功能,glibc是Linux系统中使用最广泛的C库,也被许多其他Unix-like操作系统所采用,包括FreeBSD、OpenBSD等。)把errno定义为一个宏,注意errno是一个lvalue,因此不能简单定义为某个函数的返回值,而必须定义为对函数返回指针的dereference。

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

值得一提的是,操作系统支持多线程已有近20年(距作者写此书时,大概2010年),早先一些性能方面的缺陷都基本被弥补了。例如最早的SGI STL自己定制了内存分配器,而现在g++自带的STL已经直接使用malloc来分配内存,std::allocator已经变成了鸡肋(第十二章介绍)。原先Google tcmalloc(Thread-Caching Malloc,线程缓存分配器,是由Google开发的高性能内存分配器,旨在改善多线程应用程序中的内存分配性能)相对于glibc 2.3中的ptmalloc2有很大的性能提升,现在最新的glibc中的ptmalloc3已经把差距大大缩小了。

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

与直觉相反,POSIX标准列出的是一份非线程安全的函数的黑名单,而不是一份线程安全的函数的白名单(All functions defined by this volumn of POSIX.1-2008 shall be thread-safe, except that the following functions need not be thread-safe)。在这份黑名单中,system(用于执行操作系统命令)、getenv(用于获取指定环境变量的值)/putenv(用于在程序运行时设置或修改环境变量的值)/setenv(类似putenv函数,但它不提供参数来控制是否覆盖当前环境变量值)等函数都是不安全的。

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

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

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

// 获取伦敦的当前时间
string oldTz = getenv("TZ");    // save TZ, assuming 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函数用于将一个UNIX时间戳转换为本地时间的tm结构表示
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对应一个时区,这样时间转换就不需要修改全局状态了。例如:

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操作是安全的(这意味着标准库容器不能采用自调整(self-adjusting)的数据结构,比如splay tree(它是一种自平衡二叉搜索树,旨在通过旋转操作将最近访问的节点提升为根节点,从而实现平均时间复杂度的优化),这种数据结构在read的时候也会修改状态),前提是不能有与读操作并发的写操作。例如两个线程各自访问自己的局部vector对象是安全的;同时访问共享的const vector对象也是安全的,但是这个vector不能被第三个线程修改。一旦有writer,那么read-only操作也必须加锁,例如vector::size()。

C++的标准库容器和std::string都不是线程安全的,只有std::allocator保证是线程安全的。一方面的原因是为了避免不必要的性能开销,另一方面的原因是单个成员函数的线程安全并不具备可组合性(composable)。假设有safe_vector<T> class,它的接口与std::vector相同,不过每个成员函数都是线程安全的(类似Java synchronized方法,当synchronized关键字应用于方法时,它用于控制对该方法的并发访问,确保在同一时间只有一个线程可以执行该方法。)但是用safe_vector<T>并不一定能写出线程安全的代码。例如:

safe_vector<int> vec;    // 全局可见

if (!vec.empty())    // 没有加锁保护
{
    int x = vec[0];    // 这两步在多线程下是不安全的
}

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

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

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函数用于对比两个线程标识符是否相等。这就带来一系列问题,包括:
1.无法打印输出pthread_t,因为不知道其确切类型。也就没法在日志中用它表示当前线程id。

2.无法比较pthread_t的大小或计算其hash值,因此无法用作关联容器的key。

3.无法定义一个非法的pthread_t值,用来表示绝对不可能存在的线程id,因此MutexLock class没有办法判断当前线程是否已经持有本锁。

4.pthread_t值只在进程内有意义,与操作系统的任务调度之间无法建立有效关联。比方说在/proc文件系统(/proc文件系统是一个特殊的虚拟文件系统,它不包含实际的文件,而是提供了一种用于访问和查询内核状态信息的接口,/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);
}

一次运行结果如下:
在这里插入图片描述
因此,pthread_t并不适合用作程序中对线程的标识符。

在Linux上,作者建议使用gettid系统调用(Linux特有的函数,非POSIX标准)的返回值作为线程id,这么做的好处有:
1.它的类型是pid_t,其值通常是一个小整数(最大值是/proc/sys/kernel/pid_max,默认为32768),便于在日志中输出。

2.在现代Linux中,它直接表示内核的任务调度id,因此在/proc文件系统中可以轻易找到对应项:/proc/tid或/proc/pid/task/tid。

3.在其他系统工具中也容易定位到具体某一个线程,例如在top命令中我们可以按线程列出任务,然后找出CPU使用率最高的线程id,再根据程序日志判断到底哪一个线程在耗用CPU。

4.任何时刻都是全局唯一的,并且由于Linux分配新pid采用递增轮回办法,短时间内启动的多个线程也不会有相同的线程id。

5.0是非法值,因为操作系统第一个进程init的pid是1。

但是glibc并没有封装这个系统调用,需要我们自己实现。封装gettid很简单,但每次都执行一次系统调用似乎有些浪费,如何才能做到高效呢?

muduo::CurrentThread::tid()采取的办法是用__thread变量来缓存gettid的返回值,这样只有在本线程第一次调用的时候才进行系统调用,以后都是直接从thread local缓存的线程id拿到结果(这个做法受到了glibc封装getpid()的启发),效率无忧。多线程程序在打日志的时候可以在每一条日志消息中包含当前线程的id,不必担心有效率损失。

注:我们可以这样调用gettid:

pid_t tid = static_cast<pid_t>(syscall(SYS_gettid));
printf("Thread ID: %d\n", tid);

其中,syscall函数是一个Linux特定的系统调用,它允许用户程序直接调用操作系统内核的功能,而无需通过C库或标准库函数。

还有一个小问题,万一程序执行了fork,那么子进程会不会看到stale的缓存结果呢?解决办法是用pthread_atfork()注册一个回调,用于清空缓存的线程id。

线程的创建和销毁是编写多线程程序的基本要素,线程的创建比销毁要容易得多,只需遵循几条简单的原则:
1.程序库不应该在未提前告知的情况下创建自己的“背景线程”。

2.尽量用相同的方式创建线程,例如muduo::Thread。

3.在进入main()前不应该启动线程。

4.程序中线程的创建最好能在初始化阶段全部完成。

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

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

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

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

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

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

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

线程的销毁有几种方式:
1.自然死亡。从线程主函数返回,线程正常退出。

2.非正常死亡。从线程主函数抛出异常或线程触发segfault信号等非法操作。

3.自杀。在线程中调用pthread_exit来立刻退出线程。

4.他杀。其他线程调用pthread_cancel来强制终止某个线程。

pthread_kill()是往线程发信号。

线程正常退出的方式只有一种,即自然死亡。任何从外部强行终止线程的做法和想法都是错的。佐证有:Java的Thread class把stop()、suspend()、destroy()等函数都废弃(deprecated)了,Boost.Threads根本就不提供thread::cancel()成员函数。因为强行终止线程的话(无论是自杀还是他杀),它没有机会清理资源。也没有机会释放已经持有的锁,其他线程如果再想对同一个mutex加锁,那么就会立刻死锁。因此作者认为不用研究cancellation point(线程取消点)这种“鸡肋”概念。

如果确实需要强行终止一个耗时很长的计算任务,而又不想在计算期间周期性地检查某个全局退出标志,那么可以考虑把那一部分代码fork()为一个新进程,这样杀一个进程比杀本进程内的线程安全得多。当然,fork()的新进程与本进程的通信方式也要慎重选取,最好用文件描述符(pipe/socketpair/TCP socket)来收发数据,而不要用共享内存和跨进程的互斥器等IPC,因为这样仍然有死锁的可能。

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

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

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

在C++中,cancellatioin point的实现与C语言有所不同,线程不是执行到此函数就立刻终止,而是该函数会抛异常。这样可以有机会执行stack unwind(栈展开,指在程序执行过程中,当异常(exception)被抛出或者程序执行到某个错误或异常的情况下,系统会逐级取消函数调用,回溯(unwind)到合适的异常处理点或退出点。这个过程包括了释放已分配的栈内存、调用析构函数,以确保程序状态的一致性和资源的释放),析构栈上对象(特别是释放持有的锁)。如果一定要使用cancellation,建议读一读Ulrich Drepper写的Cancellation and C++ Exceptions这篇短文(http://udrepper.livejournal.com/21541.html)。但按作者的观点,不应该从外部杀死线程。

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

void someFunctionMayCallExit()
{
    exit(1);
}

class GlobalObject // : boost::noncopyable
{
public:
    void doit()
    {
        MutexLockGuard lock(mutex_);
        someFunctionMayCallExit();
    }
    
    ~GlobalObject()
    {
        printf("GlobalObject::~GlobalObject\n");
        MutexLockGuard lock(mutex_);    // 此处发生死锁
        // clean up
        printf("GlobalObject:~GlobalObject cleanning\n");
    }

private:
    MutexLock mutex_;
};

GlobalObject g_obj;

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

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

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

class Canlendar : boost::noncopyable
{
public:
    // 纯虚函数
    virtual bool isHoliday(muduo::Date d) const = 0;
    virtual ~Calendar() {}
};

class AmericanCalendar : public Calendar
{
public:
    virtual boos 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);

通常的方式是通过factory拿到具体国家的日历,再判断某一天是不是假期:

void processRequest(const Request &req)
{
    Calendar &calendar = getCalendar(req.region);
    // 如果别的线程在此调用了exit()
    if (calendar.isHoliday(req.settlement_data))
    {
        // do something
    }
}

这一切都工作得很好,直到有一天我们想主动退出这个服务程序,于是某个线程调用了exit(),析构了全局对象,结果造成另一个线程在调用Calendar::isHoliday时发生崩溃:
在这里插入图片描述
当然,这只是举例说明“用全局对象实现无状态策略”在多线程中析构可能有危险。在真实的项目中,Calendar应该在运行的时候从外部配置读入,而不能写死在代码中。

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

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

__thread是GCC内置的线程局部存储设施(thread local storage)。它的实现非常高效,比pthread_key_t快很多,见Ulrich Drepper写的《ELF Handling For Tthread-Local Storage》(http://www.akkadia.org/drepper/tls.pdf)(ELF指可执行和共享对象文件格式,Executable and Linkable Format,是一种二进制文件格式,用于存储可执行程序、共享库、目标文件等)。__thread变量的存取效率可与全局变量相比:

int g_var;    // 全局变量
__thread int t_var;    // __thread变量

void foo()    // 交替显示源代码和汇编代码
{
    // 将寄存器ebp的值压入堆栈(栈中),%表示ebp是个寄存器
    // 通常在函数的开始部分用于保存上一个栈帧的基地址,以便在函数结束时能够正确恢复堆栈状态
    8048494: 55                     push %ebp
    // 将栈指针寄存器%esp的值赋给基址指针寄存器%ebp
    // 这通常发生在函数的入口处,用于建立当前函数的堆栈帧
    8048495: 89 e5                  mov  %esp, %ebp
    g_var = 1;    // 直接寻址
    // 将立即数0x1(即整数1)移动到内存地址0x804971c处,$表示后面的数是立即数
    8048497: c7 05 1c 97 04 08      movl $0x1, 0x804971c
    804849d: 01 00 00 00
    t_var = 2;    // 也是直接寻址,用了段寄存器gs
    // 将立即数0x2(整数2)移动到内存中的一个特定地址,该地址使用段寄存器%gs加上偏移量0xfffffffc(-4)来计算
    // 这是一种涉及段寄存器的操作,通常用于访问特定段内存中的数据
    // 在许多操作系统中,特定CPU寄存器(在x86和x86-64架构中通常是%fs或%gs)被保留用于指向当前线程的TLS
    80484a1: 65 c7 05 fc ff ff ff   movl $0x2, %gs:0xfffffffc
    80484a8: 02 00 00 00
}
    80484ac: 5d                     pop %ebp
    80484ad: c3                     ret

__thread使用规则:只能用于修改POD类型,不能修饰class类型,因为无法自动调用构造函数和析构函数。__thread可以用于修饰全局变量、函数内的静态变量、但不能用于修饰函数的局部变量或class的普通成员变量。另外,__thread变量的初始化只能用编译期常量。例如:

__thread string t_obj1("Chen Shou");    // 错误,不能调用对象的构造函数
__thread string *t_obj2 = new string;    // 错误,初始化必须用编译期常量
__thread string *t_obj3 = NULL;    // 正确,但是需要手工初始化并销毁对象

__thread变量是每个线程有一份独立实体,各个线程的变量值互不干扰。除了这个主要用途,它还可以修饰那些“值可能会变,带有全局性,但是又不值得用全局锁保护”的变量。muduo代码中用到了好几处__thread,简单列举如下:
1.muduo/base/Logging.cc:缓存最近一条日志时间的年月日时分秒,如果一秒之内输出多条日志,可避免重复格式化。另外,muduo::strerror_tl把strerror_r函数做成如果strerror一样好用,且是线程安全的。

2.muduo/base/ProcessInfo.cc:用线程局部变量来简化::scandir()的使用。

3.muduo/base/Thread.cc:缓存每个线程的id。

4.muduo/net/EventLoop.cc:用于判断当前线程是否只有一个EventLoop对象。

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

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

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

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

但是,多个线程同时操作同一个socket文件描述符确实很麻烦,作者认为是得不偿失的。需要考虑的情况如下:
1.如果一个线程正在阻塞地read某个socket,而另一个线程close了此socket。

2.如果一个线程正在阻塞地accept某个listening socket,而另一个线程close了此socket。

3.更糟糕的是,一个线程正准备read某个socket,而另一个线程close了此socket;第三个线程又恰好open了另一个文件描述符,其fd号码正好与前面的socket相同。这样程序的逻辑就混乱了。

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

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

1.如果两个线程同时read同一个TCP socket,两个线程几乎同时各自收到一部分数据,如何把数据拼成完整的消息?如何知道哪部分数据先到达?

2.如果两个线程同时write同一个TCP socket,每个线程都只发出去半条消息,那接收方收到数据如何处理?

3.如果给每个TCP socket配一把锁,让同时只能有一个线程读或写此socket,似乎可以解决问题,但这样还不如直接始终让同一个线程来操作此socket来得简单。

4.对于非阻塞IO,情况是一样的,而且收发消息的完整性与原子性几乎不可能用锁来保证,因为这样会阻塞其他IO线程(A、B线程同时读,读之前加锁,假设A获得了锁,B会等待A读完解锁)。

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

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

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

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

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

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

接下来谈一谈在多线程程序中如何管理文件描述符。Linux的文件描述符(file descriptor)是小整数,在程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。这时如果我们新打开一个文件,它的文件描述符会是3,因为POSIX标准要求每次新打开文件(含socket)的时候必须使用当前最小可用的文件描述符号码。

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

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

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

引申问题:为什么服务端程序不应该关闭标准输出(fd=2)和标准错误(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会造成空悬指针。只有确保其他地方没有持有该对象的引用的时候,才能安全地销毁对象,这自然会用到引用计数。在多线程程序中安全地销毁对象不是一件轻而易举的事,见第一章。

在非阻塞网络编程中,我们常常要面临这样一种场景:从某个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,作者想不出其他安全且高效的办法来管理多线程网络服务端程序中的并发连接。

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

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

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

fork()之后,子进程继承了父进程的几乎全部状态,但也有少数例外。子进程会继承地址空间和文件描述符,因此用于管理动态内存和文件描述符的RAII class都能正常工作。但是子进程不会继承:
1.父进程的内存锁,mlock、mlockall函数。前者用于锁定一部分或全部进程的虚拟内存,后者用于锁定进程的整个地址空间,以防止该内存被交换到磁盘上,从而提高了内存的访问性能和可预测性

2.父进程的文件锁,fcntl。

3.父进程的某些定时器,setitimer(用于设置间隔性的定时器)、alarm、timer_create(相比setitimer函数提供更多的定时控制和可扩展性)等函数。

4.其他,见man 2 fork。

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

因此,我们在编写服务端程序的时候,“是否允许fork()”是在一开始就应该慎重考虑的问题,在一个没有为fork()做好准备的程序中使用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()之后,子进程不能调用:
1.malloc(3)。因为malloc()在访问全局状态时几乎肯定会加锁。

2.任何可能分配或释放内存的函数,包括new、map::insert()、snprintf等。

3.任何Pthreads函数。你不能用pthread_cond_signal()去通知父进程,只能通过pipe(2)来同步。

4.printf()系列函数,因为其他线程可能恰好持有stdout/stderr的锁。

5.除了man 7 signal中明确列出“signal 安全”函数之外的任何函数。

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

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

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

还有一点,如果signal handler中需要修改全局数据,那么被修改的变量必须是sig_atomic_t类型(C标准库中定义的一个整数数据类型,通常用于表示可以在信号处理程序中安全访问的整数变量)的。否则被打断的函数在恢复执行后很可能不能立刻看到signal handler改动后的数据,因为编译器有可能假定这个变量不会被他处修改,从而优化了内存访问。

在多线程时代,signal的语义更为复杂。信号分为两类:发送给某一线程(如SIGSEGV,通常在程序访问未分配给它的内存区域或试图执行无效的内存操作时被生成),发送给进程中的任一线程(如SIGTERM),还要考虑掩码(mask)对信号的屏蔽等。特别是在signal handler中不能调用任何Pthreads函数,不能通过condition variable来通知其他线程(条件变量通常需要一些同步机制,如互斥锁,以确保安全地访问共享资源,信号处理程序是异步执行的,它可能无法安全地获取和释放锁,导致死锁或不安全的操作)。

多线程程序中,使用signal的第一原则是不要使用signal。包括:
1.不要用signal作为IPC的手段,包括不要用SIGUSR1等信号来触发服务端的行为。如果确实需要,可以用第九章介绍的增加监听端口的方式来实现双向的、可远程访问的进程控制。

2.也不要使用基于signal实现的定时函数,包括alarm、ualarm(用于设置以微秒为单位的定时器)、setitimer、timer_create、sleep/usleep(通常会使用SIGALRM信号实现到期后恢复执行)。

3.不主动处理各种异常信号(SIGTERM、SIGINT等),只用默认语义:结束进程。有一个例外:SIGPIPE,服务器程序通常的做法是忽略此信号,否则如果对方断开连接,而本机继续write的话,会导致程序意外终止。

4.在没有别的替代方法的情况下(比方说需要处理SIGCHLD信号),把异步信号转换为同步的文件描述符事件。传统的做法是在signal handler里往一个特定的pipe写一个字节,在主程序中从这个pipe读取,从而纳入统一的IO事件处理框架中去。现代Linux的做法是采用signalfd函数(通过读取文件描述符来获取信号信息,不再需要信号处理函数)把信号转换为文件描述符事件,从而从根本上避免使用signal handler。

大致从Linux内核2.6.27起,凡是会创建文件描述符的syscall一般都增加了额外的flags参数,可以直接指定O_NONBLOCK和FD_CLOEXEC,例如:
在这里插入图片描述
以上6个systemcall,除了最后一个是2.6.25的新功能,其余的都是增强原有的调用,把数字尾号去掉就是原来的syscall。

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

另外,以下新系统调用可以在创建文件描述符时开启FD_CLOEXEC选项:
在这里插入图片描述
FD_CLOEXEC的功能是让程序exec()时,进程会自动关闭这个文件描述符。而文件描述符默认是被子进程继承的(这是传统Unix的一种典型IPC,比如用pipe(2)在父子进程间单向通信)。

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

本章只讨论了多线程编程的技术方面,没有讨论设计方面,特别是没有讨论该如何规划一个多线程服务程序的线程数目及用途。作者个人遵循的编写多线程C++程序的原则如下:
1.线程是宝贵的,一个程序可以使用几个或十几个线程。一台机器上不应该同时运行几百个、几千个用户线程,这会大大增加内核scheduler的负担,降低整体性能。

2.线程的创建和销毁是有代价的,一个程序最好在一开始创建所需的线程,并一直反复使用。不要在运行期间反复创建、销毁线程,如果必须这么做,其频度最好能降到1分钟1次(或更低)。

3.每个线程应该有明确的职责,例如IO线程运行(运行EventLoop::loop(),处理IO事件)、计算线程(位于ThreadPool中,负责计算)等。

4.线程之间的交互应该尽量简单,理想情况下,线程之间只用消息传递(例如BlockingQueue)方式交互。如果必须用锁,那么最好避免一个线程同时持有两把或更多的锁,这样可彻底防止死锁。

5.要预先考虑清楚一个mutable shared对象将会暴露给哪些线程,每个线程是读还是写,读写有无可能并发进行。


http://www.kler.cn/a/108297.html

相关文章:

  • 【开源】一款基于SpringBoot的智慧小区物业管理系统
  • 如何通过采购管理系统提升供应链协同效率?
  • Day8补代码随想录 字符串part1 344.反转字符串|541.反转字符串II|卡码网:54.替换数字
  • 欲海航舟:探寻天性驱动下的欲望演变与人生驾驭
  • 通过交叉实现数据触底分页效果new IntersectionObserver()(html、react、vue2、vue3)中使用
  • 【elementplus】中文模式
  • 数据库简史:多主数据库架构的由来和华为参天引擎的机遇
  • [算法]求n!在m进制下末尾有多少个0
  • Redis(09)| Reactor模式
  • Vue 数据绑定 和 数据渲染
  • 分布式消息队列:RabbitMQ(1)
  • [ROS系列]ubuntu 20.04 从零配置orbslam3(无坑版)
  • 广州华锐互动:VR虚拟现实物理学习平台,开启数字化教学新格局
  • acwing 5283. 牛棚入住
  • 应用案例|基于三维机器视觉的机器人引导电动汽车充电头自动插拔应用方案
  • 怎么降低Linux内核驱动开发的风险?
  • 粤嵌实训医疗项目--day03(Vue + SpringBoot)
  • c# .net6 在线条码打印基于
  • 云服务器搭建Spark集群
  • centos服务器搭建安装Gitlab教程使用教程
  • 电子器件 电感
  • 设计模式面试知识点总结
  • Node编写重置用户密码接口
  • 大数据-Storm流式框架(六)---Kafka介绍
  • npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
  • MySQL的安装和配置