网络系统编程(I/O多路复用、网络模式、线程池)

前言

  本文主要讲述项目中网络编程常使用的一些技术点和网络模式,包括几种I/O多路复用的技术,不同种类的线程池,两个高性能网络模式等。

I/O多路复用技术

  常用的五种I/O模型分别是阻塞I/O(调用了某个函数就一直等待这个函数的返回,期间什么也不做),非阻塞I/O(调用某一个函数,如果没有返回CPU就去运行其他程序,但是每隔一段时间就会监测I/O事件是否就绪),信号驱动I/O(Linux用套接口进行信号驱动I/O,完成一个信号处理函数,进程持续运行不会阻塞,当I/O时间就绪了,进程就会收到SIGIO信号,然后处理IO事件),I/O复用(通过select,poll,epoll来实现,也会阻塞进程但是可以同时阻塞多个IO操作,可以同时对多个读写IO函数进行监测,有多个数据可读可写时才会真正调用IO操作),还有异步I/O(调用aio_read函数告诉内核描述字缓冲区指针和缓冲区大小,文件偏移和通知方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序)前四种都是同步I/O,同步I/O中内核向应用程序通知的是就绪事件,比如只通知了客户端连接,要求用户自行执行I/O操作,而异步I/O是指内核向应用程序通知的是完成事件,比如读取客户端的数据后才通知应用程序,整个过程都是由内核完成的。而并发模式中的同步和异步:同步指程序完全按照代码序列顺序执行,异步指程序的执行需要由系统事件驱动。
  Socket:网络编程中常使用socket跨主机编程,在创建socket的时候可以指定网络层使用的是IPV4或是IPV6,传输层使用的是TCP还是UDP。一般使用TCP连接中会有两个socket分别是用于监听的socket和一个已连接socket。socket的使用流程:服务端首先调用 socket() 函数,创建网络协议为 IPv4/IPv6,以及传输协议为 TCP/UDP 的 Socket (下面都以TCP连接为例),接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口绑定端口是因为内核收到TCP报文后,通过TCP报文头里的端口号找到应用程序,传递数据;绑定IP地址是因为一个机器有很多个网卡,每个网卡都有对应的IP地址。绑定完IP地址和端口后,就调用listen进行监听,之后通过accept函数从内核获取客户端的连接;而客户端是在创建好Socket之后调用connect()函数发起连接,这个函数要指明服务端的IP地址和端口号,接着就会进行TCP的三次握手,因为TCP有两个连接队列分别是TCP半连接队列(还没完全建立的连接的队列)里面放的都是没有完成握手的连接,这时服务端处于sym_rcvd状态,另一个队列是TCP全连接队列(已经建立连接的队列),这里都是完成三次握手的连接,所以服务端会处于established状态,服务端的accept会从TCP全连接队列中拿出一个已经连接完成的Socket返回应用程序,后续的数据传输都是用这个socket,所以监听的socket是在内核中,用来连接的socket是在应用层。
  在多进程模型中,当服务器的主进程连接完成后,accept()函数会返回一个已连接的socket,这就通过fork函数创建一个子进程,会将父进程的相关东西都复制一份,包括文件描述符,内存地址空间,程序计数器,执行代码等。会根据返回值来区分是父进程还是子进程(子进程返回值是0,父进程返回值是整数);子进程只会关注已连接socket,父进程只关心监听socket;当子进程退出时,内核还会保留进程信息,如果不回收就会成为僵尸进程,所以要使用wait或者waitpid两种方式来回收子进程退出后的资源。除此之外还有多线程模型,会使用线程池来避免线程的频繁创建和销毁,线程池就是提前创立若干个线程,运行时将已连接的socket放入队列,然后线程池中的线程再不断从队列中取出已连接socket进行处理。但是通过一个线程维护一个socket的方式还是不合理的因为如果访问量过大还是会崩溃。所以就有了I/O多路复用技术,用一个进程来维护多个Socket,常用的有select/poll/epoll内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。处理网络事件时是通过将所有的文件描述符传给内核,再由内核返回产生了事件的连接,然后用户态再响应这些请求。
  select/poll:实现方式是将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。所以select会遍历两次文件描述符集合,并且会拷贝两次文件描述符集合。select使用的是BitsMap来表示文件描述符的集合,但是由于内核中的限制,长度最大值是1024所以只能监听1023个文件描述符,而poll使用的是动态数组,用链表的形式来存储文件描述符,突破了select的文件描述符的限制,但是poll和select都是使用线性结构存储进程中socket集合,所以都要遍历socket也需要在内核态和用户态之间拷贝文件描述符集合。
  epoll:epoll的使用,使用流程如下所示:

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s,...);
listen(s,...);
cfd = accept(...);//accept之后会创建出一个新的socket专门用来和对应的客户端通信
int epfd = epoll_creat();
epoll_ctl(epfd,...);
while(1){
	int n = epoll_wait(...);
	for(接收到数据的socket)
		数据处理
}

  先通过epoll_create创建一个epoll对象,再通过epoll_ctl将要监视的socket添加到之前创建的对象中,最后通过epoll_wait等待数据。epoll主要是通过两个方面解决了select/poll中的问题:1、在内核中使用红黑树来跟踪进程所有待检测的文件描述符,通过epoll_ctl将需要监测的socket加入到内核中的红黑树里,使用红黑树增删查改的时间复杂度是o(logn),所以对于每个待检测的socket只用传入本身就行,无需传入整个socket集合,减少了内核和用户空间的数据拷贝和内存分配。2、epoll使用的是事件驱动的机制,内核维护了一个链表来记录就绪事件,当一个socket有事件发生时通过回调函数内核会将其加入到这个就绪事件列表中,用户调用epoll_wait时只会返回有事件发生的文件描述符个数,不用扫描整个socket集合,提高了监测效率。(epoll_wait返回时不是用共享内存的方式返回的,这个说法是错误的)
  epoll支持两种触发方式边缘触发(ET)和水平触发(LT),使用边缘触发时当被检测的socket描述符上有可读事件发生时,服务器端只会从epoll_wait中触发一次,即使进程没有调用read从内核读取数据,也会触发一次,所以要保证一次性将内核缓冲区的数据读取完(所以一般会使用循环从文件描述符读写数据,所以不能和阻塞I/O共同使用,边缘触发模式一般和非阻塞I/O搭配使用,使用边缘触发次数更多,因为可以减少使用epoll_wait的系统调用次数,这会因为上下文的切换增加系统调用开销);使用水平触发时,当socket描述符上有可读的事件发生时,服务器不断地从epoll_wait中触发,直到内核缓冲区数据被read函数读完才结束,这个目的是为了告诉我们有数据需要读取。select和poll只有水平触发方式。注:I/O多路复用技术最好和非阻塞I/O一起使用,因为多路复用API返回的事件不一定是可读写的,这样如果使用阻塞I/O就会使得程序发生阻塞。

