码迷,mamicode.com
首页 > 其他好文 > 详细

Chapter 4-02

时间:2016-04-30 06:39:22      阅读:238      评论:0      收藏:0      [点我收藏+]

标签:

Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1
4.6 多线程与IO
?本书只讨论同步IO,包括阻塞与非阻塞,不讨论异步IO(AIO)。在进行多线程网络编程的时候,几个问题是:如何处理IO?能否多个线程同时读写同一个socket文件描述符?我们知道用多线程同时处理多个socket通常可以提高效率,那么用多线程处理同一个socket也可以提高效率吗?
?首先,操作文件描述符的系统调用本身是线程安全的,我们不用担心多个线程同时操作文件描述符会造成进程崩溃或内核崩溃。
?但是,多个线程同时操作同一个socket文件描述符很麻烦,得不偿失。需要考虑的情况如下:
1.如果一个线程正在阻塞地read(2)某个socket,而另一个线程close(2)了此socket。
2.如果一个线程正在阻塞地accept(2)某个listening socket,而另一个线程close(2)了此socket。
3.更糟糕的,一个线程正准备read(2)某个socket,而另一个线程close(2)了此socket;第三个线程又恰好open(2)了另一个文件描述符,其fd号码正好与前面的socket相同。这样程序的逻辑就混乱了(§4.7)。
?以上几种情况都反映了程序逻辑设计上有问题。
?现在假设不考虑关闭文件描述符,只考虑读和写,情况也不见得多好。因为socket读写的特点是不保证完整性,读100字节有可能只返回20字节,写操作也是一样的。
1.如果两个线程同时read同一个TCP socket,两个线程几乎同时各自收到一部分数据,如何把数据拼成完整的消息?如何知道哪部分数据先到达?
2.如果两个线程同时write同一个TCP socket,每个线程都只发出去半条消息,那接收方收到数据如何处理?
3.如果给每个TCP socket配一把锁,让同时只能有一个线程读或写此socket,可以解决问题,但这样还不如直接始终让同一个线程来操作此socket简单。
4.对于非阻塞IO,情况是一样的,而且收发消息的完整性与原子性几乎不可能用锁来保证,因为这样会阻塞其他IO线程。
?如此看来,理论上只有read和write可以分到两个线程去,因为TCP socket是双向IO。问题是值得把read和write拆开成两个线程吗?
?以上讨论的都是网络IO,那么多线程可以加速磁盘IO吗?首先要避免lseek(2)/ read(2)的race condition(§4.2)。做到这一点之后,用多个线程read或write同一个文件也不会提速。
?不仅如此,多个线程分别read或write同一个磁盘上的多个文件也不见得能提速。因为每块磁盘都有一个操作队列,多个线程的读写请求到了内核是排队执行的。只有在内核缓存了大部分数据的情况下,多线程读这些热数据才可能比单线程快。
?多线程磁盘IO的一个思路是每个磁盘配一个线程,把所有针对此磁盘的IO都挪到同一个线程,这样或许能避免或减少内核中的锁争用。
?我认为应该用“显然是正确”的方式来编写程序,一个文件只由一个进程中的一个线程来读写,这种做法显然是正确的。
?为了简单起见,多线程程序应该遵循的原则是:每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition。一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。
?epoll也遵循相同的原则。Linux文档没有说明:当一个线程正阻塞在epoll_wait()上时,另一个线程往此epoll fd添加一个新的监视fd会发生什么。新fd上的事件会不会在此次epoll_wait()调用中返回?为了稳妥起见,我们应该把对同一个epoll fd的操作(添加、删除、修改、等待)都放到同一个线程中执行,这正是我们需要muduo::EventLoop::wakeup()的原因。一般的程序不会直接使用epoll、read、write,这些底层操作都由网络库代劳了。
?这条规则有两个例外:对于磁盘文件,在必要的时候多个线程可以同时调用pread(2)/pwrite(2)来读写同一个文件;对于UDP,由于协议本身保证消息的原子性,在适当的条件下(比如消息之间彼此独立)可以多个线程同时读写同一个UDP文件描述符。
4.7 用RAII包装文件描述符
?Linux的文件描述符(file descriptor)是小整数,在程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。这时如果我们新打开一个文件,它的文件描述符会是3,因为POSIX标准要求每次新打开文件(含socket)的时候必须使用当前最小可用的文件描述符号码。
?POSIX这种分配文件描述符的方式容易造成串话。比如,一个线程正准备read(2)某个socket,而第二个线程几乎同时close(2)了此socket;第三个线程又恰好open(2)了另一个文件描述符,其号码正好与前面的socket相同(因为比它小的号码都被占用了)。这时第一个线程可能会读到不属于它的数据,不仅如此,还把第三个线程的功能也破坏了,因为第一个线程把数据读走了(TCP连接的数据只能读一次,磁盘文件会移动当前位置)。另外一种情况,一个线程从fd = 8收到了比较耗时的请求,它开始处理这个请求,并记住要把响应结果发给fd = 8。但是在处理过程中,fd = 8断开连接,被关闭了,又有新的连接到来,碰巧使用了相同的fd = 8。当线程完成响应的计算,把结果发给fd = 8时,接收方已经变了,后果未知。
?在单线程程序中,可以通过某种全局表来避免串话;在多线程程序中,这种做法不高效,因为意味着每次读写都要对全局表加锁。
?在C++里用RAII解决这个问题。用Socket对象包装文件描述符,所有对此文件描述符的读写操作都通过此对象进行,在对象的析构函数里关闭文件描述符。只要Socket对象还活着,就不会有其他Socket对象跟它有一样的文件描述符,也就不可能串话。剩下就是做好多线程中的对象生命期管理,见第1章。
?为什么服务端程序不应该关闭标准输出(fd = 1)和标准错误(fd = 2)?
因为有些第三方库在特殊情况下会往stdout或stderr打印出错信息,如果我们的程序关闭了标准输出(fd = 1)和标准错误(fd = 2),这两个文件描述符有可能被网络连接占用,结果造成对方收到莫名其妙的数据。正确的做法是把stdout或stderr重定向到磁盘文件(最好不要是/dev/null),这样我们不至于丢失关键的诊断信息。这应该由启动服务程序的进程完成29,对服务程序本身是透明的。

