前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >服务器处理连接的架构演变

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

作者头像
theanarkh
发布2021-05-28 17:26:06
9270
发布2021-05-28 17:26:06
举报
文章被收录于专栏:原创分享

服务器是现代软件中非常重要的一个组成。服务器,顾名思义,是提供服务的组件,那么既然提供服务,那就要为众人所知,不然大家怎么能找到服务呢?就像我们想去吃麦当劳一样,那我们首先得知道他在哪里。所以,服务器很重要的一个属性就是需要发布服务信息,服务信息包括提供的服务和服务地址。这样大家才能知道需要什么服务的时候,去哪里找。对应到计算机中,服务地址就是ip+端口,但是ip和端口不容易记,不利于使用,所以又设计出DNS协议,这样我们就可以使用域名来访问一个服务,DNS服务会根据域名解析出ip。解决了寻找服务的问题后,接下来的问题就是服务器如何高效地处理连接。本文介绍服务器处理连接的架构演进。

一个基于tcp协议的服务器,基本的流程如下(本文皆为伪代码)。

代码语言:javascript
复制
int socketfd = socket();bind(socketfd);listen(socketfd);

执行完以上步骤,一个服务器正式开始服务。下面我们看一下基于上面的模型,分析各种各样的处理方法。

1 单进程accept

代码语言:javascript
复制
while(1) {
  int socketForCommunication  = accept(socketfd);
  handle(socketForCommunication);}

上面是一个服务器处理连接最朴素的模型,处理逻辑就是服务器不断地调用accept摘下完成三次握手的连接,然后处理,如果没有连接则服务器阻塞。我们看看这种模式的处理过程。假设有n个请求到来。那么socket的结构是。

这时候进程从accept中被唤醒。然后拿到一个新的socket用于通信。结构变成

很多同学都了解三次握手是什么,但是可能很少同学会深入思考或者看他的实现。众所周知,一个服务器启动的时候,会监听一个端口,其实就是新建了一个socket。那么如果有一个连接到来的时候,我们通过accept就能拿到这个新连接对应的socket。那么这个socket和监听的socket是不是同一个呢?其实socket分为监听型和通信型的。表面上,服务器用一个端口实现了多个连接,但是这个端口是用于监听的,底层用于和客户端通信的其实是另一个socket。所以每一个连接过来,负责监听的socket发现是一个建立连接的包(syn包),他就会生成一个新的socket与之通信(accept的时候返回的那个)。监听socket里只保存了他监听的ip和端口,通信socket首先从监听socket中复制ip和端口,然后把客户端的ip和端口也记录下来,当下次收到一个数据包的时候,操作系统就会根据四元组从socket池子里找到该socket,从而完成数据的处理。

言归正传,串行这种模式如果处理的过程中有调用了阻塞api,比如文件io,就会影响后面请求的处理。可想而知,效率是有多低。而且并发量比较大的时候,监听socket对应的队列很快就会被占满(已完成连接队列有一个最大长度)。这是最简单的模式,虽然服务器的设计中肯定不会使用这种模式,但是他让我们了解了一个服务器处理请求的整体过程。

2 多进程模式

串行模式中,所有请求都在一个进程中排队被处理,这是效率低下的原因。这时候我们可以把请求分给多个进程处理来提供效率,因为在串行处理的模式中,如果有文件io操作,他就会阻塞主进程,从而阻塞后续请求的处理,在多进程的模式中,即使一个请求阻塞了进程,那么操作系统会挂起该进程,接着调度其他进程执行,那么其他进程就可以执行新的任务。多进程模式下分为几种。

2.1 主进程accept,子进程处理请求

这种模式下,主进程负责摘取已完成连接的节点,然后把这个节点对应的请求交给子进程处理,逻辑如下。

代码语言:javascript
复制
1.  while(1) {  
2.      var socketForCommunication = accept(socket);  3.      if (fork() > 0) { // 忽略出错处理
4.           continue;5.           // 父进程负责accept6.      } else {  7.          // 子进程8.          handle(socketForCommunication); 
9.        exit(); 
10.     }  
11. }

这种模式下,每次来一个请求,就会新建一个进程去处理。这种模式比串行的稍微好了一点,每个请求独立处理,假设a请求阻塞在文件io,那么不会影响b请求的处理,尽可能地做到了并发。他的瓶颈就是系统的进程数有限,如果有大量的请求,系统无法扛得住。再者,进程的开销很大。对于系统来说是一个沉重的负担。

2.2 子进程accept

这种模式不是等到请求来的时候再创建进程。而是在服务器启动的时候,就会创建多个进程。然后多个进程分别调用accept。这种模式的架构如下。

