当前位置: 首页 > 科技观察

服务器处理连接的架构演进

时间:2023-03-19 16:33:18 科技观察

本文转载自微信公众号《编程杂技》,作者theanarkh。转载本文请联系编程杂技公众号。服务器是现代软件的一个非常重要的组成部分。服务器,顾名思义,就是提供服务的组件。既然提供服务,那肯定是人尽皆知的。不然大家怎么找到服务呢?就像我们要吃麦当劳,首先要知道他在哪里。所以服务器一个很重要的属性就是需要发布服务信息,服务信息包括提供的服务和服务地址。这样,每个人都可以在需要时知道在哪里可以找到服务。对应电脑,服务地址是ip+port,但是ip和port不好记,不利于使用,所以设计了DNS协议,让我们可以使用域名访问一个服务,而DNS服务会根据域名解析ip。解决了找到服务的问题,接下来的问题就是服务端如何高效的处理连接。本文描述了服务器处理连接的架构演变。一个基于tcp协议的服务器,基本流程如下(本文均为伪代码)。intsocketfd=socket();绑定(socketfd);听(socketfd);执行以上步骤后,服务器正式开始服务。我们来看看基于上述模型的各种处理方法。1单进程acceptwhile(1){intsocketForCommunication=accept(socketfd);handle(socketForCommunication);}以上是服务器处理连接的最简单模型。处理逻辑是服务端不断调用accept移除完成三次握手的连接,然后处理,如果没有连接则服务端阻塞。让我们看看这种模式是如何工作的。假设有n个请求到来。然后是套接字结构。这个时候进程从accept中被唤醒。然后获取一个新的套接字进行通信。结构变成了很多同学都知道三次握手是什么,但是可能很少同学会深入思考或者看他的实现。众所周知,一个服务器在启动的时候,会监听一个端口,这个端口其实就是一个新的socket。那么如果有连接过来,我们就可以通过accept获取新连接对应的socket。那么这个socket和listeningsocket是一样的吗?实际上,套接字分为监听型和通信型。服务器表面上看是用一个端口实现多连接,但这个端口是用来监听的,底层其实是另外一个socket用来和客户端通信的。所以每有一个连接过来,负责监听的socket发现是一个建立连接的包(synpacket),就会生成一个新的socket与之通信(accept时返回的那个)。监听套接字只保存它监听的ip和端口。通信套接字先从监听套接字中复制ip和端口,然后记录客户端的ip和端口。下次收到数据包时,操作系统会根据四元组从套接字池中找到套接字,从而完成数据处理。言归正传,如果串行模式在处理过程中调用了阻塞API,比如fileio,会影响后续请求的处理。可想而知效率有多低。而当并发量比较大的时候,监听socket对应的队列很快就会满(完成的连接队列有最大长度)。这是最简单的模式。虽然在服务端的设计中肯定不会用到这种模式,但是可以让我们了解一个服务端处理请求的整体过程。2多进程模式串行模式下,所有的请求都在一个进程中排队处理,这是效率低下的原因。这时候我们可以将请求分发到多个进程来提高效率,因为在串行处理模式下,如果有文件io操作,就会阻塞主进程,从而阻塞后续请求的处理。在多进程模式中,即使一个请求阻塞了进程,操作系统也会挂起该进程,然后调度其他进程执行,让其他进程执行新的任务。有几种类型的多进程模式。2.1主进程接受,子进程处理请求。在这种模式下,主进程负责提取完成连接的节点,然后将这个节点对应的请求交给子进程处理。逻辑如下。while(1){varsocketForCommunication=accept(socket);if(fork()>0){//忽略错误处理continue;//父进程负责accept}else{//子进程handle(socketForCommunication);exit();}}在这种模式下,每有一个请求到来,都会创建一个新的进程来处理它。这种模式比串行模式略好。每个请求都是独立处理的。假设a请求阻塞在文件io中,不会影响b请求的处理,尽可能做到并发。他的瓶颈是系统中的进程数是有限的。如果有大量请求,系统将无法处理。此外,该过程的开销非常高。对系统来说是一个沉重的负担。2.2subprocessaccept这种模式不等到request来了才创建process。相反,当服务器启动时,会创建多个进程。然后多个进程分别调用accept。该模型的架构如下。for(leti=0;i0){//父进程负责监听子进程}else{//子进程处理请求while(1){varsocketForCommunication=accept(socket);handle(socketForCommunication);}}}这种模式下,多个子进程阻塞在accept中。如果此时有请求到达,所有的子进程都会被唤醒,但是最先被调度的子进程会先摘下请求节点。后续进程唤醒后,可能会遇到没有请求处理。又进入休眠状态,进程被无效唤醒。这就是著名的骇人听闻的群体现象。架构如下。改进方法是在accpet之前加一把锁,拿到锁的进程就可以accept了。这样就保证了只有一个进程会阻塞accept,nginx解决了这个问题。但是新版操作系统已经在内核层面解决了这个问题。一次只有一个进程会被唤醒。2.3进程池模式进程池模式是在服务器启动时预先创建一定数量的进程,但这些进程都是工作进程。他不负责接受请求。他只负责处理请求。主进程负责accept,他将accept返回的socket传递给worker进程处理。模式如下该模式的逻辑如下letfds=[[],[],[]...进程数];letprocess=[];for(leti=0;i<进程数;i++){//创建管道5.socketpair(fds[i]);letpid;if(pid=fork()>0){//父进程process.push({pid,otherfields});}else{letindex=i;//子进程处理请求while(1){//从管道中读取文件描述符varsocket=read(fd[index][1]);//处理请求handle(socket);}}}for(;;){varnewSocket=accept(socket);//找出处理请求的子进程leti=findProcess();//传递文件描述符write(fds[i][0],newSocket);}使用进程池在多进程模式下,主进程负责接受请求,然后将请求交给子进程。但是相对于多进程模式2.1,进程池模式相对复杂一些,因为在多进程模式2.1中,当主进程收到请求时,实时fork一个子进程。这时子进程会在主进程中继承新请求对应的fd,因此可以直接处理fd对应的请求。在进程池模式下,子进程是预先创建的。当主进程接收到请求时,子进程无法获取请求对应的fd。这时候主进程就需要使用传递文件描述符的技术,将本次请求对应的fd传递给子进程。一个进程其实就是一个结构体task_struct,它有一个字段用来记录打开的文件描述符。当我们访问一个文件描述符时,操作系统会根据fd的值从task_struct中找到fd对应的底层资源。所以当主进程将文件描述符传递给子进程时,它传递的不仅仅是一个数字fd,因为如果只这样做的话,这个fd可能不对应子进程中的任何资源,或者对应的资源是同主进程中的不一致。在传递文件描述符的同时,操作系统帮我们处理了很多事情,让我们可以在子进程中通过fd访问到正确的资源,也就是在主进程中接收到的请求。3多线程模式多线程模式与多进程模式类似,也分为以下几种:1.主进程接受,创建子线程来处理2.子线程接受3.线程池前两种和多进程模式下一样,但是第三种比较特殊,我们主要介绍第三种。在子进程模式下,每个子进程都有自己的task_struct,也就是说fork之后,每个进程负责维护自己的数据,而线程则不同。线程共享主线程(主进程)的数据,当主进程从accept中拿到一个fd,如果传递给线程,线程可以直接操作。所以在线程池模式下,架构如下。主进程负责接受请求,然后通过互斥的方式向共享队列中插入一个任务。线程池中的子线程也通过互斥的方式从共享队列中抽取节点进行处理。4事件驱动现在很多服务器(nginx、Nodejs、redis)都是采用事件驱动的方式设计的。我们从前面的设计模式中知道,为了应对大量的请求,服务器需要大量的进程/线程。这是一个非常大的开销。事件驱动模式一般配合单进程(单线程),无论处理多少请求,也是在一个进程中处理。但是因为是单进程,所以不适合CPU密集型,因为如果一个任务一直在占用CPU,后面的任务就无法执行了。他比较适合io-intensive(一般会提供一个线程池来处理cpu或者阻塞任务)。大多数操作系统都提供事件驱动的API。但是事件驱动在不同的系统中实现方式不同。所以通常有一个抽象层来消除这种差异。这里以linux的epoll为例。//创建一个epollvarepollFD=epoll_create();/*向epoll中的一个文件描述符注册一个感兴趣的事件,这里是监听socket,注册一个可读事件,即connecttoevent={event:readablefd:listensocket//一些上下文}*/epoll_ctl(epollFD,EPOLL_CTL_ADD,socket,event);while(1){//阻塞等待事件就绪,events保存就绪事件的信息,total是个数vartotal=epoll_wait(epollFD,保存就绪事件的结构,事件个数,暂停);for(leti=0;i