?现代C++的一个特点是对象生命期管理的进步,即不需要手工delete对象。在网络编程中,有的对象是长命的(如TcpServer),有的对象是短命的(如TcpConnection)。长命的对象的生命期往往和整个程序一样长,直接使用全局对象(或scoped_ptr)或者做成main()的栈上对象都行。对于短命的对象,其生命期不一定完全由我们控制,比如对方客户端断开了某个TCP socket,它对应的服务端进程中的TcpConnection对象(必然是个heap对象,不可能是stack对象)的生命也走到尽头。但是这时我们并不能立刻delete这个对象,因为其他地方可能还持有它的引用,贸然delete会造成空悬指针。只有确保其他地方没有持有该对象的引用的时候,才能安全地销毁对象,这会用到引用计数。见第1章。
?在非阻塞网络编程中,我们会面临这样的场景:从某个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的生命期。这是唯一一个采用引用计数方式管理生命期的对象。
4.8 RAII与fork()
?编写C++时,我们总是设法保证对象的构造和析构是成对出现的,否则就几乎一定会有内存泄漏。这一点不难做到(§1.7)。利用这一特性,我们可以用对象来包装资源,把资源管理与对象生命期管理统一起来(RAII)。
?但是,假如程序会fork(),这一假设就会被破坏。考虑下面这个例子,Foo对象构造了一次,但是析构了两次。

#include <unistd.h>
#include <stdio.h>

class Foo
{
public:
    Foo()
    {
        printf("Ctor\n");
    }

