linux多线程服务端编程

Doug Schmidt : 网络编程中有很多是事务性的工作,可以提取为公用的框架或库,而用户只需要填上关键的业务逻辑代码,并将回调注册到框架中,可以实现完整的网络服务,这也是Reactor模式的主要思想,它的意义在于将消息(IO事件)分发到用户提供的处理函数,并保持网络部分的通用代码不变,独立于用户的业务逻辑
 
6.6.2 常见的并发网络服务程序设计方案P160
    方案1. child-per-client或fork()-per-client,此方案适合并发连接数不大的情况,且计算响应的工作量远大于fork()的开销,如数据库服务器,也就是说适合长连接而不适合短链接,用pthread()-per_client用时会少一点,但是仍然有上述局限性。另外,这几种方案都是阻塞式网络编程,当一个线程/进程如果阻塞在read上,但程序又想给TCP连接发数据,那么将无法进行,解决方法是要么用两个线程(一个负责读一个负责写),要么就用io多路复用(要求非阻塞)。
这种方案每个线程负责整个事件,那么即使在poll返回之后,也只会在处理完当前事件之后,再去处理下一个poll
    方案2.收到计算请求之后,不在Reactor线程计算,而是创建一个新线程计算,以充分利用多核CPU,创建新线程的开销可以用线程池避免。但是如果一个线程上有多个计算请求,他们会被分配给不同的线程计算(当然也可以指定仅仅由某个计算线程计算,取决于突发性和公平性的选择),那么由于计算复杂度不同,返回可能顺序就不一样了,我们可以设置id,辨别request对应response。
    
    如图,我们使用线程池,里面有多个线程可以同时处理一个Reactor上的突发计算请求。也就是全部的IO工作都在一个Reactor线程完成,而计算任务交给thread poll。此方案适用于:计算任务彼此独立,而且IO压力不大
    线程池的另一个作用是执行阻塞操作。比如有的数据库的客户端只是提供同步访问,那么可以把数据库查询放到线程池中,可以避免阻塞IO线程,不会影响其他客户连接。另外可以用线程池来调用一些阻塞的IO函数
 
 
    方案3:IO压力比较大,一个Reactor(IO thread)处理不过来,并且计算量较少,可以在一个IO线程内完成。这里muduo默认内置的多线程方案,也就是one loop per thread。由一个main Reactor负责accept连接,然后把连接挂在某个sub Reactor中。这样该连接的所有操作(读,解码,计算,编码,写)都在那个sub Reactor所处的线程中完成,多个连接可能被分派到多个线程中,以充分利用CPU
    
 
方案4:使用多个Reactor来处理IO,又使用线程池来处理计算。这种方案适合既有突发IO(每个连接上的IOread,write很多很频繁,要利用多线程处理多个连接上的IO),又有突发计算的应用(每个计算任务重,而且计算任务繁多),则利用线程池把一个连接上的计算任务分配给多个线程去做。
 
 
 
选择建议:
    1.如果请求有优先级之分,那么高优先级的单独开一个IO thread来处理它的计算,因为对于每个IO thread,epoll返回的事件没有优先级之分。
    2.按照1000Mbit/s的吞吐量配一个io thread,如果程序本身没有多少计算量,主要瓶颈在网络带宽,则只用一个io thread处理(读,解码,计算,编码,写),如方案3。如果程序计算量较大,而且对延迟不敏感,则可以把计算放到线程池中,如方案4
 
归纳这几种,推荐one loop per thread + thread pool,即最后一种方案
    event loop(io thread)用作non-blocking io和定时器
    thread pool用来做计算,具体可以是任务队列或者是生产者消费者队列
 
此图解释:
    网络IO,就是新来的连接怎么处理,对于方案2,有多少连接建立多少io thread,所以是N;方案5,8,直接在连接线程上进行;方案9,11,因为sub reactor有大小限制,但是跟IO连接无关。
    
 
 
