传统的网络服务设计模式中有两种,一种是多线程,一种是线程池。
对于多线程模式,也就是来了客户端服务器就会新建一个线程来处理该客户端的读写事件。由于服务器会为每个连接新建一个线程来处理,资源占用会非常大。因此当连接数量达到上限时,再有用户请求连接到来将直接导致资源瓶颈,严重的可能会导致服务器崩溃。
为了解决一个线程对应一个客户端连接模式带来的问题,后来提出了线程池的方式,也就是创建一个固定大小的线程池,来一个客户端就从线程池中获取一个空闲的线程来处理。当客户端处理完读写操作后就交出对线程的占用。以避免为每隔客户端都创建线程带来的资源浪费,使得线程可以重用。
但线程池也存在它的弊端,如果连接大多是长连接,因此可能会导致在一段时间内线程池中的线程都被占用,当再有用户请求连接时,由于没有可用的空闲线程来处理会导致客户端连接失败,从而影响用户体验。因此,线程池比较使用大量的短连接应用。
总所周知Linux世界中一切皆文件,每个进程都拥有一张文件描述符的表,指向文件、Socket、硬件等其它操作系统对象。文件描述符(FD, File Descriptor)是一个用于指向文件引用的抽象概念,具体来说文件描述符是一个索引值,指向内核为每个进程所维护的该进程打开文件的记录表。当应用程序打开一个现有文件或创建新文件时,内核会向进程返回一个文件描述符。
Go是基于I/O 多路复用模型和goroutine
调度器构建的简洁且高效地原生网络模型。
Golang中I/O是阻塞的,Go生态系统是围绕这个想法构建的,开发者根据阻塞接口编程,在通过goroutine
和channel
处理并发,而非采用的callbacks
或futures
。
I/O多路复用指多路选择器(select/poll/epoll
)在单一用户线程同时监听多个文件描述符上的I/O事件,阻塞等待直到当某个文件描述符收到可读写的通知。
Go原生网络模型是基于netpoller
的I/O多路复用模型,所谓netpoller
其实是利用操作系统的非阻塞I/O访问模型,配合epoll/kqueue
等I/O事件监控机制,为弥合操作系统的异步机制和Golang接口的差异,在runtime上做的一层封装,以此来实现网络I/O优化。同时借助于Go Runtime Scheduler对goroutine
进行高效地调度。
Golang的netpoller底层事件驱动技术是基于I/O多路复用技术(I/O事件驱动技术,比如epoll/kqueue/iocp
)来做封装,只不过是将这些调度和上下文切换的工作转移到了Golang运行时的调度器上,让它来负责调度goroutine
,最终暴露出goroutine-per-connection
这样极简的开发模式给使用者。从而实现开发者使用同步的模式去编写异步的逻辑,对于开发者来说I/O是否阻塞是无感知的,开发者无需考虑goroutine
甚至更底层的线程、进程的调度和上下文切换,极大地降低了开发者编写网络引用的心智负担。
netpoller
会将异步I/O转换为阻塞I/O,netpoller
使用操作系统提供的接口来轮询网络Socket。在Linux上使用的是epoll
、在BSD和Darwin上使用的是kqueue
、在Windows上使用的是IoCompletionPort
(IOCP)。这些接口的共同之处在于它们为用户空间提供了一种有效查询网络I/O状态的方法。因此Golang的netpoller在不同的操作系统底层使用的I/O多路复用技术也不同。
总结来说,所有的网络操作都是以网络操作符netFD
为中心实现的。
// Network file descriptor.
type netFD struct {
pfd poll.FD
// immutable until Close
family int
sotype int
isConnected bool // handshake completed or use of association with peer
net string
laddr Addr
raddr Addr
}
netFD
网络文件描述符与底层PollDesc
结构绑定,当在一个netFD
上读写时遇到EAGAIN
错误时会将当前goroutine
存储到这个netFD
对应的PollDesc
中,同时会调用gopark
将当前goroutine
给park
住,直到这个netFD
上再次发生读写事件,才会将此goroutine
给ready
激活重新运行。显然,在底层通知goroutine
再次发生读写事件的方式就是epoll/kqueue/iocp
等事件驱动机制。
netFD
是一个网络描述符,类似于Linux中的文件描述符。netFD
中包含了一个poll.FD
的数据结构。
// FD is a file descriptor. The net and os packages embed this type in
// a larger type representing a network connection or OS file.
type FD struct {
// System file descriptor. Immutable until Close.
Sysfd syscall.Handle
// I/O poller.
pd pollDesc
}
poll.FD
结构中包含两个重要的数据结构Sysfd
和pollDesc
,Sysfd
是系统文件描述符,pollDesc
是对底层事件驱动的封装,所有读写超时等操作都是通过调用它对应方法实现的。
例如:使用Golang编写的TCP echo服务器
package main
import (
"log"
"net"
)
func HandleConn(conn net.Conn) {
defer conn.Close()
packet := make([]byte, 1024)
for {
n, err := conn.Read(packet)
if err != nil {
log.Println("read socket error:", err)
return
}
conn.Write(packet[:n])
}
}
func main() {
listener, err := net.Listen("tcp", ":8888")
if err != nil {
log.Println("listen error:", err)
return
}
for {
conn, err := listener.Accept()
if err != nil {
log.Println("accept error:", err)
break
}
go HandleConn(conn)
}
}
调用net.Listen
之后底层会通过Linux系统调用socket
来创建一个文件描述符分配给listener
,用来初始化listener
的netFD
,接着调用netFD
的listenStream
方法完成对Socket的bind
和listen
操作,以及对netFD
的初始化。
服务端的netFD
在listen
时会创建epoll
实例,并将listenerFD
加入到epoll
的事件队列中,netFD
在accept
时会返回connFD
也会加入到epoll
的事件队列。当netFD
在读写时出现syscall.EAGAIN
错误时,通过pollDesc
的waitRead
方法会将当前的goroutine
给pack
住,直到ready
,从pollDesc
的waitRead
中返回。
内存空间
现代计算机有硬件和操作系统组成,操作系统通过内核与硬件交互,操作系统可划分为内核与应用两部分,内核提供进程管理、内存管理、网络等底层功能,封装了与硬件交互的接口,通过系统调用提供给上层应用使用。
现代操作系统大多采用内核空间和用户空间的设计来保护操作系统自身的安全性和稳定性。
CPU指令中有些指令是非常危险的,错用会导致系统崩溃比如清内存、设置时钟等。如果允许所有的应用程序都可以使用这些指令,那么系统崩溃的概率会大大增加。因此CPU将指令会分为特权指令和非特权指令,对于具有危险性的指令只允许操作系统机器相关模块使用,普通应用程序只能使用哪些不会造成灾难的指令。比如Interl的CPU会将特权等级分为4个等级分别从Ring0~Ring3,Linux操作系统只能使用Ring0和Ring3两个运行级别的,当进程运行在Ring3级别时会被称为运行在用户态,运行在Ring0级别时被称为运行在内核态。
当进程运行在内核空间时就处于内核态,当进程运行在用户空间时则处于用户态。
- 在内核态下进程运行在内核地址空间中,此时CPU可以执行任何指令。运行的代码也不受任何限制,可自由地访问任何有效地址,也可直接进行端口的访问。
- 在用户态下进程运行在用户地址空间中,被执行的代码要受到CPU的检查,因此只能访问映射其地址空间的页表项中规定的在用户态下可以访问页面的虚拟地址,而且只能对任务状态段(TSS)中I/O许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。
操作系统的核心是内核,是独立于普通应用程序的。内核可以访问受保护的内存空间,也有访问底层硬件设备的权限。为了保证用户进程不能直接操作内核(kernel),以保证内核的安全。操作系统将虚拟空间划分为两部分,一分部为内核空间,一部分为用户空间。
虚拟寻址
现代操作系统都会采用虚拟存储器(虚拟内存)来实现虚拟寻址,CPU先会生产一个虚拟地址,通过地址翻译成物理地址即实际内存的地址,再通过总线来传递,最后CPU会拿到某个物理地址返回的字节。
操作系统的核心是内核,为保证内核的安全,操作系统讲虚拟空间划分为两部分,一部分是内核空间,一部分是用户空间。内核是独立于普通的应用程序的,内核可访问受保护的内存空间,具有访问底层硬件设备的所有权限。
对于32位操作系统而言,寻址空间(虚拟存储空间)为4G即2的32次方。Linux操作系统会中将最高的1G字节(从虚拟地址0xC00000000到0xFFFFFFFF)供内核使用称为内核空间。将较低的3G字节(从虚拟地址0x000000000到0xBFFFFFFFF)供进程使用称为用户空间。
所有的系统资源管理都是在内核空间中完成的,比如读写磁盘文件、分配回收空间、从网络接口读写数据等等。应用程序是无法直接进行操作的,可通过内核提供的接口来完成这样的操作。比如应用程序要读取磁盘上的一个文件时可向内核发起一个“系统调用”来告诉内核:“我要读取磁盘上的某个文件”,系统调用其实是通过一个特殊的指令让进程从用户态进入内核态,在内核空间中CPU可以执行任意指令。
对于进程而言从用户空间进入内核空间并最终返回到用户空间的过程是十分复杂的,比如进程在内核态和用户态都会存在一个堆栈,运行在用户空间是进程使用的是用户空间的堆栈,运行在内核空间时进程使用的是内核空间中的堆栈。
既然用户态进程必须切换到内核态时才能使用系统资源,进程从用户态进入内核态有三种方式分别是系统调用、软中断、硬件中断。
现代网络服务主流已完成从CPU密集型到I/O密集型的转变,服务端程序对I/O的处理是必不可少的,一旦操作I/O则必定会在用户态和内核态之间来回切换。
进程切换是指进程上下文切换,即为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行,这种行为被称为进程切换或调度。
I/O
操作系统上I/O是用户空间和内核空间的数据交换,因此I/O操作通常会包含两个操作。
- 数据准备阶段
- 等待网络数据到达网卡(读就绪) -> 读取到内核缓冲区
- 等待网络可写(写就绪)-> 写入到内核缓冲区
- 数据复制阶段
- 从内核缓冲区复制数据->用户空间(读)
- 从用户空间复制数据->内核缓冲区(写)
对于一次I/O访问,以读操作为例,数据先会被拷贝到操作系统内核的缓冲区中,然后才从操作系统内存缓冲区拷贝到应用程序的缓冲区中,最后交给进程。所以,当一个读操作发生时,实际上它会经历两个阶段:
- 等待数据准备阶段
- 内核空间复制数据到用户进程缓冲区(用户空间)阶段
例如:网络请求访问服务器静态文件时的I/O流程
进程发起一个系统调用来读取磁盘文件
- DMA将磁盘文件数据拷贝到内核空间的读缓冲区
- CPU将内核空间读缓冲区中的数据拷贝到用户空间的缓冲区中
进程发起一个系统调用来向网卡写数据
- CPU将用户空间缓冲区中的数据拷贝到内核空间的Socket缓冲区中
- DMA将内核空间中Socket缓冲区中的数据拷贝到网卡
注意:DMA直接存储器访问,可看作是CPU的一种辅助硬件访问的芯片,在进行内存与I/O设备数据传输时,无需CPU来控制,直接通过DMA实现。
I/O模型
由于数据I/O读写操作在内存空间之间的切换,而产生了5种网络模式的方案。
UNIX网络编程中归纳了5种I/O模型
网络模型 | 名称 | 阻塞 |
---|---|---|
阻塞I/O | Blocking I/O | 同步阻塞 |
非阻塞I/O | Nonblocking I/O | 同步阻塞 |
I/O多路复用 | I/O Multiplexing | 同步阻塞 |
信号驱动I/O | Signal Driven I/O | 同步阻塞 |
异步I/O | Asyncchronous I/O | 异步非阻塞 |
阻塞I/O(Blocking I/O)
以读操作为例,当用户进程发起读操作时会执行recvfrom
系统调用。
- 内核处理进入第一阶段准备数据
对于网络I/O由于数据最开始还没有到达,比如还没有接受到一个完整的UDP包,此时内核就需要等待足够多的数据到来,而此时用户进程会被阻塞挂起。 - 当内核等到数据准备好后进入第二个阶段数据复制
数据会从内核缓存区拷贝到用户内存,只有当数据拷贝完成内核才会返回结果,而此时用户进程也时处于阻塞状态。
只有当数据拷贝完成用户进程才得以解除阻塞状态,重新运行起来。在Linux中默认情况下所有的Socket都是阻塞的。
非阻塞I/O(NonBlocking I/O)
以读操作为例
- 当用户进程发起读操作,此时若内核中数据尚未准备好,则立即返回一个错误,因此不会阻塞用户进程。
从用户进程角度来讲,它发起一个读操作后并不需要等待,而会马上得到一个结果。 - 用户进程判断返回结果是否为错误即可直到数据是否已经准备好,若发生错误则表示数据尚未准备好,此时用户进程会再次发送一个读操作。
- 一旦内核中数据准备好,同时再次收到用户进程的系统调用时,内核会立即将数据拷贝到用户内存然后返回。
非阻塞I/O的特点是用户进程在内核准备数据阶段需要不断地主动询问数据是否已经准备好。
多路复用I/O(I/O Multiplexing)
事件驱动
传统服务器I/O模型会为每个请求创建一个子线程来处理,这种方式在并发量小的情况下可以正常支撑业务,但在高并发场景下,机器资源会很快被耗尽。目前常见的高吞吐高并发系统往往是基于事件驱动的I/O多路复用模型设计,这种模式会将所有的请求交给一个单独的线程来管理,这个线程被称为事件循环线程。当事件等待的系统资源就绪时会及时进行处理,而不是为每隔连接生成一个系统线程。这种事件驱动的异步模型大幅度提升了服务器的吞吐能力,在相同配置的服务器能接受更多的请求。
事件驱动模型应用十分广泛,Redis是一个典型的单线程基于事件驱动的内存数据库,Node.js、Nginx、Netty等也都是基于这种方式来实现高吞吐性能。
操作系统提供了事件轮询API(select/poll/epoll系统调用)来支持I/O多路复用模式,I/O多路复用模式其实复用的是一个用户线程。比如通过select
系统调用用户线程可以同时检查多个文件描述符,监视这些文件描述符是否处于就绪状态,即对文件的I/O系统调用是否会非阻塞的执行。文件描述符就绪状态的转化是通过I/O事件来触发的,比如输入数据到达、套接字连接建立完成、之前满载的套接字发送缓冲区在TCP队列中的数据传送到对端后有了剩余空间等等。
多路复用
I/O多路复用机制存在的本质是内核的wakeup callback
事件,Linux通过Socket睡眠队列来管理所有等待Socket的某个事件的任务,同时会通过wakeup
机制来异步唤醒整个睡眠队列上等待事件的任务,通知任务相关事件发生。通常Socket事件发生时,会顺序遍历Socket睡眠队列上的每个任务节点,调用每个任务节点挂载的回调函数。在遍历过程中,如果遇到某个节点是排他的,则会终止遍历。总体而言会涉及到两大逻辑分别是睡眠等待逻辑和唤醒逻辑。
I/O多路复用的本质是通过一种机制(内核缓冲I/O数据)让单个进程可以监视多个文件描述符,一旦某个文件描述符进入就绪状态,就能通知用户进程进行对应的读写操作。而select/poll/epoll
则是Linux API提供的I/O多路复用的方式。
I/O多路复用模型也称为事件驱动I/O,用户会阻塞在select/poll/epoll
系统调用上,内核不会断轮询所负责的所有Socket或其文件描述符。当某个Socket有数据到达时就会通知用户进程。select
调用就返回,此时用户进程再同步等待数据从内核空间拷贝到用户空间。
I/O多路复用实际上是select/poll/epoll
监听多个I/O
对象,当I/O对象有变化时就通知用户进程,优点在于可以处理多个Socket。但select/poll/epoll
本质上都是同步I/O,因为它们都需要在读写事件就绪后自己负责进行读写,也就是说读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责将数据从内核拷贝到用户空间。
select
select本质上是通过设置或检查存放文件描述符标志位的数据结构来进行下一步处理
- 每次调用select都需要将文件描述符集合从用户态拷贝到内核态,当文件描述符很多时开销很大。
- 每次调用select都需要在内核中遍历传递进来的所有的文件描述符,当文件描述符很多时开销很大。
- select支持的文件描述符数量32位机器默认为1024,64位机器默认位2048,太小了。
I/O多路复用和阻塞I/O看起来没有什么区别,因为两个阶段都时阻塞的,事实上I/O多路复用还更差一些,因为它需要使用两个系统调用(select和recvfrom),而阻塞I/O只调用了一个系统调用(recvfrom)。但select的优势在于它可以同时处理多个连接。如果处理的连接数不是很高的话,使用select/poll/epoll
的Web服务器不一定会比使用多线程+阻塞I/O的性能更好,因为select/poll/epoll
的优势并不是针对单个连接处理,而是能同时处理多个连接。
多路复用I/O模型
以select
为例
- 当用户进程调用
select
时整个进程会被阻塞 - 与此同时,内核会监视所有
select
负责的Socket。 - 当任何一个Socket中数据准备好时
select
就会返回。 - 此时用户进程再次调用读操作,将数据从内核拷贝到用户进程。
I/O多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符其中任意一个进入都就绪准备,select()
系统调用就可以返回。
在多路复用I/O模型中有一个线程会不断地去轮询多个Socket的状态,只有当Socket真正有读写事件时才会真正地调用实际的I/O读写操作。因为在多路复用I/O模型中,只需要使用一个线程就可以管理多个Socket,系统无需建立新的进程或线程,也不必维护这些进程或线程,并且只有在真正有Socket读写事件发生时才会使用I/O资源,因此会大大减少资源的占用。
对于长连接线程的资源一直不会释放,后续可能会有更多连接,若每个Socket对应一个线程会造成很大的资源浪费和性能瓶颈。另外多路复用I/O中轮询每个Socket状态是由内核完成的,这一点比非阻塞I/O模型的效率要更高,因为非阻塞I/O轮询Socket状态是通过用户线程完成的。
信号驱动I/O
信号驱动I/O模型中,当用户线程发起一个I/O操作时会给对应的Socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号后,会在信号函数中调用I/O操作来进行实际的读写。
信号驱动I/O模型一般用于UDP中,因为它对TCP Socket几乎是没用的,因为信号产生的过于频繁,同时信号的出现也并没有告诉是什么事情。
异步非阻塞I/O模型
异步I/O模型是最理想的I/O模型,在异步I/O模型中当用户线程发起读操作后可以立即去做其它事情。当内核接收到一个异步的读操作后会立即返回,说明读操作已经成功发起,因此不会对用户线程产生任何阻塞。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当一切完成后内核会给用户线程一个信号,告诉用户线程读操作已经完成。用户线程完全不需要关心实际整个I/O操作是如何进行的,只需要发起一个请求,当接收内核返回的成功信号时表示I/O操作已经完成,就可以直接去使用数据了。
在异步I/O模型中,I/O操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成的,然后发送一个信号告知用户线程操作已经完成。用户线程中不需要再次调用I/O函数进行具体的读写。这一点是和信号驱动模型有所不同的。在信号驱动模型中,当用户线程接收到信号时表示数据已经就绪,然后需要用户线程调用I/O函数进行实际的读写操作。而在异步I/O中接收到信号表示I/O操作已经完成,无需继续在用户线程中调用I/O函数来进行实际的读写操作。
判断一个I/O模型是同步还是异步,主要看数据在用户和内核空间之间复制的时候是不是会阻塞当前进程,如果阻塞进程则是同步I/O,若不阻塞进程则是异步I/O。
进程阻塞
任意时刻一个CPU核心(processor)上只能运行一个进程,当某个进程发起一个系统调用(System Call)后,由于系统调用操作不能立即完成,需等待一段事件,于是内核会将进程挂起为等待(waiting)状态,以确保不会被调度执行占用CPU资源。
有疑问加站长微信联系(非本文作者)