《Linux多线程服务端编程》—muduo网络库(1)

TCP网络编程本质论

思维转换:

把原来“主动调用recv(2)来接收数据,主动调用accept(2)来接受新连接,主动调用send(2)来发送数据”的思路转换为“注册一个收数据的回调,网络库收到数据会调用我,直接把数据提供给我,供我消费。注册一个接受连接的回调,网络库接受了新连接会回调我,直接把新连接对象传给我,供我使用。需要发送数据的时候,只管往连接中写,网络库会负责无阻塞地发送。”

作者(陈硕)认为,TCP网络编程最本质的是处理三个半事件:

1.连接的建立,包括服务端接受(accept)新连接和客户端成功发起(connect)连接。TCP连接一旦建立,客户端和服务端是平等的,可以各自收发数据。

2.连接的断开,包括主动断开(close、shutdown)和被动断开(read(2)返回0)。

3.消息到达,文件描述符可读。这是最为重要的一个事件,对它的处理方式决定了网络编程的风格(阻塞还是非阻塞,如何处理分包,应用层的缓冲如何设计等等)。

3.5 消息发送完毕,这算半个。对于低流量的服务,可以不必关心这个事件;这里的“发送完毕”,是指将数据写入操作系统的缓冲区,将由TCP协议栈负责数据的发送与重传,不代表对方已经收到数据。

Reactor模式

Reactor 是一种事件驱动机制。它和普通函数调用的不同之处在于:应用程序不是主动的调用某个API完成处理,而是恰恰相反,Reactor逆置了事件处理流程,应用程序需要提供相应的接口并注册到Reactor上,如果相应的时间发生,Reactor将主动调用应用程序注册的接口,这些接口又称为“回调函数”

moduo库Reactor模式的实现

muduo主要通过3个类来实现Reactor模式:EventLoop,Channel和Poller。

1. EventLoop

EventLoop是一个主控类,是一个事件发生器,它驱动Poller产生/发现事件,然后将事件派发到Channel处理。moduo的线程模型为 one loop per thread,即每个线程只能有一个 EventLoop 对象。EventLoop对象的生命周期通常和其所属的线程一样长。

主要数据成员:

    const pid_t threadId_; //保存当前EventLoop所属线程id

    boost::scoped_ptr poller_; //实现I/O复用 

    boost::scoped_ptr timerQueue_;

    int wakeupFd_;

    boost::scoped_ptr wakeupChannel_; //用于处理wakeupFd_上的可读事件,将事件分发到handlRead()

    ChannelList activeChannels_; //有事件就绪的Channel

    Channel* currentActiveChannel_;

    MutexLock mutex_; //pendingFunctors_会暴露给其他线程,所以需要加锁 

    std::vector pendingFunctors_; 

主要功能函数:

loop(),在该函数中会循环执行以下过程:
调用Poller::poll(),通过此调用获得一个vector< channel* >activeChannels_的就绪事件集合,再遍历该容器,执行每个Channel的 Channel::handleEvent() 完成相应就绪事件回调,最后执行 pendingFunctors_ 排队的函数。
上述一次循环就是一次Reactor模式完成。