muduo TcpServer有一个内置的one loop per thread多线程IO模型,可以通过setThreadNum()来开启
1.收发数据时,需要保证进程是非阻塞的,因此可以设置一个网络缓冲区,负责保存收发的数据,从而客户端和服务器可以直接非阻塞返回
2.收发数据时,由于进程所用的内存有限,如果while循环一次读取文件(1G)的全部内容,如果是多线程的,那么内存会不够。
   因此可以设置每次读取64kb,发送完再读取,利用一个write的回调函数,每次都读取,直到文件读取完毕   P190
 
3.有一些资源(如文件描述符fd,套接字socketid)需要在用完时释放,如果使用RAII,把该资源绑定在某个对象里面(该对象的析构函数会调用本资源的释放函数),则该资源的生命期与对象一致,对象在离开作用域时自动调用析构函数,因此就能够自动释放该资源了
 
4.non-blocking网络编程中buffer P205
   保证了应用程序如果有数据需要发送时,调用发送函数后可以不阻塞直接返回,这些数据由tcp:connection中的buffer托管
   如果output buffer里还有待发送的数据,而程序又想关闭连接,那么这时候网络库不能立刻关闭连接,而要等数据发送完毕。因此TcpConnection::shutdown()没有直接关闭TCP连接,与close()不同
 
    所有muduo中的IO都是带缓冲的IO,你不会自己去read或者write某个socket,只会操作TcpConnection的Input buffer和output buffer。确切的说,在onMessage()回调里读取input buffer,调用TcpConnection::send()来间接操作output buffer,一般不会直接操作output buffer
 
    数据库建立时应考虑的问题:
        1.减少系统调用,一次读的数据越多越划算,那么应当准备一个大的缓冲区
        2.减少内存占用,如果有10000个并发连接,每个连接一建立就分配50kb的读写缓冲区的话,那么将占用1GB内存,而大多数时候这些缓冲区的使用率很低。
 
5.不同的eventloop数目和是否使用多个thread组合,应对不同的场景
 
 
6.限制并发连接数 P237
    1.服务程序超载问题
    2.因为文件描述符fd是稀缺资源,如果出现fd耗尽,会很棘手,原因是:如果本进程的文件描述符达到上限,accept()返回EMFILE,则无法为新连接创建socket文件描述符。又因为没有socket文件描述符来表示这个连接,所以我们无法close它。程序继续运行,会重新回到epoll_wait这里,返回一个无法生成fd的有读写需求的事件,只要一天没有fd生成,程序就会陷入busy loop,影响本event loop上的其他连接和其他服务
 
    解决办法:
    准备一个空闲的文件描述符。遇到这种情况,先关闭这个空闲文件,获得一个fd名额,使accept返回这个新的文件描述符,然后立刻close它,断开此客服端连接,然后重新打开一个空闲文件,把此fd占住,以备再次出现这种情况
    另外我们可以设置比max fd小一点的一个limit,比方说1024个fd,我们在fd总数为1000就拒绝新连接了
    muduo:用一个AtomicInt的int成员,表示当前连接数,如果连接数大于最大连接数,则shutdown掉该连接
 
7.定时器
    muduo的TimerQueue采用了平衡二叉树来管理未到期的timers,时间复杂度为O(logN)
    在非阻塞服务端编程中,不能用sleep()或类似的办法让程序原地停留等待,这会让程序失去响应,因为主事件循环被挂起了,无法处理IO事件。对于定时任务,把它变成一个特定的消息,到时候触发相应的消息处理函数就行了
 
 