    void doit()
    {
        printf("doit()\n");
    }

    ~Foo()
    {
        printf("Dtor\n");
    }
};

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

?如果Foo class封装了某种资源,而这个资源没有被子进程继承,那么Foo::doit()的功能在子进程中是错乱的。而我们没有办法自动预防这一点,总不能每次申请一个资源就去调用一次pthread_atfork()吧?
?fork()之后,子进程继承了父进程的几乎全部状态,但也有少数例外。子进程会继承地址空间和文件描述符,因此用于管理动态内存和文件描述符的RAII class都能正常工作。但是子进程不会继承:
1.父进程的内存锁,mlock(2)、mlockall(2)。
2.父进程的文件锁,fcntl(2)。
3.父进程的某些定时器,setitimer(2)、alarm(2)、timer_create(2)等等。
4.其他,见man 2 fork。
?通常我们会用RAII手法来管理以上种类的资源(加锁解锁、创建销毁定时器等等),但是在fork()出来的子进程中不一定正常工作,因为资源在fork()时已经被释放了。比方说用RAII技法封装timer_create()/timer_delete(),在子进程中析构函数调用timer_delete()可能会出错,因为试图释放一个不存在的资源。或者更糟糕地把其他对象持有的timer给释放了(如果碰巧新建的timer_t与之重复的话)。
?因此,在编写服务端程序的时候,“是否允许fork()”是在开始时就应该考虑的问题,在一个没有为fork()做好准备的程序中使用fork(),会遇到难以预料的问题。
4.9 多线程与fork()
?多线程与fork()30的协作性很差。这是POSIX系列操作系统的历史包袱。因为长期以来程序都是单线程的,fork()运转正常。当20世纪90年代初期引入多线程之后,fork()的适用范围大为缩减。

?fork()一般不能在多线程程序中调用31 32,因为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()、snprintf33等等。
3.任何Pthreads函数。不能用pthread_cond_signal()去通知父进程,只能通过读写pipe(2)来同步34。
4.printf()系列函数,因为其他线程可能恰好持有stdout/stderr的锁。
5.除了man 7 signal中明确列出的“signal安全”函数之外的任何函数。

?唯一安全的做法是在fork()之后立即调用exec()执行另一个程序,彻底隔断子进程与父进程的联系。
4.10 多线程与signal
?Linux/Unix的信号(signal)与多线程是水火不容35。在单线程时代,编写信号处理函数(signal handler)是一件棘手的事情,由于signal打断了正在运行的thread of control,在signal handler中只能调用async-signal-safe的函数36,即所谓的“可重入(reentrant)”函数。不是每个线程安全的函数都是可重入的,见§4.9举的例子。

?另外,如果signal handler中需要修改全局数据,那么被修改的变量必须是sig_atomic_t类型的38。否则被打断的函数在恢复执行后很可能不能立刻看到signal handler改动后的数据,因为编译器有可能假定这个变量不会被他处修改,从而优化了内存访问。

?在多线程时代,signal的语义更为复杂。信号分为两类:发送给某一线程(SIGSEGV),发送给进程中的任一线程(SIGTERM),还要考虑掩码(mask)对信号的屏蔽等。特别是在signal handler中不能调用任何Pthreads函数,不能通过condition variable来通知其他线程。
?在多线程程序中,使用signal的第一原则是不要使用signal39。包括
1.不要用signal作为IPC的手段,包括不要用SIGUSR1等信号来触发服务端的行为。如果确实需要,可以用§9.5介绍的增加监听端口的方式来实现双向、可远程访问的进程控制。
2.不要使用基于signal实现的定时函数,包括alarm/ualarm/setitimer/timer_create、sleep/usleep等等。
3.不主动处理各种异常信号(SIGTERM、SIGINT等等),只用默认语义:结束进程。有一个例外:SIGPIPE,服务器程序通常的做法是忽略此信号40,否则如果对方断开连接,而本机继续write的话,会导致程序意外终止。
4.在没有别的替代方法的情况下(比方说需要处理SIGCHLD信号),把异步信号转换为同步的文件描述符事件。传统的做法是在signal handler里往一个特定的pipe(2)写一个字节,在主程序中从这个pipe读取,从而纳入统一的IO事件处理框架中去。现代Linux的做法是采用signalfd(2)把信号直接转换为文件描述符事件,从而从根本上避免使用signal handler41。