高性能网络模式:Reactor和Proactor

  为了实现I/O多路复用技术,如果使用面向过程的方式会很复杂,所有有人提出了面向对象的思想,对I/O多路复用做了一层封装,让使用者只用关注应用代码就行,这种面向对象的模式就叫Reactor模式:直接翻译叫反应堆模式,这是一种非阻塞同步网络模式,指的是对应事件,就是有事件过来Reacror做出反应,所以是I/O多路复用的监听事件,收到事件后,根据事件类型分配某个进程/线程。Reactor模式主要由Reactor和处理资源池两个核心部分组成:Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;处理资源池负责处理事件,如 read -> 业务逻辑 -> send;Reactor模式优点是灵活多变的Reactor可以有一个也可以有几个,处理资源池可以是单进程也可以是多进程。常用的经典模式有三种分别是:单 Reactor 单进程 / 线程;单 Reactor 多线程 / 进程;多 Reactor 多进程 / 线程。
  单 Reactor 单进程 / 线程:一般会有Reactor,Acceptor,Handler三种对象,Reactor负责对象的作用是监听和分发时间,Accept对象负责的是获取连接,Handler负责的是处理业务。
  整体流程是:Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
  这种方案的缺点就是无法充分利用多核CPU性能,第二个是如果Handler对象在处理业务,整个进程是没有办法处理其他连接事件的,如果业务耗时较长,就会造成响应延迟,所以这种方案不适用于计算机密集型的场景,只适用于业务处理非常快速的场景。(比如Redis6.0之前的版本)。
  单 Reactor 多线程 / 多进程:工作流程首先和单Reactor单进程一样,但是区别在于Handler对象不负责业务处理,只负责数据的接收和发送,通过read读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;这种模式的优势就是能够充分利用多核CPU的能力,缺点就是在数据发送的时候会有数据竞争,需要加上互斥锁,并且因为Reactor对象承担了所有事件和监听和响应,而且只在主线程中运行,所以高并发下容易成为瓶颈。
  多 Reactor 多进程 / 线程:主要流程,主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
  这种模式是分工鲜明的,主线程只负责接收新连接,子线程完成后续的业务处理,主线程只用把新的连接传给子线程,子线程直接就将处理结果发送给客户端。
  而Proactor是一个异步网络模型,(Reactor是一个非阻塞同步网络)阻塞I/O等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。非阻塞I/O 下read请求在数据为准备好的情况下可以立即返回,并且应用程序会不断轮询内核,直到数据准备好,上述两个过程在调用时需要内核将数据从内核空间拷贝到用户空间,所以是同步的,而异步I/O是指内核数据准备好和数据从内核态拷贝到用户态都不需要等待
  Reactor是非阻塞同步网络模式,感知的是就绪的可读写事件,这个过程是同步的,需要读取完数据后应用进程才能处理数据;Proactor是异步网络模式,感知的是已读完成的读写事件,在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,操作系统完成读写工作后,就会通知应用进程直接处理数据。
  Proactor工作的流程:Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;Handler 完成业务处理;
