IO模型的发展历程

I/O模型指的是程序与输入输出设备之间交互的方式。下面是I/O模型的发展历程:

  1. 阻塞式I/O模型(Blocking I/O Model):在此模型中,当一个I/O操作被执行时,进程会阻塞,直到该操作完成为止。这种模型简单易用,但是效率较低,因为进程会一直等待操作完成,期间不能进行其他工作。
  2. 非阻塞式I/O模型(Non-Blocking I/O Model):在此模型中,当一个I/O操作被执行时,进程不会阻塞,而是立即返回,并在后续时间内轮询查看操作是否已经完成。这种模型相对于阻塞式I/O模型来说效率更高,但需要轮询查看操作状态,会增加CPU的负担。
  3. I/O复用模型(I/O Multiplexing Model):在此模型中,使用select、poll、epoll等方法,可以同时监测多个文件描述符上的I/O事件,从而实现非阻塞的I/O操作。这种模型通过将多个文件描述符注册到同一个轮询器进行监听,减少了轮询的开销,提高了效率。
  4. 信号驱动式I/O模型(Signal Driven I/O Model):在此模型中,当一个I/O操作完成时,系统会向进程发送一个信号,进程接收到信号后会进行I/O操作的处理。这种模型相对于I/O复用模型来说,减少了轮询的开销,但是需要考虑一些额外的复杂性,如信号与进程之间的交互。
  5. 异步I/O模型(Asynchronous I/O Model):在此模型中,当一个I/O操作被提交时,进程可以继续执行其他操作,而不需要等待I/O操作完成。当I/O操作完成后,系统会向进程发送一个通知,进程可以在此时处理相关的数据。这种模型相对于其它模型来说,效率更高,并且可以支持更高并发量。

阻塞式IO模型

单线程阻塞IO模型

下面看一个阻塞式IO模型的Python代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import socket

# 创建socket对象
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 绑定IP地址和端口号
server_address = ('localhost', 8000)
server_socket.bind(server_address)

# 监听socket连接
server_socket.listen(1)

print('等待客户端连接...')

while True:
# 等待客户端连接,此处会阻塞
client_socket, client_address = server_socket.accept()
print('收到来自 {} 的连接'.format(client_address))

while True:
try:
# 接收来自客户端的数据,此处会阻塞
data = client_socket.recv(1024)

if data:
# 向客户端发送响应数据
client_socket.sendall(b'Hello, ' + data)
else:
# 客户端关闭了连接
print('来自 {} 的连接已关闭'.format(client_address))
client_socket.close()
break
except Exception as e:
# 出现异常,关闭连接
print('出现异常:{}'.format(str(e)))
client_socket.close()
break

# 关闭服务器socket连接
server_socket.close()

这个示例代码创建了一个TCP服务器,监听在本地8000端口上。上面有两处标记阻塞的地方。当代码运行到这两个地方后,会持续的等待数据,直到数据准备好,程序才会进行下一步。可见,这样做的效率并不高。

多线程阻塞IO模型

下面是一个使用Python实现阻塞式I/O的示例,同时采用线程来实现并发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import socket
import threading

def handle_client(client_socket):
# 接收数据
request = client_socket.recv(1024)
print(f"Received: {request.decode()}")

# 发送响应
response = "HTTP/1.1 200 OK\nContent-Type: text/html\n\nHello World!"
client_socket.send(response.encode())

# 关闭客户端socket连接
client_socket.close()

# 创建socket对象和地址
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
address = ('localhost', 8000)

# 绑定socket并开始监听
server_socket.bind(address)
server_socket.listen(5)
print(f"Listening on {address}...")

while True:
# 等待客户端连接
client_socket, _ = server_socket.accept()
print("Connected by", _)

# 创建新线程处理客户端请求
client_thread = threading.Thread(target=handle_client, args=(client_socket,))
client_thread.start()