10.用timing wheel踢掉空闲连接P251
    处理连接超时可以用一个简单的数据结构:8个桶组成的循环队列,第零个桶表示这些连接在下一秒就要被断开,第一个桶放1秒之后将要超时的连接,第二个桶放2秒之后将要超时的连接。每个连接一收到数据就把自己放进第七个桶,然后在每秒的timer里把第零个桶的连接断开,把这个空桶放到队尾,时间重置为7S,而且tail指针永远指向时间为7s的队尾桶。相当于每次不用检查所有的连接,只要检查第一个桶里的连接,相当于把任务分散了,相比于每1秒都要检查所有的桶,这里相当于把空间换时间。
    如果断开conn1之前收到数据,就把它移到当前tail指向的桶里,它的时间被重置为7S
 
 
    具体实现:
    用share_ptr管理Entry,如果从连接收到数据,就把相应的EntryPtr放到这个格子里,这样它的引用计数就递增了。当Entry的引用计数递减到0时,说明它没有在任何一个格子出现,那么连接超时,Entry的析构函数就会断开连接。
    用weak_ptr管理Entry,这个是用于保存到TcpConnection的context里(这个不计数),因为在收到数据的时候,我们要把这个Entry提升为share_ptr,放到最后一个桶里就行,前面也有同样的share_ptr不用管,到期它会自动被析构的
    用unordered_set装这些share_ptr,每个表示一个桶,我们只需要在loop的runEvery函数里设置一个回调函数onTimer(),它只做一个事情,往circular_buffer()的队尾添加一个空的Bucket,这样circular_buffer就会弹出队首的Bucket,并且析构它。在析构Bucket的时候,会依次析构其中的EntryPtr对象,就能保证Entry的引用计数是正确的。
    用boost::circular_buffer装这些桶,则桶的大小固定,排序固定,队尾插一个,队头会自动弹出,并且调用析构函数
    
 
第八章 Muduo网络库的设计与实现
    EventLoop,在one loop per thread模式里(一个线程只能有一个eventloop),它的构造函数会检查当前线程是否已经创建了其他EventLoop对象,遇到错误就终止程序(LOG_FATAL)。它会记住本对象所属的线程(threadId_),生命期和所属的线程一样长。loop()负责调用Poller::poll获得当前活动事件的Channel列表,然后依次调用每个Channel的handleEvent()函数
    
    Channel:每个Channel对象自始至终只属于一个EventLoop,因此每个Channel对象都只属于某一个IO线程。每个Channel对象自始至终只负责一个fd的IO事件分发,但它并不拥有这个fd,也不会在析构的时候关闭这个fd。Channel负责把不同的IO事件分发给不同的回调,例如ReadCallback,WriteCallback。events_是它关心的IO事件,由用户设置,revents_是目前活动的事件,由EventLoop/Poller设置,每个channel都和一个pollfd对应,如果某个Channel暂时不关心任何事件,就把pollfd.fd设为-1,让poll忽略此项。
    
    Poller:是IO多路复用的封装,其生命期与EventLoop相等。Poller并不拥有Channel,Channel在析构之前必须自己unregister(EventLoop::removeChannel()),避免空悬指针
                Poller::poll()是Poller的核心功能,它获得当前活动的IO事件,然后用fillActiveChannels()遍历pollfds_,把活动的fd对应的channel填充到调用方传入的activeChannels,也就是告诉channel集合他们分别负责的fd有什么事件发生了。
 
Reactor模式的核心内容:
    
 
8.2定时器
    TimerQueue()只有addTimer()和cancel两个函数,addTimer以后会被EventLoop封装为更好用的runAt()、runAfter()、runEvery()等函数
    由于TimerQueue需要高效地组织目前尚未到期的Timer,能快速地根据当前事件找到已经到期的Timer,也能高效添加和删除Timer,
    数据结构:
        因此可以用按到期时间排序的线性表。查找复杂度为O(N)
        二叉堆组织优先队列,复杂度O(logN),如果要快速删除其中的某个元素,则Timer要记住自己在heap中的位置,从而高效删除heap中间的某个元素
        二叉搜索树(std::set/std::map),把Timer按到期时间先后排好序,时间复杂度仍为O(logN)。碰到两个时间相同的,我们用pair<Timestamp,Timer*>为key,保证他们都能存进去
 
    EventLoop有runat,runAfter,runEvery函数利用定时器定时执行某回调
        