runInLoop(boost::function< void() >),实现用户指定任务回调,若是EventLoop隶属的线程调用 EventLoop::runInLoop() 则 EventLoop 马上执行;若是其它线程调用则执行 EventLoop::queueInLoop(boost::function< void() > 将任务添加到队列中(线程转移)。
EventLoop如何知道有任务这件事呢——通过eventfd可以实现线程间通信,具体做法是:
其它线程向 EventLoop::vector< boost::function< void() > >添加任务T,然后通过 EventLoop::wakeup() 向 eventfd 写一个int,eventfd的回调函数 EventLoop::handleRead() 读取这个int,从而相当于 EventLoop 被唤醒,此时在loop中遍历队列,执行堆积的任务。这里采用Channel管理eventfd,Poller侦听eventfd,体现了eventfd可以统一事件源的优势。

queueInLoop(Functor& cb),将cb放入队列,并在必要时唤醒IO线程。有两种情况需要唤醒IO线程:
1 调用 queueInLoop() 的线程不是IO线程;
2 调用 queueInLoop() 的线程是IO线程,而此时正在调用pengding functor。

2. Channel

Channel是事件分发器,是设备fd的包装(主要包装socket)。每个Channel 只属于一个 EventLoop (也就是只属于一个IO线程),每个Channel只负责一个文件描述符fd的IO事件分发,但其不拥有fd。用户一般不直接使用Channel,而会使用更上层的封装,如TcpConnection。Channel的生命期由其owner class负责管理,它一般是其他class的直接或间接成员,fd由其owner拥有和关闭。

数据成员:

     int fd_; //文件描述符

     int events_; //文件描述符注册事件

     int revents_; //文件描述符的就绪事件,由Poller::poll设置

     // readCallback_,writeCallback...各种事件回调,
     // 会在拥有该Channel类的构造函数中被注册,
     // 例如TcpConnction会在构造函数中将TcpConnection::handlRead()注册
     // 给Channel::readCallback

主要功能函数:

setCallback() 系列函数:接受Channel所属的类注册相应的事件回调函数;

enableReading(),update():当一个fd想要注册可读事件时,首先通过

Channel::enableReading()->
Channel::update(this)->
EventLoop::updateChannel(Channel)->
Poller::updateChannel(Channel*)

调用链向poll系统调用的侦听事件表注册或者修改注册事件。

handleEvent():事件分发器Channel的核心,由EventLoop::loop()调用,该函数调用 Channel::handleEventWithGuard(),在其内根据 Channel::revents 的值分发调用相应的事件回调。

3. Poller

Poller是IO multiplexing的封装,封装了poll和epoll。Poller是EventLoop的间接成员,只供拥有该Poller的EventLoop在IO线程调用。生命期与EventLoop相等。

主要数据成员:

    vector pollfds_; //事件结构体数组,用于poll的第一个参数;

    map<int,channel*> channels_; //用于文件描述符fd到Channel的映射便于快速查找到相应的Channel

主要功能函数:

updateChannel(Channel*) :用于将传入的Channel关心的事件注册给Poller。

poll(int timeoutMs,vector< channel* > activeChannels):其调用poll获得当前活动的事件集合,将就绪事件所属的Channel调用fillActiveChannels()加入到调用方传入的 activeChannels_ 中。

Reactor模式的核心内容时序图

《Linux多线程服务端编程》—muduo网络库(1)_第1张图片

其他类简介

EventLoopThread:启动一个自己的线程并在其中运行一个EventLoop,其语义和”one loop per thread“相吻合。其关键的函数startLoop()会返回新线程中 EventLoop 对象的地址,因此需要用条件变量来等待线程的创建与运行(因为EventLoopThread对象已创建,所以startLoop()可以调用了,但是线程的创建和运行可能还没完成,此时EventLoop 对象还没构造完成,如果不等待则可能出错)。线程主函数会在stack上定义EventLoop对象,然后将其地址赋值给loop_成员变量,最后notify()条件变量,唤醒startLoop()。
由于EventLoop的生命期与线程主函数的作用域相同,因此在threadFunc()退出之后,这个指针就失效了(好在服务程序一般不要求能安全地退出)。

TcpConnection:抽象一个TCP连接,无论是客户端还是服务器只要建立了网络连接就会使用TcpConnection;

TcpClient/TcpServer:分别抽象TCP客户端和服务器;

Connector/Acceptor:分别包装TCP客户端和服务器的建立连接/接受连接;

Acceptor 接受新连接

Acceptor用于accept(2)新TCP连接,并通过回调通知使用者,它是内部类,供TcpServer使用,生命期由后者控制。

Acceptor的数据成员包括Socket、Channel等,其中Socket是一个RAII handle,它是listening socket。Channel用于观察此socket上的readable事件,并回调Acceptor::handleRead(),后者会调用accept(2)来接受新连接,并回调用户callback。

TcpServer 新建TcpConnection

TcpServer新建连接的相关函数调用顺序如下:

其中Channel::handleEvent()的触发条件是listening socket可读,表示有新连接到达。TcpServer会为新连接创建相应的TcpConnection对象。

TcpServer class

TcpServer class的功能时管理 accept(2) 获得的TcpConnection。TcpServer供用户直接使用,生命期由用户控制。

TcpServer内部使用Acceptor来获得新连接的fd,它保存用户提供的ConnectionCallback 和 MessageCallback,在新建TcpConnection的时候会原样传给后者。

每个TcpConnection对象都有一个名字,是所属TcpServer在创建TcpConnection对象时生成的,名字作为ConnectionMap的key。

在新连接到达时,Acceptor会回调newConnection(),后者会创建TcpConnection对象conn,把它加入到ConnectionMap中,设置好callback,再调用 conn->connectEstablished(),其中会回调用户提供的ConnectionCalback。

TcpConnection class

TcpConnection 的生命周期由shared_ptr来管理,以防止访问失效的对象或者发生网络串话(即旧的TCP连接断开,新的TCP连接使用了同一个文件描述符,原本发给前世的信息误发给今生了)。通过weak_ptr,我们就能知道socket连接在处理request期间是否已经关闭了。

TcpConnection 使用Channel来获得socket上的IO事件,它会自己处理writable事件,而把readable事件通过MessageCallback传达给客户。

TcpConnection 拥有TCP socket,它的析构函数会 close(fd)。

TcpConnection 表示的是“一次TCP连接”,是不可再生的,一旦连接断开,该对象就没用了。另外,TcpConnection 没用发起连接的功能,其构造函数的参数是已经建立好连接的socket fd。

Buffer

为什么需要有output buffer:在write()调用中,操作系统可能不会接受所有应用程序的数据并一次性发送,而是可能分多次发送,为了尽快返回event loop,而不是在那等待,TcpConnection需要有output buffer,将数据存放起来,在数据发完之前,持续关注POLLOUT事件。

有一点需要注意,TcpConnection关闭连接使用shutdown()而不是close(),因为如果对方已经发送了数据,而这些数据还在路上,此时如果调用close(),socket的读写都关闭,数据将会丢失。而shutdown()可以只关闭读或者写的其中一个(比如关闭写方向的连接,而保留读方向),称为TCP的半关闭。这样子我们就可以先关闭写的一端,等接受完所有数据之后,再关闭读的一端。

为什么需要有input buffer:TCP是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等情况。网络库在处理socket可读事件时,必须一次性把socket里的数据读完(从操作系统buffer搬到应用层buffer),不然会反复触发POLLIN事件(level trigger)。为应对“数据不完整”的情况,收到的数据先放到input buffer里,等构成一条完整的消息再通知程序的业务逻辑。

多线程TcpServer

EventLoopThreadPool

多线程TcpServer自己的EventLoop只用来接受新连接,而新连接会从event loop pool里面挑选一个loop来执行IO。目前muduo使用最简单的轮询调度(Round-Robin Scheduling)算法来选取pool中的EventLoop。

Connector

主动发起连接需要处理各种错误,以及考虑重试。我们把它封装为Connector class,它只负责建立socket连接,不负责创建TcpConnection,它的NewConnectionCallback回调的参数是socket文件描述符。

实现的几个难点:

  1. socket是一次性的,一旦出错,无法恢复,只能关闭重来,但Connector是可以重用的,因此每次尝试连接都要使用新的socket文件描述符和新的Channel对象。要留意Channel对象的生命期管理,并防止socket文件描述符泄露。
  2. EAGAIN是真错误,需要关闭socket再延期重试,EINPROGRESS是正在连接。即便出现socket可写,也不一定意味着连接已成功建立,需要用getsockopt(sockfd, SOL_SOCKET, SO_ERROR,…)再次确认下。
  3. 重试的间隔应该逐渐延长,例如0.5s,1s,2s,4s,直至30s,即back-off。需要在Connector的析构函数中注销定时器。
  4. 要处理自连接。处理方法是断开连接再重试。

你可能感兴趣的:(UNIX网络编程,服务器,C/C++)