在这个示例中,我们创建了一个阻塞式的socket对象,并绑定到指定的地址上。接着,我们进入一个循环,不断等待客户端连接。当有客户端连接时,我们创建一个新的线程来处理客户端请求,从而实现并发。在handle_client()函数中,我们首先接收客户端发送的数据,然后构造一个HTTP响应并发送给客户端。最后,我们关闭客户端socket连接。
在这里我们通过线程实现了并发,但是实际上,并没有解决代码阻塞的问题,只是将阻塞等待的时间转移到了子线程当中。

非阻塞式I/O模型

非阻塞式I/O模型是一种能够在一个线程中处理多个I/O事件的方式,可以让程序在等待I/O完成时不会被阻塞。下面是一个使用Python实现非阻塞式I/O的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('localhost', 8080)
server_socket.bind(server_address)
server_socket.listen(5)

while True:
    try:
        # 没有接受到数据,出让执行权
        # 接收到数据,执行接受TCP数据包
        conn,addr = server_socket.accept()
        while True:
            try:
                # 没有接受到数据,出让执行权
                data = conn.recv(1024)
                conn.send(data)
            except BlockingIOError:
                continue
    except BlockingIOError:
        continue

上面这个模型可以看出,代码是没有阻塞过程的,一直在轮询是否接受到数据,效率是非常高的。但是缺点也很明显,哪怕一直都没有IO数据,程序也一直在空转,浪费了许多的CPU资源。

I/O多路复用模型

下面是一个使用Python的select模块实现I/O多路复用的TCP服务器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import socket
import select

# 创建TCP服务器套接字并绑定端口
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('localhost', 8888))
server_socket.listen(10)

# 将服务器套接字加入到select监听列表中
inputs = [server_socket]

while True:
# 使用select函数进行监听,阻塞直到有socket对象可读或可写
readable, writable, exceptional = select.select(inputs, [], [])

# 遍历所有可读取的socket对象
for sock in readable:
# 如果是服务器套接字,则表示有客户端请求连接
if sock == server_socket:
client_socket, address = server_socket.accept()
inputs.append(client_socket)
print(f"New connection from {address}")
else:
# 否则为已连接客户端发来数据
data = sock.recv(1024)
if data:
print(f"Received \"{data.decode().strip()}\" from {sock.getpeername()}")
sock.sendall(data)
else:
# 如果客户端关闭了连接,则从监听列表中删除该socket对象
print(f"Client {sock.getpeername()} closed connection")
inputs.remove(sock)
sock.close()

这个示例中通过select模块实现了I/O多路复用,可以同时处理多个客户端连接请求和数据收发。每当有新的连接请求时,我们将客户端套接字加入到监听列表中,当有数据可读取时就进行处理,如果客户端关闭连接则从监听列表中删除该套接字。

信号驱动式I/O模型

下面是一个使用Python的select模块实现I/O多路复用的TCP服务器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import socket
import select
import signal

HOST = '127.0.0.1'
PORT = 8080
BACKLOG = 5

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((HOST, PORT))
server_socket.listen(BACKLOG)

# 处理SIGINT信号
def handle_sigint(signum, frame):
server_socket.close()
print("Server shutting down...")
exit(0)

# 注册SIGINT信号处理函数
signal.signal(signal.SIGINT, handle_sigint)

# 监听文件描述符列表
inputs = [server_socket]

print(f"Server started on {HOST}:{PORT}...")

while True:
# 使用select函数等待文件描述符就绪
readables, _, _ = select.select(inputs, [], [])

for sock in readables:
if sock == server_socket:
# 新连接请求
client_socket, client_address = server_socket.accept()
inputs.append(client_socket)
print(f"New connection from {client_address}")
else:
# 已连接客户端发送数据
data = sock.recv(1024)
if data:
print(f"Received data from {sock.getpeername()}: {data.decode('utf-8')}")
else:
# 客户端关闭连接
print(f"Client {sock.getpeername()} disconnected.")
inputs.remove(sock)
sock.close()

该示例代码中,主要使用了Python自带的socket、select和signal模块来实现信号驱动式I/O模型的TCP服务器。在主循环中,通过select函数等待文件描述符就绪,如果是server_socket就表示有新连接请求,否则就是已连接的客户端发送数据。