代码语言:javascript
复制
1.  for (let i = 0 ; i < 进程个数; i++) {  
2.      if (fork() > 0) {  
3.          // 父进程负责监控子进程4.      } else {  
5.          // 子进程处理请求6.          while(1) {  
7.              var socketForCommunication = accept(socket);  
8.              handle(socketForCommunication);  
9.          }  
10.     }  
11. }

这种模式下多个子进程都阻塞在accept。如果这时候有一个请求到来,那么所有的子进程都会被唤醒,但是首先被调度的子进程会首先摘下这个请求节点。后续的进程被唤醒后可能会遇到已经没有请求可以处理。又进入睡眠,进程被无效唤醒,这是著名的惊群现象。架构如下。

改进方式就是在accpet之前加锁,拿到锁的进程才能进行accept。这样就保证了只有一个进程会阻塞在accept,nginx解决了这个问题。但是新版操作系统已经在内核层面解决了这个问题。每次只会唤醒一个进程。

2.3 进程池模式

进程池模式就是服务器启动的时候,预先创建一定数量的进程,但是这些进程是worker进程。他不负责accept请求。他只负责处理请求。主进程负责accept,他把accept返回的socket交给worker进程处理。模式如下

这种模式的逻辑如下

代码语言:javascript
复制
1.  let fds = [[], [], []…进程个数];  
2.  let process = [];  
3.  for (let i = 0 ; i < 进程个数; i++) {  
4.      // 创建管道用于传递文件描述符  5.      socketpair(fds[i]);  
6.      let pid;  
7.      if (pid = fork() > 0) {  
8.          // 父进程9.          process.push({pid, 其他字段});  
10.     } else {  
11.         let index = i;  
12.         // 子进程处理请求13.         while(1) {  
14.             // 从管道中读取文件描述符15.             var socket = read(fd[index][1]);  
16.             // 处理请求17.             handle(socket);  
18.         }  
19.     }  
20. }  
22. for (;;) {  
23.     var newSocket = accept(socket);  
24.     // 找出处理该请求的子进程25.     let i = findProcess();  
26.     // 传递文件描述符27.     write(fds[i][0], newSocket);  
28. }

使用进程池的模式时,主进程负责accept,然后把请求交给子进程处理,但是和多进程的模式2.1相比,进程池模式相对比较复杂,因为在多进程模式2.1中,当主进程收到一个请求的时候,实时fork一个子进程,这时候,这个子进程会继承主进程中新请求对应的fd,所以他可以直接处理该fd对应的请求,在进程池的模式中,子进程是预先创建的,当主进程收到一个请求的时候,子进程中是无法拿得到该请求对应的fd的。这时候,需要主进程使用传递文件描述符的技术把这个请求对应的fd传给子进程。一个进程其实就是一个结构体task_struct,他有一个字段记录了打开的文件描述符,当我们访问一个文件描述符的时候,操作系统就会根据fd的值,从task_struct中找到fd对应的底层资源,所以主进程给子进程传递文件描述符的时候,传递的不仅仅是一个数字fd,因为如果仅仅这样做,在子进程中该fd可能没有对应任何资源,或者对应的资源和主进程中的是不一致的。而传递文件描述符,操作系统帮我们处理了很多事情,让我们在子进程中可以通过fd访问到正确的资源,即主进程中收到的请求。

3 多线程模式

多线程模式和多进程模式是类似的,也是分为下面几种

1 主进程accept,创建子线程处理

2 子线程accept

3 线程池

前面两种和多进程模式中是一样的,但是第三种比较特别,我们主要介绍第三种。在子进程模式时,每个子进程都有自己的task_struct,这就意味着在fork之后,每个进程负责维护自己的数据,而线程则不一样,线程是共享主线程(主进程)的数据的,当主进程从accept中拿到一个fd的时候,传给线程的话,线程是可以直接操作的。所以在线程池模式时,架构如下。

主进程负责accept请求,然后通过互斥的方式插入一个任务到共享队列中,线程池中的子线程同样是通过互斥的方式,从共享队列中摘取节点进行处理。

4 事件驱动

现在很多服务器(nginx,Nodejs,redis)都开始使用事件驱动模式去设计。从之前的设计模式中我们知道,为了应对大量的请求,服务器需要大量的进程/线程。这个是个非常大的开销。而事件驱动模式,一般是配合单进程(单线程),再多的请求,也是在一个进程里处理的。但是因为是单进程,所以不适合cpu密集型,因为一个任务一直在占据cpu的话,后续的任务就无法执行了。他更适合io密集的(一般都会提供一个线程池,负责处理cpu或者阻塞型的任务)。大部分操作系统都提供了事件驱动的api。但是事件驱动在不同系统中实现不一样。所以一般都会有一层抽象层抹平这个差异。这里以linux的epoll为例子。