8.3 EventLoop::runInLoop()函数
    EventLoop:在它的IO线程内执行某个用户任务回调。如果用户在当前IO线程调用这个函数,回调会同步进行;如果用户在其他线程调用runInloop(),cb会被加入队列,对应的IO线程会被唤醒来调用这个Functor。这样可以保证在不用锁的情况下保证线程安全性
    
    IO线程什么时候会被唤醒?1.如果调用queueInLoop()的线程不是IO线程,那么需要唤醒,;2.如果在IO线程调用queueInLoop(),而此时正在调用pending functor(挂起的函数),那么也必须唤醒
    1是因为,loop()函数里,while循环中,每个IO线程先要处理poll来的事件,最后才作为work线程处理其他线程产生的事件,因此,如果此时没有poll事件出现,那么所有的IO线程都会阻塞在poll上,就不能处理IO事件了,因此需要不断地唤醒,一有消息就唤醒,poll可以下次再读,但是如果卡在poll那里的话其他线程产生的事件就不能处理了
    2是因为doPendingFunctor()调用的Functor可能再调用queueInLoop(cb),这是queueInLoop()就必须wakeup(),否则这些新加的cb就不能被及时调用了??
 
    EventLoopThread class
    定义Eventloop的线程类,它随时准备运行,但是它的loop_是空的,它只能等待,只有在它的loop_被赋予实值得时候,这个线程才真正开始运行
 
8.4 实现TCP网络库
    
 
    Acceptor class
    用于accept新TCP连接,并通过回调通知使用者,它是内部class,供TcpServer使用,生命期由后者控制。
    具体实现:
        Acceptor的socket是listening socket,即server socket,负责倾听有没有客户端连接。Channel用于观察此socket上的readable事件,一旦有事件发生,它就负责回调Acceptor::handleRead(),后者会调用accept来接受新连接,并回调用户callback。这样做的好处就是可以把我要做的事情封装起来,只需要在Loop循环里关注我的listenAddr就可以了
 
8.5 TcpServer接受新连接 P303
    
    TcpServer的功能是管理accpet获得的TcpConnection。Tcpserver是供用户直接使用的,生命期由用户控制,用户只需设置好callback,再调用start()即可。
 
8.5.2 TcpConnection Class
    TcpConnection表示的是“一次TCP连接”,它是不可再生的,一旦连接断开,这个TcpConnection就没用了。另外TcpConnection没有发起连接的功能,其构造函数的参数是已经建立好连接的socket fd(无论是TcpServer被动接受还是TcpClient主动发起),因此其初始状态是kConnecting
 
8.6 TcpConnection断开连接
    muduo只有一种关闭连接的方式:被动关闭。即对方先关闭连接,本地read返回0,触发关闭逻辑。
    TcpConection的改动
        TcpConnection class也新增了CloseCallback的事件回调,但是这个回调是给TcpServer和TcpClient用的,用于通知他们移除所持有的TcpConnectionPtr,这不是给普通用户用的,只能内部使用,普通用户只能使用ConnectionCallback
        另外,TcpConnection里面的connectEstableished()是当TcpServer接受了一个新连接,初始化TcpConnection用的
        和connectDestroyed(),当TcpServer要把它从连接map里面移除时,调用此函数析构TcpConnection
        他们都只能使用一次
 
        如何使TcpConnection能自动断开连接?只需要在handleRead()里面,读数据时如果读完了,就自动调用handleclose()函数,此回调绑定到TcpServer::removeConnection,这使得此连接在完成任务后会断开。当然不读数据的情况下,TcpConnection::connectionDestroyed()函数也可以使对象在析构前可以断开连接
 
    TcpServer的改动
        TcpServer向TcpConnection注册CloseCallback,用于接收连接断开的消息,具体的函数是TcpServer::removeConnection(),它调用connectDestroyed()来断开连接
 
    EventLoop和Poller的改动
        因为TcpConnection不再是只生不灭,因此eventloop新增了removeChannel()成员函数,来删除注册的channel,对应也要在epoll检测的fd里删除对应的fd
        