异步I/O模型

以下是一个使用异步I/O模型的TCP服务器示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import asyncio

HOST = '127.0.0.1'
PORT = 8080

# 处理新连接请求
async def handle_new_client(reader, writer):
address = writer.get_extra_info('peername')
print(f"New connection from {address}")

while True:
data = await reader.read(1024)
if not data:
# 客户端关闭连接
print(f"Client {address} disconnected.")
break

message = data.decode('utf-8')
print(f"Received message from {address}: {message}")

# 响应客户端消息
response = f"Echo: {message}"
writer.write(response.encode('utf-8'))
await writer.drain()

writer.close()

async def main():
server = await asyncio.start_server(handle_new_client, HOST, PORT)

async with server:
print(f"Server started on {HOST}:{PORT}...")
await server.serve_forever()

asyncio.run(main())

该示例代码中,主要使用了Python自带的asyncio模块来实现异步I/O模型的TCP服务器。通过asyncio.start_server函数创建一个TCP服务器,并将handle_new_client作为回调函数来处理新连接请求。在handle_new_client中,通过reader.read异步读取客户端发送的数据,并使用writer.write异步发送响应数据。
在主函数main中,使用asyncio.run运行异步协程来启动服务器。当有新连接请求时,handle_new_client会被异步执行,不会阻塞主循环,从而实现了异步I/O模型的TCP服务器。

select、poll和epoll的原理

select、poll和epoll都是实现I/O多路复用的系统调用。它们的基本原理都是在内核中维护一个事件表,用于存储每个文件描述符及其所关注的事件(如可读、可写或异常条件)。应用程序可以通过这些系统调用将自己的文件描述符添加到事件表中,并等待内核通知它们哪些文件描述符已经准备好进行I/O操作。

在具体实现上,select、poll和epoll之间有一些不同之处:

  1. select

在select中,应用程序通过fd_set类型的集合来指定感兴趣的文件描述符,并通过select系统调用向内核注册这些文件描述符和感兴趣的事件。当内核发现有感兴趣的事件发生时,它会修改相应的fd_set集合,以便应用程序能够知道哪些描述符已经准备好进行I/O操作。

select的实现通常使用了轮询的方式来遍历所有的文件描述符集合,并检查其中的每个文件描述符是否已经就绪。这种实现方式会导致效率低下,并且随着文件描述符数量的增加,其效率会进一步降低。

  1. poll

与select类似,poll也使用了一个结构体数组来存储文件描述符及其所关注的事件。与select不同的是,poll没有对集合大小进行限制,并且使用了链表来管理事件。这样,应用程序可以更方便地扩展和管理自己的文件描述符集合。

poll的实现通常也是采用轮询方式遍历整个事件列表,从而判断哪些文件描述符已经准备好进行I/O操作。这种实现方式与select相似,同样会随着文件描述符数量的增加导致效率降低。

  1. epoll

epoll是一种高效的I/O多路复用机制,它通过将文件描述符添加到内核事件表中来实现对事件的监视。当一个文件描述符就绪时,内核会将其加入到一个就绪队列中,并通知应用程序进行处理。

有三种工作模式:LT(Level Triggered)、ET(Edge Triggered)和ONESHOT。其中LT模式是默认模式,ET模式可以提高并发性能,ONESHOT模式可以确保每个事件只被一个线程处理。

epoll的实现采用了红黑树和双向链表的数据结构。在内核中,每个文件描述符都会对应一个可读、可写或异常事件的双向链表。当一个文件描述符就绪时,它会被移动到相应的事件链表上。这种实现方式可以快速定位到就绪的文件描述符,从而提高系统的性能和可扩展性。

总之,select、poll和epoll都是实现I/O多路复用的系统调用,它们的基本原理都是在内核中维护一个事件表,并使用轮询或其他数据结构来监视文件描述符的状态。但在具体实现上有所不同,epoll因其高效性和可扩展性而成为Linux系统中最流行的I/O多路复用机制之一。