什么是IO模型
简单的来说 IO是输入和输出,IO模型就是定义了应用程序处理输入/输出操作的一种机制,我们常见的IO模型有: 阻塞IO(BIO), 非阻塞IO(NIO), IO多路复用
阻塞IO
我画了一个图来说明这个过程
具体的步骤是:
- 绑定IP地址
- 监听端口号
- 等待连接: Accept 这里是一个阻塞点
- 客户端A和连接到了服务器,通过Accept创建了一个新的fd,而这个fd就代表了客户端A,我们对fd的读写就是对客户端A的读写
- 我们会等待客户端A的写入,这里形成了第二个阻塞点,会一直等待客户端的写入
下面是示例代码:
package main
import (
"bufio"
"fmt"
"net"
"strings"
)
func main() {
// 在本地监听TCP连接,端口为8080
listener, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Error listening:", err.Error())
return
}
defer listener.Close()
fmt.Println("Server listening on port 8080")
for {
// 接受客户端的连接请求
// 这里的conn其实就是fd,是连接上来的客户端的fd
// 这里是一个阻塞点,如果没有新的连接上来,程序阻塞在这里
conn, err := listener.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err.Error())
return
}
fmt.Println("Accepted connection from", conn.RemoteAddr().String())
//
handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer conn.Close()
// 创建一个缓冲读取器
reader := bufio.NewReader(conn)
for {
// 从连接中阻塞读取数据
message, err := reader.ReadString('\n')
if err != nil {
fmt.Println("Read error:", err.Error())
return
}
// 打印接收到的消息并返回给客户端
fmt.Printf("Received message: %s", message)
response := strings.ToUpper(message)
conn.Write([]byte(response))
}
}
从上面代码也能看出来,如果没有新的连接上来,程序会阻塞住,等待客户端的连接 当有新的连接上来后,程序会阻塞在等待客户端写入消息的时候(handleConnection函数的for) 如果这时候有新的连接来了,如果之前的连接未断开的情况下,服务器将无法处理新的连接,这也是BIO最大的问题
非堵塞IO
和BIO相当于的就是非堵塞IO(NIO),在这个模型里,阻塞点已经没有了,取而代之的是一直询问是否有新的消息
这是NIO的例子:
步骤:
- 绑定IP地址
- 监听端口
- Accept: 等待新的连接,这里不再是阻塞点,如果没有新的连接也会立即返回
- 如果有新的连接也会产生一个新的fd,我们把这个fd加入到一个数组中,下一步使用
- 循环遍历这个fd的数组,询问是否有新的内容可以读取,这一步也不再是阻塞点了,如果没有新的内容也会立即返回
通过上面的的图和步骤说明,我们发现如果我们需要客户端是否有新的内容可以读取需要不断的询问是否有新的内容,这是NIO最大的问题,对CPU高消耗
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_CONNECTIONS 10
// 设置文件描述符为非阻塞模式
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL");
return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL");
return -1;
}
return 0;
}
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr;
char buffer[BUFFER_SIZE];
int clients[MAX_CONNECTIONS];
int client_count = 0;
// 初始化客户端数组
for (int i = 0; i < MAX_CONNECTIONS; i++) {
clients[i] = -1;
}
// 创建监听套接字
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 绑定套接字到指定端口
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
exit(EXIT_FAILURE);
}
if (listen(listen_fd, 10) == -1) {
perror("listen");
exit(EXIT_FAILURE);
}
// 设置监听套接字为非阻塞
set_nonblocking(listen_fd);
printf("Server is running on port %d\n", PORT);
while (1) {
// 尝试接受新的连接
conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd != -1) {
set_nonblocking(conn_fd);
printf("Accepted new connection\n");
// 将新连接的文件描述符添加到数组中
if (client_count < MAX_CONNECTIONS) {
clients[client_count++] = conn_fd;
} else {
printf("Max connections reached\n");
close(conn_fd);
}
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("accept");
}
// 轮询连接数组
for (int i = 0; i < client_count; i++) {
if (clients[i] == -1) continue;
ssize_t count = read(clients[i], buffer, BUFFER_SIZE);
if (count > 0) {
// 回显收到的数据
write(clients[i], buffer, count);
} else if (count == 0 || (count == -1 && errno != EAGAIN && errno != EWOULDBLOCK)) {
// 客户端断开或读取错误
printf("Closing connection on fd %d\n", clients[i]);
close(clients[i]);
clients[i] = -1; // 标记该槽位为空
}
}
// 可以在此处添加其他非阻塞逻辑,例如定时任务或其他IO任务
usleep(100000); // 减少CPU占用
}
close(listen_fd);
return 0;
}
IO多路复用
我们发现NIO的模型中,如果我们要知道哪个客户端有新的消息了,我们需要遍历全部的fd,如果fd的数量很多呢? 而且这种忙CPU的做法对资源的消耗也是很大的.于是就有了更先进的 IO多路复用
如何理解IO多路复用
需要我们自己监听FD的过程现在由系统帮我们完成,当有新的可读取的内容时,系统再通知程序处理这些数据
IO多路复用,根据操作系统的不同大致分为 select poll epoll
区别如下:
- select: 只会告诉你有消息来了,但不会告诉你具体是哪个fd的消息,要获取消息还是需要循环全部的fd,并且只能监听1024(默认)个fd
- poll: 解决了监听1024的限制数量,但并没有解决不知道具体是哪个fd有新消息的问题
- epoll: 解决了监听1024的限制数量,并且有新的消息来的时候会告诉我们是哪个fd的消息,但这个是linux操作系统才有的
golang使用多路复用例子
package main
import (
"fmt"
"net"
"os"
)
func handleConnection(conn net.Conn) {
buffer := make([]byte, 1024)
defer conn.Close()
for {
n, err := conn.Read(buffer)
if err != nil {
fmt.Println("Error reading:", err)
return
}
// Echo back the data
fmt.Println("Received:", string(buffer[:n]))
_, err = conn.Write(buffer[:n])
if err != nil {
fmt.Println("Error writing:", err)
return
}
}
}
func main() {
listen, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Error creating listener:", err)
os.Exit(1)
}
defer listen.Close()
fmt.Println("Server is listening on port 8080")
// 接受并处理客户端连接
for {
conn, err := listen.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err)
continue
}
// 为每个连接启动一个新的goroutine进行处理
go handleConnection(conn)
}
}
上面的例子是通过golang使用多路复用的实例代码,可以发现使用都很简单,每一个新的连接对应一个协程(最早是线程,但成本太高了,打开的数量优先,有了协程后,都选用成本更低的协程作为了替代品,这也减少了线程的切换,减少了资源的消耗),所以IO多路复用和协程更配
Nodejs的IO网络模型
Node.js的网络IO模型结合了事件驱动和非阻塞IO,通过Event Loop(事件循环)和libuv库实现对网络操作的高效管理,包括新的连接以及数据的读取和写入 具体的流程:
- 接收请求: 当Node.js服务器启动并监听某个端口时,它准备接收新的连接。当一个新的连接请求到达时,底层的libuv负责处理该连接并分配一个新的文件描述符(fd)来表示这个连接。
- IO多路复用: libuv利用操作系统提供的IO多路复用机制(如Linux上的
epoll、macOS上的kqueue等)来监控这些文件描述符,通过IO多路复用,libuv能有效地管理和监控这些连接,检查哪些连接(即哪些文件描述符)有可读或可写的事件。 - 事件循环: 一旦IO多路复用检测到有文件描述符的数据可以读取了,libuv将会该事件推送到Nodejs的事件循环中,事件循环会处理这个事件关联的回调函数
总结: 这一机制使得Node.js在处理IO操作时非常高效,因为它可以在一个单线程内管理大量的并发连接,而不需要为每个连接创建一个独立的线程。这是Node.js在处理IO密集型任务时表现出色的原因。通过非阻塞IO和事件驱动模型,Node.js可以有效利用CPU资源来处理高并发网络应用
和NodejsIO模型类似的处理还有 Redis
FD
在上文中我们多次提到了FD(文件描述符),那么什么是FD呢 它是计算机操作系统内核为访问文件或其他输入输出资源(如管道、网络套接字等)所提供的一种抽象,提供的一种抽象。文件描述符是一个非负整数,指向内核中的表的一个入口,该表包含了已打开的文件的信息。每个进程有自己的文件描述符表。比如我们的进程打开的第一个文件FD=1, 第二个文件FD=2一样,这个FD指向了一个具体的文件和文件的位置,我们对FD的读写就是对文件的读写
参考
B站视频: www.bilibili.com/video/BV1jK… 比较系统,时长大概2小时多一点
文章: www.51cto.com/article/693… 会涉及到用户态和内核态和数据拷贝的一些问题