muduo网络库:14---C++多线程编程精要之(多线程与IO、用RAII包装文件描述符)

一、多线程与IO

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

系统调用时线程安全的,但是使用不是线程安全的

  • 首先,操作文件描述符的系统调用本身是线程安全的,我们不用担心多个线程同时操作文件描述符会造成进程崩溃或内核崩溃
  • 但是,多个线程同时操作同一个socket文件描述符确实很麻烦,我认为是得不偿失的。需要考虑的情况如下:
    • 如果一个线程正在阻塞地read某个socket,而另一个线程close了此socket
    • 如果一个线程正在阻塞地accept某个listening socket,而另一个 线程close了此socket
    • 更糟糕的是,一个线程正准备read某个socket,而另一个线程 close了此socket;第三个线程又恰好open了另一个文件描述符,其 fd号码正好与前面的socket相同。这样程序的逻辑就混乱了(见下面的“用RAII包装文件描述符”)。
  • 我认为以上这几种情况都反映了程序逻辑设计上有问题
  • 现在假设不考虑关闭文件描述符,只考虑读和写,情况也不见得多好。因为socket读写的特点是不保证完整性,读100字节有可能只返回20字节,写操作也是一样的:
    • 如果两个线程同时read同一个TCP socket,两个线程几乎同时各自收到一部分数据,如何把数据拼成完整的消息?如何知道哪部分数据先 到达?
    • 如果两个线程同时write同一个TCP socket,每个线程都只发出去半条消息,那接收方收到数据如何处理?
    • 如果给每个TCP socket配一把锁,让同时只能有一个线程读或写此socket,似乎可以“解决”问题,但这样还不如直接始终让同一个线程来操作此socket来得简单
    • 对于非阻塞IO,情况是一样的,而且收发消息的完整性与原子性 几乎不可能用锁来保证,因为这样会阻塞其他IO线程
  • 如此看来,理论上只有read和write可以分到两个线程去,因为TCP socket是双向IO。问题是真的值得把read和write拆开成两个线程吗?

多线程不会加快磁盘IO

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

多线程应遵守的原则

  • 为了简单起见,我认为多线程程序应该遵循的原则是:
    • 每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition
    • 一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符
    • 这些不难做到,muduo网络库已经把这些细节封装了
  • epoll也遵循相同的原则:
    • Linux文档并没有说明:当一个线程正阻塞在epoll_ wait()上时,另一个线程往此epoll fd添加一个新的监视fd会发生什么。新fd上的事件会不会在此次epoll_wait()调用中返回?
    • 为了稳妥起见,我们应该把对同一个epoll fd的操作(添加、删除、修改、等待) 都放到同一个线程中执行,这正是我们需要muduo::EventLoop::wakeup() 的原因
  • 这条规则有两个例外:
    • 对于磁盘文件,在必要的时候多个线程可以同时调用pread/pwrite来读写同一个文件(pread/pwrite参阅:https://blog.csdn.net/qq_41453285/article/details/88936714
    • 对于UDP,由于协议本身保证消息的原子性,在适当的条件下(比如消息之间彼此独立)可以多个线程同时读写同一个UDP文件描述符
  • 当然,一般的程序不会直接使用epoll、read、write,这些底层操作都由网络库代劳了

二、用RAII包装文件描述符

  • 本节谈一谈在多线程程序中如何管理文件描述符。

POSIX标准分配文件描述符的方式

  • 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时,接收方已经物是人非,后果难以预料

用RAII封装文件描述符

  • 在单线程程序中,或许可以通过某种全局表来避免串话;在多线程程序中,我不认为这种做法会是高效的(通常意味着每次读写都要对全局表加锁)
  • 在C++里解决这个问题的办法很简单:RAII
    • 用Socket对象包装文件描述符,所有对此文件描述符的读写操作都通过此对象进行,对象的析构函数里关闭文件描述符
    • 这样一来,只要Socket对象还活着,就不会有其他Socket对象跟它有一样的文件描述符,也就不可能串话
  • 剩下的问题就是做好多线程中的对象生命期管理,这在前面“线程安全的对象生命期管理”几篇文章中已经介绍了

不要关闭标准输出和标准错误

  • 引申问题:为什么服务端程序不应该关闭标准输出(fd=1)和标准错误(fd=2)?
  • 因为有些第三方库在特殊紧急情况下会往stdout或stderr打印出错信息,如果我们的程序关闭了标准输出(fd=1)和标准错误(fd=2),这两个文件描述符有可能被网络连接占用,结果造成对方收到莫名其妙的数据
  • 正确的做法是把stdout或stderr重定向到磁盘文件(最好不要是/dev/null),这样我们不至于丢失关键的诊断信息。 当然,这应该由启动服务程序的看门狗进程完成(参考http://github.com/chenshuo/muduo-protorpc的Zurg slave示例),对服务程序本身是 透明的

对象生命期管理(使用shared_ptr来管理TcpConnection)

  • 现代C++的一个特点是对象生命期管理的进步,体现在不需要手工delete对象
  • 在网络编程中:
    • 有的对象是长命的(例如TcpServer):长命的对象的生命期往往和整个程序一样长,那就很容易处理,直接使用全局对象(或scoped_ptr) 或者做成main()的栈上对象都行
    • 有的对象是短命的(例如TcpConnection):
      • 对于短命的对象,其生命期不一定完全由我们控制,比如对方客户端断开了某个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,我想不出其他安全且高效的办法来管理多线程网络服务端程序中的并发连接 

三、总结

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

董哥的黑板报

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值