1. 服务器框架详解
1.1 服务器模型
1.1.1 C/S 模型
此模型很简单,就是服务器和客户端。
此模型 非常适合资源相对集中的场合。
缺点:
因为服务器是通信的中心,当访问量过大时,可能所有的客户都将得到很慢的响应。
此缺点可由 P2P 模型解决。
C/S 服务器普通实现如下图所示:
1.1.2 P2P 模型
概念:
P2P(Peer to Peer,点对点)模型比 C/S 更符合网络通信实际情况。
它 摒弃了 C/S 模型那样以服务器为中心的格局,让网络上所有主机重新回归对等地位。
P2P 使得每台机器在消耗服务的同时也给别人提供服务,这样资源能够充分共享。
缺点:
当用户之间传输的请求过多时,网络的负载将加重。
a 模型存在一个问题:
主机之间很难相互发现。
实际使用的 P2P 模型通常带有一个专门发现服务器,如 b模型。
b模型发现服务器:
通常提供查找服务(甚至还提供内容服务),使每个客户都能尽快找到自己需要的资源。
2.服务器程序解构
-
概念
服务器种类繁多,但基本框架一样,不同之处在于逻辑处理。
按照服务器程序一般原理,将服务器框架分为如下三个模块及功能描述(如下图 8-4):
-
三个模块详解
-
I/O处理单元
I/O 处理单元是服务器管理客户连接的模块。 功能: 1.等待并接收新的客户连接 2.接收客户数据 3.将服务器响应数据返回给客户端。 扩展: 数据的收发不一定在 I/O 处理单元执行,也可能在逻辑单元中执行,具体何处执行取决于 事件处理模式。 对于一个服务器机群来讲,I/O处理单元是一个专门的接入服务器, 它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。 实际开发知识点(下面详解): 4 种I/O模型 和 2 种高效事件处理模式。
-
逻辑单元
一个逻辑单元通常是一个进程或线程。 功能: 分析并处理客户数据,然后将结果传递给 I/O 处理单元或直接发送给客户端(具体使用哪种方式取决于事件处理模式)。 扩展: 对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。 服务器通常拥有多个处理单元,以实现对多个客户任务的并行处理。 实际开发知识点(下面详解): 2 种高效并发模式,以及高效的逻辑处理方式——有限状态机。
-
网络存储单元
网络存储单元可以是数据库、缓存、文件,甚至是一台独立服务器。 网络存储单元不是必须存在的,如ssh、telnet等服务就不要这个单元。 实际开发知识点: 不涉及网络编程,不做详解。
-
请求队列
请求队列是各单元之间通信方式的抽象。 流程: I/O 处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。 同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竟态条件。 实现: 请求队列 通常被实现为 池的一部分。 扩展: 对服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的 TCP 连接。 这种 TCP 连接 能提高服务器之间交换数据的效率,因为它避免了动态建立 TCP 连接导致的额外的系统开销。 实际开发知识点(后面详解): 进程池、线程池、连接池。
-
3. I/O处理单元 详解
上面提到I/O处理单元 包含 5 种I/O模型
和 2 种高效事件处理模式
。分别如下:
5 种I/O模型: 阻塞I/O、非阻塞I/O、I/O复用、SIGIO信号、异步I/O
2 种高效事件处理模式: Reactor模式、Proactor模式。
3.1 详解Linux I/O 模型
参考链接
3.1.1 同步
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。
也就是`必须一件一件事做`,等前一件做完了才能做下一件事。
例:
如普通B/S模式(同步):
提交请求->等待服务器处理->处理完毕返回 这个期间客户端浏览器不能干任何事。
3.1.2 异步
异步的概念和同步相对。
当一个异步过程调用发出后,调用者不能立刻得到结果。
实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
例:
如 ajax请求(异步):
请求通过事件触发->服务器处理(这是浏览器仍然可以作其他事情)->处理完毕
3.1.3 阻塞
概念:
阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。
函数只有在得到结果之后才会返回。
扩展:
有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。
对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回,它还会抢占cpu去执行其他逻辑,也会主动检测io是否准备好。
3.1.4 非阻塞
概念:
非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
3.1.5 阻塞、非阻塞、同步、异步最主要区别
同步 | 就是我调用一个功能,该功能没有结束前,我死等结果 |
异步 | 就是我调用一个功能,不需要知道该功能结果,该功能有结果后通知我(回调通知) |
阻塞 | 就是调用我(函数),我(函数)没有接收完数据或者没有得到结果之前,我不会返回。 |
非阻塞 | 就是调用我(函数),我(函数)立即返回,通过select通知调用者 |
同步I/O和异步I/O的区别就在于:数据拷贝的时候进程是否阻塞
阻塞I/O和非阻塞I/O的区别就在于:应用程序的调用是否立即返回
3.2 Linux 5 种 I/O 模型
1)阻塞I/O(blocking I/O)
2)非阻塞I/O (nonblocking I/O)
3) I/O复用(select 和poll) (I/O multiplexing)
4)信号驱动I/O (signal driven I/O (SIGIO))
5)异步I/O (asynchronous I/O (the POSIX aio_functions))
其中前4种都是同步,最后一种才是异步。
3.2.1 阻塞 I/O
场景:
小明同学急用开水,打开水时发现开水龙头没水,他一直等待直到装满水然后离开。
这一过程就可以看成是使用了阻塞IO模型,因为如果水龙头没有水,他也要等到有水并装满杯子才能离开去做别的事情。
很显然,这种IO模型是同步的。
概念:
应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。
如果数据没有准备好,一直等待….直到数据准备好了,从内核拷贝到用户空间,IO函数返回成功指示。
例:
如下图。
扩展:
linux 默认情况下,所有的 socket 都是被设置为阻塞的。
3.2.2 非阻塞 I/O
场景:
小明同学又一次急用开水,打开水龙头后发现没有水,因为还有其它急事他马上离开了,过一会他又拿着杯子来看看……
在中间离开的这些时间里,小明同学离开了装水现场(回到用户进程空间),可以做他自己的事情。这就是非阻塞IO模型。
但是它只有是检查无数据的时候是非阻塞的,在数据到达的时候依然要等待复制数据到用户空间(等着水将水杯装满),因此它还是同步IO。
概念:
非阻塞IO通过进程反复调用IO函数(多次系统调用,并马上返回);
在数据拷贝的过程中,进程是阻塞的
例:
如下图。
扩展:
在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞I/O不会交出CPU,而会一直占用CPU。
3.2.3 I/O复用
是最常用的 I/O 通知机制。
场景:
有一天,学校里面优化了热水的供应,增加了很多水龙头,
这个时候小明同学再去装水,舍管阿姨告诉他这些水龙头都还没有水,你可以去忙别的了,等有水了告诉他。
于是等啊等(select调用中),过了一会阿姨告诉他有水了。
这里有两种情况:
情况1: 阿姨只告诉来水了,但没有告诉小明是哪个水龙头来水了,要自己一个一个去 尝试。(select/poll 场景)
情况2: 舍管阿姨会告诉小明同学哪几个水龙头有水了,小明同学不需要一个个打开看(epoll 场景)
概念:
应用程序通过 I/O 复用函数向内核注册一组事件,内核通过 I/O 复用函数把其中就绪的事件通知给应用程序。
I/O 复用本身是阻塞的,但它具有同时监听多个 I/O 事件的能力。
例:
当某个socket可读或可写时,阻塞该 socket,而其他的 socket 会有相应的状态。
扩展:
select 工作流程:
1.当用户进程调用了select,那么整个进程就会被block,
而同时,kernel会 “监视”所有select负责的socket,当
2.任何一个socket中的数据准备好了,select就会返回。
这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
由上 select 工作流程,可看出 IO多路复用的特点是通过一种机制,一个进程能同时等待多个文件描述符,
而这些文件描述符(套接字描述符)其中的任意一个进入就绪状态,select()函数就可以返回。
性能:
如果处理的连接数不是很高的话,
使用select/epoll的web server不一定比使用mutil-threading + blocking IO的web server性能更好,可能延迟还更大。
select/epoll 的优势并不是对于单个连接能处理得更好,而是在于能同时处理更多的连接。
设置非阻塞IO的常用方式如下:
1.创建socket时指定(在type中增加 SOCK_NONBLOCK)
int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);
2.在使用前指定
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) | O_NONBLOCK);
3.2.4 信号驱动I/O
场景:
有一天,学校里面优化了热水的供应,增加了很多水龙头,
这个时候小明同学再去装水,舍管阿姨告诉他你可以先去做别的事,等有水了我通知你。
概念:
首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用I/O操作函数处理数据。
3.2.5 异步 I/O
概念:
当一个异步过程调用发出后,调用者不能立刻得到结果。
实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者的输入输出操作。
上诉过程中不发生阻塞,故事异步的。
3.2.6 异步 I/O 和 同步 I/O 区别
同步 I/O 模型:
要求用户代码自行执行 I/O 操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区)。
可以理解为,同步I/O向应用程序通知的是 I/O 就绪事件。
异步 I/O 模型:
由内核来执行 I/O 操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的)。
可以理解为,异步 I/O 向应用程序通知的是 I/O 完成事件。
总结:
由上总结,可以看出 I/O 是异步和同步 主要是看 内核向应用程序通知的是何种事件(就绪事件还是完成事件),
以及该由谁来完成 I/O 读写(是应用程序还是内核)。
3.3 Linux 2 种高效事件处理模式
服务器程序通常需要处理三类事件:
I/O 事件、信号及定时事件。
随着网络设计模式兴起,Reactor 和 Proactor 事件处理模式应运而生。
同步 I/O
通常用于实现 Reactor 模式
,异步 I/O
通常用于实现 Proactor 模式
。
不过可以使用 同步I/O 模拟出 Proactor 模式。
3.3.1 Reactor 模式
概念:
Reactor 模式要求 主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,
若有,则立即将该事件通知 工作线程(逻辑单元)。
除了上面的通知操作,主线程(I/O处理单元)不做任何其他实质性的工作。
读写数据、接受新连接、处理客户请求均在 工作线程(逻辑单元) 中完成。
例:
如下图(epoll_wait 为例)。
3.3.2 Proactor 模式
概念:
Proactor 模式将所有 I/O 操作都交给主线程(I/O处理单元)和内核处理,
工作线程(逻辑处理单元)仅仅负责处理业务逻辑。
故,此模式更符合 上文 2.服务器程序解构 中 图8-4 的服务器结构。
例子:
见下图(异步 I/O实现为例)。
3.3.2 使用同步 I/O 模拟 Proactor 模式
原理:
主线程执行数据读写操作,读写完成后,主线程向工作线程通知这一“完成事件”。
例:
如下图
3.4 两种高效的并发模式
并发编程:
概念:
并发编程目的是让程序 “同时” 执行多个任务,主要有多进程和多线程两种方式。
1.如果程序是计算密集型:
则并发编程没有任何优势,反而由于任务的切换使效率降低。
2.如果程序是 I/O 密集型:
比如经常读写文件,访问数据库。
由于 I/O 操作的速度远没有 CPU计算速度快,所以让程序阻塞于 I/O 操作将浪费大量 CPU 时间。
如果程序有多个执行进程,则当前被 I/O 操作所阻塞的执行线程可主动放弃 CPU(或由操作系统调度),并将执行权转移到其他线程。
这样 CPU利用率就提升了。
实现方式:
1.多进程
2.多线程
并发模式:
概念:
指I/O处理单元 和 多个逻辑单元之间协调完成任务的方法。
服务器并发模式:
1.半同步/半异步(half-sync/half-async)模式
2.领导者/追随者(Leader/Followers)模式
3.4.1 半同步/半异步(half-sync/half-async)模式
-
半同步/半异步(half-sync/half-async)模式 普通版
特别注意: 这里 半同步/半异步(half-sync/half-async)模式 的 “同步” 和 “异步” 与前面讨论的 I/O 模型中的 “同步” 和 “异步” 是完全不同的概念。 I/O 模型中: 同步 和 异步 的区分是内核向应用程序通知的是何种 I/O 事件(是就绪事件还是完成事件),以及由谁来完成 I/O 读写(是应用程序还是内核)。 并发模式中: 同步(见下图 a): 指程序完全按照代码序列的顺序执行。 按此方式 运行的线程称为同步线程。 异步(见下图 b): 指程序的执行需要由系统事件来驱动。 常见的系统事件包括: 中断、信号等。 按此方式 运行的线程称为异步线程。
半同步/半异步(half-sync/half-async)模式中: 同步线程: 用于处理业务逻辑(相当于 上文 2.服务器程序解构 中 图8-4 的逻辑单元)。 异步线程: 用于处理 I/O 事件(相当于 上文 2.服务器程序解构 中 图8-4 的 I/O 处理单元)。 工作流程(如下图所示): 1.监听到客户请求后,将其封装成请求对象并插入请求队列。 2.请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。 具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。 请求队列的设计: 1.如最简单的轮流选取工作线程的 Round Robin 算法 2.如条件变量或信号量随机选择一个工作线程
-
半同步/半反应堆(half-sync/half-reactive)
-
半同步/半异步(half-sync/half-async) 高效版
3.4.2 领导者/追随者(Leader/Followers)模式
什么是句柄
概念:
领导者/追随者 模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。
工作流程(如下图 8-14 所示):
1.任意时间点,程序都仅有一个领导者线程,它负责监听 I/O 事件,
而其它线程都是追随者,它们休眠在线程池等待成为新的领导者。
2.若当前领导者检测到 I/O 事件,
首先从线程池中推选出新的领导者线程,然后处理 I/O事件。
此时,新的领导者等待新的 I/O 事件,而原来的 领导者则处理 I/O 事件,二者实现了并发。
领导者/追随者(Leader/Followers)模式包含 4 个组件:
(4 个组件关系如上图 8-12 所示)
1.句柄集(HandleSet)
句柄(handle)用于表示 I/O 资源,在 Linux下通常是一个文件描述符。
句柄集 管理众多句柄,使用 wait_for_event() 方法来监听这些句柄上的 I/O 事件,并将其中的就绪事件通知给领导者线程。
领导者 则调用绑定到 Handle 上的事件处理器来处理事件(这个绑定通过 调用句柄集中的 register_handle 方法实现)。
2.线程集(ThreadSet)
这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。
它负责各线程之间的同步,以及新领导者线程的推选。
线程集 中的 线程在任一时间必处于 如下三种状态之一:
1.Leader:
线程当前处于领导者身份,负责等待句柄集上的 I/O 事件。
2.Processing:
线程正在处理事件。
领导者检测到 I/O 事件后转移到 Processing 状态来处理该事件,并调用 promote_new_leader() 方法来推选新的领导者,
也可以指定其他追随者来处理事件(Event Handoff),此时领导者地位不变。
当处于 Processing 状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。
3.Follower
线程当前处于追随者身份,通过调用线程集的 join() 方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。
三种状态转换如下图 8-13 所示
3.事件处理器(EventHandler)
事件处理器 通常包含:
一个或多个回调函数 handle_event。
事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。
4.具体的事件处理器(ConcreteEventHandler)
是事件处理器的派生类。
它们必须重新实现基类的 handle_event 方法,以处理特定的任务。
扩展:
优点:
领导者线程自己监听 I/O 事件并处理客户请求,因而领导者/追随者 模式不需要线程间传递任何数据,
也无需像 半同步/半反应堆 模式那样 在线程之间同步对请求队列的访问。
缺点:
领导者/追随者(Leader/Followers)模式 仅支持一个事件源集合,
故无法让每个工作线程独立的管理多个客户连接。
3.5 逻辑单元内部高效编程方法–有限状态机
有的应用层协议头部包含数据包类型字段:
每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。
状态之间的转移需要状态机内部驱动。示例如下图 8-2 所示。
下面讲一个真正的有限状态机应用实例:HTTP的读取和分析。
预备知识:
HTTP 并没有 TCP 那样的头部长度字段。
但可以根据协议规定,判断 HTTP 头部结束依据是 遇到一个空行(仅包含一对回车换行符<CR><LF>)
分析流程:
如果一次读操作没有遇到空行(可以认为没有读入完整 HTTP 请求头部),那必须等待客户继续写数据并再次腐乳。
故每完成一次读操作,就要分析读入的数据中是否有空行。
在寻找空行的过程中,可以同时完成对整个 HTTP 请求头部的分析(注意请求行前面还有请求行和头部域),以提高解析 HTTP 请求的效率。
如下例子,使用主、从两个有限状态机实现最简单的 HTTP 请求的 读取和分析。(为了表述简洁,约定 HTTP请求的一行(包括 请求行和头部字段)为行)
3.6 提高服务器性能的其他建议
提高服务器的整体性能:
除了上文提到的几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式——有限状态机。
还有其他几个方面:
池、数据赋值、上下文切换 和 锁。
3.6.1 池(pool)
概念:
以空间换时间,即“浪费”服务器硬件资源,以换取运行效率。
静态资源分配:
池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化。
故,池的资源是预先静态分配的(引出预先分配多少资源的问题,下文详解)。
效果:
池相当于服务器管理系统资源的应用层设施,避免了服务器对内核的频繁访问。
预先分配多少资源,解决方式:
1.最简单的解决方案就是分配“足够多”的资源
2.预先分配一定的资源,此后若发现资源不够用,再动态分配一些加入池中。
池的分类:
根据资源类型:
1.内存池
通常用于 socket 接收缓存和发送缓存。
例:
对于某些请求,如 HTTP 请求,预先分配一个大小足够的(如5000字节)棘手缓存区是合理的。
当客户请求长度超过接收缓存区的大小时,我们可以选择丢弃请求 或 动态扩大接收缓存区。
2.进程池
3.线程池
进程池 和 线程池 是并发编程常用。
当需要一个工作进程或工作线程来处理新到来的客户请求时,可以直接从进程池或线程池取得一个执行实体,
无需动态调用 fork 或 pthread_create .
4.连接池
常用于服务器或服务器机群的内部永久连接。
若逻辑单元需要频繁访问数据库,
方式1:
每次访问就向数据库建立连接,访问完毕后流释放连接。
显然,此方式效率很低。
方式2:
当某个逻辑单元需要访问数据库时,就从连接池中取得一个连接的实体并使用,
完成访问后,再将该链接返还给连接池。
3.6.2 数据复制
高性能服务器应避免不必要的数据复制:
情况1:
若内核可以 “直接处理” 从 socket 或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区。
“直接处理” 指的是应用程序不关心这些数据内容。
例:
如FTP 服务器,当 ftp 客户请求一个文件时,可以使用 “零拷贝” 函数 sendfile 来直接将其发送给客户端。
情况2:
用户代码内部(不访问内核)的数据复制也是应该避免。
例:
当两个工作进程间要传递大量数据时,就应该考虑 共享内存在它们之间共享这些数据,而不是使用管道或消息队列来传递。
3.6.3 上下文切换
上下文切换(context switch): 即进程切换或线程切换。
并发程序必须考虑上下文切换(即 进程或线程 切换)导致的系统开销。
注意:
即使是 I/O 密集型服务器,也不应该使用过多的工作线程(或进程),否则线程切换会占用大量 CPU 时间。
多线程服务器,当线程数量不大于 CPU 数目,上下文切换就不是问题。
3.6.4 锁
锁的代码不仅不处理业务逻辑,而且需要访问内核资源。
故,若可能就避免使用锁。
若躲不开使用锁,就尽可能减小锁的粒度,如使用读写锁。
例:
当所有工作线程都只读取一块共享内存内容时,读写锁并不会增加系统额外开销。
只有当其中某一个工作线程需要写这块内存时,系统才会锁这块区域。