muduo网络库:13---C++多线程编程精要之(线程的创建与销毁的守则)
原创
©著作权归作者所有:来自51CTO博客作者董哥的黑板报的原创作品,请联系作者获取转载授权,否则将追究法律责任
- 本文内容衔接于前一篇文章(C/C++系统库的线程安全性):
- 相关语法链接:
- pthread_create
- pthread_exit()、pthread_cancel()、取消点
- pthread_kill()
- exit()、_exit()
一、线程创建的几项原则
- 线程的创建比销毁要容易得多,只需要遵循几条简单的原则:
- ①程序库不应该在未提前告知的情况下创建自己的“背景线程”
- ②尽量用相同的方式创建线程,例如muduo::Thread
- ③在进入main()函数之前不应该启动线程
- ④程序中线程的创建最好能在初始化阶段全部完成
①程序库不应该在未提前告知的情况下创建自己的“背景线程”
- 一个进程可以创建的并发线程数目受限于地址空间的大小和内核参数
- 一台机器可以同时并行运行的线程数目受限于CPU的数目
- 因此我们在设计一个服务端程序的时候要精心规划线程的数目,特别是根据机器的CPU数目来设置工作线程的数目,并为关键任 务保留足够的计算资源。如果程序库在背地里使用了额外的线程来执行任务,我们这种资源规划就漏算了。可能会导致高估系统的可用资源, 结果处理关键任务不及时,达不到预设的性能指标
- 还有一个重要原因是,一旦程序中有不止一个线程,就很难安全地 fork()了(参阅后面的“多线程与fork()”文章)。因此“库”不能偷偷创建线程。如果确实有必要使用背景线程,至少应该让使用者知道。另外,如果有可能,可以让使用者在初始化库的时候传入线程池或event loop对象,这样程序可以统筹线程的 数目和用途,避免低优先级的任务独占某个线程
②尽量用相同的方式创建线程,例如muduo::Thread
- 理想情况下,程序里的线程都是用同一个class创建的(例如muduo::Thread)
- 这样容易在线程的启动和销毁阶段做一些统一的簿记(bookkeeping)工作,比如说
- 调用一次muduo::CurrentThread::tid()把当前线程id缓存起来,以后再取线程id就不会陷入内核了
- 也可以统计当前有多少活动线程(线程数目可以cong/proc/pid/status拿到),进程一共创建了多少线程,每个线程的用途分别是什么
- C/C++的线程不像Java线程那样有名字,但是我们可以通过Thread class实现类似的效果
- 如果每个线程都是通过muduo::Thread启动的,上面这些都不难做到。必要的话可以写一个ThreadManager singleton class,用它来记录当前活动线程,可以方便调试与监控
- 但是这不是总能做到的,有些第三方库(C语言库)会自己启动线程,这样的“野生”线程就没有纳入全局的ThreadManager管理之中
- muduo::CurrentThread::tid()必须要考虑被这种“野生”线程调用的可能, 因此它必须每次都检查缓存的线程id是否有效,而不能假定在线程启动阶段已经缓存好了id,直接返回缓存值就行了
- 如果库提供异步回调, 一定要明确说明会在哪个(哪些)线程调用用户提供的回调函数,这样用户可以知道在回调函数中能不能执行耗时的操作,会不会阻塞其他任务的执行
③在进入main()函数之前不应该启动线程
- 在main()函数之前不应该启动线程,因为这会影响全局对象的安全构造
- 这里的全局对象也包括namespace级全局对象、文件级静态对象,class的静态对象,但不包含函数内的静态对象
- 我们知道:
- C++保证在进入main()之前完成全局对象的构造
- 同时,各个编译单元之间的对象构造顺序是不确定的,我们也有一些办法来影响初始化顺序,保证在初始化某个全局对象时使用到的其他全局对象都是构造完成的
- 但无论如何这些全局对象的构造是依次进行的, 都在主线程中完成,无须考虑并发与线程安全
- 因为这破坏了初始化全局对象的基本假设
- 万一将来代码改动之后造成该线程访问了未经初始化的全局对象,那么这种隐晦错误查起来就很费劲了
- 或许你想用锁来保证全局对象初始化完成,但是怎么保证这个全局的锁对象的构造能在线程启动之前完成呢?
- 因此,全局对象不能创建线程
- 如果一个库需要创建线程,那么应该进入main()函数之后再调用库的初始化函数去做
④程序中线程的创建最好能在初始化阶段全部完成
- 不要为了每个计算任务,每次请求去创建线程。一般也不会为每个网络连接创建线程,除非并发连接数与CPU数相近
- 一个服务程序的线程数目应该与当前负载无关,而应该与机器的CPU数目有关,即load average有比较小(最好不大于CPU数目)的上限。这样尽量避免出现thrashing(抖动),不会因为负载急剧增加而导致机器失去正常响应。这么做的重要原因是,在机器失去响应期间,我们无法探查它究竟在做什么,也没办法立刻终止有问题的进程,防止损害进一步扩大
- 如果有实时性方 面的要求,线程数目不应该超过CPU数目,这样可以基本保证新任务总能及时得到执行,因为总有CPU是空闲的
- 最好在程序的初始化阶段创建全部工作线程,在程序运行期间不再创建或销毁线程
- 借助 muduo::ThreadPool和muduo::EventLoop,我们很容易就能把计算任务和 IO任务分配到已有的线程,代价只有新建线程的几分之一
二、线程销毁的几种方式
- 自然死亡。从线程主函数返回,线程正常退出
- 非正常死亡。从线程主函数抛出异常或线程触发segfault信号等非法操作(通常伴随进程死亡。如果程序中的某个线程意外终止,我不认为让进程继续带伤运行下去有何必要)
- 自杀。在线程中调用pthread_exit()来立刻退出线程
- 他杀。其他线程调用pthread_cancel()来强制终止某个线程
- pthread_kill()是往线程发信号,留到后面的“多线程与signal”文章再介绍
线程正常退出的方式
- 线程正常退出的方式只有一种,即自然死亡。任何从外部强行终止线程的做法和想法都是错的
- 可参阅的文章有:
- 因为强行终止线程的话(无论是自杀还是他杀),它没有机会清理资源。也没有机会释放已经持有的锁,其他线程如果再想对同一个mutex加锁,那么就会立刻死锁。因此我认为不用去研究cancellation point(取消点)这种“鸡肋”概念(下面还会介绍)
如果确实需要强制终止线程
- 如果确实需要强行终止一个耗时很长的计算任务,而又不想在计算期间周期性地检查某个全局退出标志,那么可以考虑把那一部分代码fork()为新的进程,这样杀(kill)一个进程比杀本进程内的线程要安全得多
- 当然,fork()的新进程与本进程的通信方式也要慎重选取:
- 最好用文件描述符(pipe/socketpair/TCP socket)来收发数据
- 而不要用共享内存和跨进程的互斥器等IPC,因为这样仍然有死锁的可能
muduo::Thread
- muduo::Thread不是传统意义上的RAII class,因为它析构的时候没有销毁持有的Pthreads线程句柄(pthread_t),也就是说Thread的析构不会等待线程结束
- 一般而言,我们会让Thread对象的生命期长于线程,然后通过Thread::join()来等待线程结束并释放线程资源。如果Thread对象的生命期短于线程,那么就没有机会释放pthread_t了
- muduo::Thread没有提供detach()成员函数,因为我不认为这是必要的
线程有时不需要销毁
- 上面的“线程创建的几项原则”中的第④项中建议“程序中线程的创建最好能在初始化阶段全部完成”,如果能做到这一点,则线程是不必销毁的
- 因为线程将伴随进程一直运行,彻底避开了线程安全退出可能面临的各种困难,包括:Thread对象生命期管理、资源释放等等
三、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短文(udrepper.livejournal.com/21541.html)。不过按我的观点,不应该从外部杀死线程
三、exit()在C++中不是线程安全的
- exit()函数在C++中的作用除了终止进程,还会析构全局对象和已经构造完的函数静态对象
死锁演示案例
- exit()会存在潜在的死锁可能,考虑下面这个例子:
void someFunctionMayCallExit()
{
exit(1);
}
class GlobalObject
{
public:
void doit(){
MutexLockGuard lock(mutex_);
someFunctionMayCallExit();
}
~GlobalObject(){
printf("GlobalObject:~GlobalObject\n");
MutexLockGuard lock(mutex_);
printf("GlobalObject:~GlobalObject cleanning\n");
}
private:
MutexLock mutex_;
};
GlobalObject g_obj;
int main()
{
g_obj.doit();
}
- 程序产生的结果如下,程序阻塞在析构函数中造成死锁:
- doit()函数调用了someFunctionMayCallExit(),其中someFunctionMayCallExit()又调用了exit(),从而使全局g_obj对象析构了,但是doit()函数没有执行完,因此mutex_锁也没有释放
- 当g_obj全局对象析构的时候会调用析构函数,析构函数需要对mutex_进行加锁,但是锁被doit()所指持有,因此产生死锁
一个调用纯虚函数导致程序崩溃的例子
- 现在我们假设有一个策略基类,在运行时我们会根据情况用不同的无状态策略(派生类对象)。由于策略是无状态的,因此我们可以共享派生类对象,不必每次都新建
- 下面是一个例子:
- 这里以日志基类和不同国家的假期为例
- factory函数(getCalendar)返回某个全局对象的引用,而不是每次都创建新的派生类对象
class Date{};
class Calendar:boost::noncopyable
{
public:
virtual bool isHoliday(Date d)const=0;
virtual ~ Calendar(){}
};
class AmericanCalendar:public Calendar
{
public:
virtual bool isHoliday(Date d)const;
};
class BritishCalendar:public Calendar
{
public:
virtual bool isHoliday(Date d)const;
};
Calendar& getCalendar(const std::string& region);
- 通过调用factory函数返回一个国家对象,然后再调用某个日期对于该国家来说是否为假日
- 代码如下:
struct Request
{
std::string region;
Date settlement_date;
};
void processRequest(const Request& req)
{
Calendar& calendar=getCalendar(req.region);
if(Calendar.isHoliday(req.settlement_date));
{
}
}
- 如果有一天我们想主动退出这个服务程序,于是某个线程调用了exit(),析构了全局对象,结果造成另一个线程在调用Calendar::isHoliday()时发生了崩溃
- 结果如下图所示:
- 当然,这只是举例说明“用全局对象实现无状态策略”在多线程中析构可能有危险。在真实的项目中,Calendar应该在运行的时候从外部配置读入(比如,中国每年几大节日放假安排要等到头一年年底才由国务院假日办公布。又比如2012年英女王登基60周年,英国新加了一两个节日),而不能写死在代码中
- 这其实不是exit()的过错,而是全局对象析构的问题:
- C++标准没有照顾全局对象在多线程环境下的析构,据我看似乎也没有更好的办法
- 如果确实需要主动结束线程,则可以考虑用_exit系统调用。它不会试图析构全局对象,但是也不会执行其他任何清理工作,比如flush标准输出
- 由此可见,安全地退出一个多线程的程序并不是一件容易的事情
- 何况这里还没有涉及如何安全地退出其他正在运行的线程,这需要精心设计共享对象的析构顺序,防止各个线程在退出时访问已失效的对象
- 在编写长期运行的多线程服务程序的时候,可以不必追求安全地退出, 而是让进程进入拒绝服务状态,然后就可以直接杀掉了(参阅后面的“分布式系统中心跳协议的设计”文章)
四、总结
- 本专题未完结,参阅下一篇文章(多线程与IO、用RAII包装文件描述符)