8.7 Buffer读取数据
    Buffer用于保证TCP网络编程的读写不阻塞。
    TcpConnection中添加inputBuffer_变量,并且用此Buffer来读取数据
 
    在实现中:
    1.使用了发散/聚合技术(scatter/gather IO),一部分缓冲区是在网络库,另一部份缓冲区是在栈上,这样的输入缓冲区足够大。通常一次readv调用就能取完全部数据,不必事先知道有多少数据可读而提前预留(reserve()) Buffer的capacity(),可以在extrabuf中取多出来的数据
    发散/聚合技术:
        scatter/gather方式是与block dma方式相对应的一种dma(直接内存存取)方式
        在dma传输数据的过程中,要求源物理地址和目标物理地址必须是连续的。但是有的存储器地址虽然他是连续的,但是在物理上却不一定是连续的,则dma传输要分多次完成。
        如果传输完一块物理连续的数据后发起一次中断,同时主机进行下一块物理连续的传输,则这种方式即为block dma方式
        scatter/gather方式则不同,它是用一个链表描述物理不连续的存储器,然后把链表首地址告诉dma master。dma master传输完一块物理连续的数据后,就不用再发中断了,而是根据链表传输下一块物理连续的数据,最后发起一次中断。显然scatter/gather方式比block dma方式效率高。
 
    2.Buffer::readFd之调用一次read,没有反复调用直到其返回EAGAIN。因为muduo采用level trigger,也就是说只要某个fd是可读的,他每次epoll_wait都会通知用户去读取该fd,直到读完为止,这样的话buffer即使只read一次也不会丢失数据。
    这样做的好处:1.对于追求低延迟的程序来说,这是高效的,因为读数据每次只需要read一次(如果采用edge trigger,那么至少需要read两次,直到读到EAGAIN). 2.这样做照顾了多个连接的公平性,不会因为某个连接上数据量过大而影响其他连接处理消息。
    当然如果再改进的话,当n = writable + sizeof(extrabuf),就再读一次
 
8.8 TcpConnection 发送数据
    enableWriting()表示本Channel的fd开始关注writeable事件,但是我们只在需要发送的时候才开启关注,否则会造成busy loop。
    
    shutdown()是因为如果网络库中还有内容需要发给对方的,要等这些内容发完才能断开连接。而且他会把实际工作放到shutdownInLoop()中来做,后者保证在IO线程调用,所以是线程安全的
    send()也是一样的,如果在非IO线程调用,它会把message复制一份,传给IO线程中的sendInLoop()来发送,保证线程安全
 
    发送数据的方法设计:
        sendInLoop()会先尝试直接发送数据,如果一次发送完毕就不会启用WriteCallback;如果只发送了部分数据,则把剩余的数据放入outputBuffer_,并开始关注writeable事件(enablewriting)(下次epoll可以write的时候继续write),以后在handlerWrite()中发送剩余的数据。如果当前outputBuffer_已经有待发送的数据,那么就不能先尝试发送了,因为这会造成数乱序
        当socket变得可写时,Channel会调用TcpConection::handleWrite(),这里我们继续发送outputBuffer_中的数据。一旦发送完毕,立刻停止观察writeable事件,避免busyloop。另外如果这时连接正在关闭,则调用shutdownInLoop(),继续执行关闭过程。
 
        这里sendInLoop和handlewrite都只调用了一次write而不会反复调用直至它返回EAGAIN,原因是第一次write没有能够发送完全部数据的话,第二次调用write几乎肯定会返回EAGAIN,因为要等下一波了。
    
 