4.11 Linux新增系统调用的启示
?本节源自博客42,省略了signalfd、timerfd、eventfd等内容。

?大致从Linux内核2.6.27起,凡是会创建文件描述符的syscall一般都增加了额外的flags参数,可以直接指定O_NONBLOCK和FD_CLOEXEC,例如:
accept4- 2.6.28、eventfd2- 2.6.27、inotify_init1- 2.6.27、pipe2- 2.6.27、signalfd4- 2.6.27、timerfd_create- 2.6.25
?以上6个syscall,除了最后一个是2.6.25的新功能,其余的都是增强原有的调用,把数字尾号去掉就是原来的syscall。
?O_NONBLOCK的功能是开启“非阻塞IO”,而文件描述符默认是阻塞的。这些创建文件描述符的系统调用能直接设定O_NONBLOCK选项。
?另外,以下新系统调用可以在创建文件描述符时开启FD_CLOEXEC选项:
dup3- 2.6.27、epoll_create1- 2.6.27、socket- 2.6.27
?FD_CLOEXEC的功能是让程序exec()时,进程会自动关闭这个文件描述符。而文件描述默认是被子进程继承的(这是传统Unix的一种典型IPC,比如用pipe(2)在父子进程间单向通信)。
?以上8个新syscall都允许直接指定FD_CLOEXEC,说明fork()的主要目的已经不再是创建worker process并通过共享的文件描述符和父进程保持通信,而是创建干净的进程(fork()之后立刻exec()),与父进程没有多少瓜葛。为了回避fork() + exec()之间文件描述符泄漏的race condition,这才在几乎所有能新建文件描述符的系统调用上引入了FD_CLOEXEC参数,参见《Secure File Descriptor Handling》43。
?以上两个flags说明Linux服务器开发的主流模型正在由fork() + worker processes模型转变为第3章推荐的多线程模型。fork()的使用频度会降低,将来或许只有专门负责启动别的进程的程序才会调用fork(),而一般的网络服务器程序不会再fork()出子进程了。原因之一是,fork()一般不能在多线程程序中调用(§4.9)。
小结
?本章只讨论了多线程编程的技术方面,没有讨论设计方面,特别是没有讨论该如何规划一个多线程服务程序的线程数目及用途。我个人遵循的编写多线程C++程序的原则如下:
1.线程是宝贵的,一个程序可以使用几个或十几个线程。一台机器上不应该同时运行几百个、几千个用户线程,这会大大增加内核scheduler的负担,降低整体性能。
2.线程的创建和销毁是有代价的,一个程序最好在一开始创建所需的线程,并一直反复使用。不要在运行期间反复创建、销毁线程,如果必须这么做,其频度最好能降到1分钟1次(或更低)。
3.每个线程应该有明确的职责,例如IO线程(运行EventLoop::loop(),处理IO事件)、计算线程(位于ThreadPool中,负责计算)等等(§3.5.3)。
4.线程之间的交互应该尽量简单,理想情况下,线程之间只用消息传递(例如BlockingQueue)方式交互。如果必须用锁,那么最好避免一个线程同时持有两把或更多的锁,这样可彻底防止死锁。
5.要预先考虑清楚一个mutable shared对象将会暴露给哪些线程,每个线程是读还是写,读写有无可能并发进行。
Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1

Chapter 4-02

标签:

原文地址:http://blog.csdn.net/gaoxiangnumber1/article/details/51285028

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!