但是在Linux中异步I/O是不完善的,因为aio系列的函数是由POSIX定义的异步操作接口,不是真正的操作系统支持的只是在用户空间模拟的。

线程池

  如果线程过多会带来过度的开销,进而影响缓存局部性和整体性能,使用线程池就可以等待监督管理者分配可并发执行的任务,避免了在处理短时间任务时创建和销毁线程的代价,线程池不仅可以保证内核的充分利用,还可以防止过分调度,一般线程池中可用线程的数量取决于并发处理器、处理器内核、内存、网络sockets等的数量,如果线程过多会造成额外的线程切换开销,对于计算密集型任务,线程数一般取cpu数量或者稍大(+1)比较合适,对于IO密集型的任务,一般要多于CPU的核数,因为线程间竞争的不是CPU的计算资源而是IO,IO的处理一般较慢,多于内核数的线程将为CPU争取更多的任务,不至在线程处理IO的过程造成CPU空闲导致资源浪费,公式:最佳线程数 = CPU当前可使用的内核数 * 当前CPU的利用率 * (1 + CPU等待时间 / CPU处理时间)。
  线程池中一般要具备几个功能分别是提交任务(比如Reactor模式中的Reactor对象),任务缓存(任务队列),任务调度(常见的是任务队列),任务执行(线程)。
  线程池模式一般分为两种分别是:HS/HA半同步/半异步模式、L/F领导者与跟随者模式
  半同步/半异步模式,又称为生产者消费者模式,是比较常见的实现方式,比较简单。分为同步层、队列层、异步层三层。同步层的主线程处理客户逻辑,位于异步层的异步线程用于处理I/O事件,当异步线程监听到客户请求后,就会将其封装成请求对象插入到请求队列中去,请求队列中有值时,处在同步模式下的工作线程会来读取并处理该请求对象。由于线程间有数据通信,因此不适于大数据量交换的场合。当使用的网络模式为Proactor模式时会是一种新的变体叫做半同步/半反应堆(社长的web服务器开发项目中运用这个模式):主线程充当异步线程,负责监听所有socket上的事件,若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件,如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中,所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权。
  领导者跟随者模式,在线程池中的线程可处在3种状态之一:领导者leader、追随者follower或工作者processor。任何时刻线程池只有一个领导者线程。事件到达时,领导者线程负责消息分离,并从处于追随者线程中选出一个来当继任领导者,然后将自身设置为工作者状态去处置该事件。处理完毕后工作者线程将自身的状态置为追随者。这一模式实现复杂,但避免了线程间交换任务数据,提高了CPU cache相似性。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值