8.9 完善TcpConnection
    8.9.1 SIGPIPE
        sigpipe的默认行为是终止进程。在网络编程中,如果对方断开连接而服务端继续写入的话,会造成服务进程意外退出,因为服务进程会收到对方终止进程的信号SIGPIPE。我们要想办法使得服务进程不会因为对方断开连接而退出。
        解决办法就是在程序开始的时候就忽略SIGPIPE。这样即使对方终止进程,服务端仅仅会因为发送数据不成功而断开连接,但是不会终止进程。
IgnoreSigPipe()
{
    ::signal(SIGPIPE,SIG_IGN);
}
 
8.9.2 TCP No Delay和TCP keepalive
    TcpNoDelay是禁用Nagle算法
    TCP keepalive是定期检查TCP连接是否还存在
 
    8.9.3 WriteCompleteCallback和HighWaterMarkCallback
    1.什么时候关注writeable事件:在需要发送数据的时候关注,否则造成busyloop,因为你问一个socketfd什么时候可写,只要网络库不阻塞,肯定什么时候都可写
    2.如果发送数据的速度高于对方接收数据的速度,会造成数据在本地内存中堆积(网络库也是存放在内存中)。
 
    解决办法:
        提供两个回调,“高水位回调HighWater”和“低水位回调WriteComplete”。
        低水位回调WriteComplete:如果发送缓冲区被清空,就调用它
        高水位回调HighWater:如果输出缓冲的长度超过用户指定的大小,就会触发回调
 
        为了防止服务器发来的数据撑爆客户端的输出缓冲区,一种做法是C在高水位停止读取来自S的数据,在低水位恢复读取S的数据
 
8.10 多线程TcpServer
    实现多线程TcpServer的关键步骤是在新建TcpConnection时从event loop pool里挑选一个loop给TcpConnection用。也就是说多线程TcpServer自己的Eventloop只用来接收新连接,而新连接TcpConnection会用其他EventLoop来执行IO。(单线程TcpServer的EventLoop是与TcpConnection共享的)
        
    TcpServer每次新建一个TcpConnection就会调用getNextLoop()来取得EventLoop,如果是单线程服务,每次返回的都是baseLoop_,即TcpServer自己用的那个loop。
        
    此时TcpServer除了有一个accptor之外还有一个线程池(多个TcpServer之间不共享)
    单线程:把TcpServer自用的loop_传给TcpConnection。
    多线程:从线程池里抽一个线程,作为TcpConnection的eventloop
 
    EventLoop可以根据需要在多个线程间调配负载 p326
 
8.11 Connector
    主动发起连接要处理的问题:
    1.错误处理
    2.重试
    把这些处理过程封装成Connector class
 
    实现的难点:
        1.socket是一次性的,一旦出错,就无法恢复,因此每次Connector重连的时候都要用新的socket文件描述符和channel对象。注意channel在施放时应当先释放socket文件描述符,避免泄漏
        2.EAGAIN表示本机暂时端口用完,要关闭socket再延期重试。“正在连接”的返回码是EINPROGRESS。另外即便出现socket可写,也要用getsockopt再次确认连接是否建立。
        3.为了避免使用EventLoop::runAfter()定时但是Connect在定时器到期之前析构,我们把定时器和Connector绑定在一起,即在connector的析构函数中注销定时器。
        4.处理自连接,断开连接再重试
 
    TimerQueue::cancel()
        用一个全局递增的序列号来区分Timer对象,TimerId同时保存Timer*和sequence_
 
 
8.12 TcpClient
    TcpClient代码与TcpServer有几分相似,都有newConnection和removeConnection,只不过每个TcpClient都只管一个TcpConnection。
    几个要点:
        1.TcpClient具备TcpConnection断开之后重新连接的功能,Connector也具有反复尝试连接的功能,无论服务端是启动慢了还是重启,客户端都可以保证连接
        2.连接断开后初次尝试重连的延迟应该有随机性,避免给服务器带来短期大负载
 
8.13 epoll
        把epoll封装为EPoller class。
        读到需要读写的事件时,就把对应的事件存放到activechannel里面

猜你喜欢

转载自www.cnblogs.com/eelzhblog/p/11060987.html