代码语言:javascript
复制
1.  // 创建一个epoll  2.  var epollFD = epoll_create();  
3.  /* 
4.   在epoll给某个文件描述符注册感兴趣的事件,这里是监听的socket,注册可读事件,即连接到来 
5.   event = { 
6.      event: 可读 
7.      fd:监听socket 
8.      // 一些上下文 
9.   } 
10. */  
11. epoll_ctl(epollFD , EPOLL_CTL_ADD , socket, event);  
12. while(1) {  
13.     // 阻塞等待事件就绪,events保存就绪事件的信息,total是个数14.     var total= epoll_wait(epollFD , 保存就绪事件的结构events, 事件个数, timeout);  
15.     for (let i = 0; i < total; i++) {  
16.         if (events[i].fd === 监听socket) {  
17.             var newSocket = accpet(socket);  
18.             // 把新的socket也注册到epoll,等待可读,即可读取客户端数据19.             epoll_ctl(epollFD , EPOLL_CTL_ADD , newSocket, 可读事件);  
20.         } else {  
21.             // 从events[i]中拿到一些上下文,执行相应的回调22.         }  
23.     }  
24. }

这就是事件驱动模式的大致过程。本质上是一个订阅/发布模式。服务器通过注册文件描述符和事件到epoll中。epoll开始阻塞,等到epoll返回的时候,他会告诉服务器哪些fd的哪些事件触发了。这时候服务器遍历就绪事件,然后执行对应的回调,在回调里可以再次注册新的事件。就是这样不断驱动着。epoll的原理其实也类似事件驱动。epoll底层维护用户注册的事件和文件描述符。epoll本身也会在文件描述符对应的文件/socket/管道处注册一个回调。然后自身进入阻塞。等到别人通知epoll有事件发生的时候,epoll就会把fd和事件返回给用户。

代码语言:javascript
复制
1.  function epoll_wait() {  
2.      for 事件个数
3.          // 调用文件系统的函数判断4.          if (事件[i]中对应的文件描述符中有某个用户感兴趣的事件发生?) {  
5.              插入就绪事件队列  
6.          } else {  
7.              /*
8.               在事件[i]中的文件描述符所对应的文件/socket/管道等indeo节点注册回调。
9.               即感兴趣的事件触发后回调epoll,回调epoll后,epoll把该event[i]插入
10.              就绪事件队列返回给用户
11.              */12.         }  
13. }

现在的服务器的设计中还会涉及到协程。不过目前自己还没有看过具体的实现,所以还无法介绍(想了解原理的话可以看libtask这个协程库)。

5 reuseport端口复用

前面介绍的几种模式中,在处理连接的方案上,大致有下面几种

1 单进程串行处理

2 主进程接收连接,分发给子进程处理。

3 子进程接收请求,有惊群现象。

从串行处理到多进程/多线程模式,在处理连接上有了很大的改进,但是依然存在一些问题,2中的问题是,虽然有多个子进程处理请求,但是只有一个进程接收请求,这是远远不够的。3中的问题是,多个子进程可以同时accept,首先会导致惊群问题,其次,被唤醒处理连接的进程应该处理多少个连接也是一个问题,比如有10个连接,进程1被唤醒后是全部处理还是只处理一个,把剩下的留给其他进程处理呢?即使新版的内核已经解决了惊群问题,但是被唤醒的进程应该处理多少个连接的问题依然存在,所以如何接收请求和分发请求是两个可以改进的地方,新版linux支持reuseport特性后,使得处理请求的模式有了很大的改善。reuseport之前,一个socket是无法绑定到同一个地址的,通常的做法是主进程bind后,fork子进程,然后子进程listen。但是共享的是同一个socket。reuseport特性支持多个socket绑定到同一个地址,当连接到来时,操作系统会根据地址信息找到一组socket,然后根据策略选择一个socket,然后唤醒阻塞在该socket的进程。这样之前多进程共享socket的模式下,被唤醒的进程应该处理多少个请求的问题也解决了,因为reuseport模式中,每个进程一个socket,对应一个请求队列,内核会把请求分发到各个socket中,被socket唤醒的进程只处理自己的监听socket下的连接就行,架构如下

这种模式在底层解决了多进程请求分发的问题,提高了处理请求的效率同时实现了负载均衡。

以上是服务器处理请求的架构演变,服务器作为对性能要求极高的软件,在技术演变的过程中,不仅应用层做了很多改进,操作系统内核层面也做了很多改进。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-05-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 编程杂技 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 单进程accept
  • 2 多进程模式
  • 2.1 主进程accept,子进程处理请求
  • 2.2 子进程accept
  • 2.3 进程池模式
  • 3 多线程模式
  • 4 事件驱动
相关产品与服务
云服务器
云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档