管道、IO多路复用

命名管道

特性命名管道(Named Pipe)匿名管道(Anonymous Pipe)
标识方式文件系统中的特殊文件(如 /tmp/my_pipe仅存在于内存,无文件系统路径
进程关系可被任意进程访问(需权限)仅限父子进程同源进程
生命周期持久化(除非显式删除)随进程结束自动销毁
跨主机通信支持(如Windows的SMB协议)不支持

Linux中的命名管道 (named pipe) 是一种特殊类型的文件,又称为FIFO

它外的外在表现好像是一个磁盘文件,实际上本质只是一个数据流或缓冲区。

管道允许一个进程的输出直接作为另一个进程的输入,在Linux系统中是一种重要的实现进程间通信(IPC)的机制。

命名管道创建的管道文件不是普通的磁盘文件。这个文件本身不存储数据(它不占用磁盘空间来存储长期数据),而是作为一个通信接口存在 (它是一个文件系统标识, 方便我们以操作文件系统的方式读写管道的缓冲区)。

命名管道, 从一端读取数据, 从另一端写入数据。使用命名管道, 必须读端写端同时打开, 无法单独去打开读端或者写端。

命名管道是一种半双工通信, 但是我们一般把它当作单工管道使用( 即: 一个进程只打开它的写端写数据, 另一个进程只打开它的读端读数据)。

当管道缓冲区无数据, 读管道的操作将被阻塞。

当管道缓冲区写满, 写管道将被阻塞。

在管道操作中, 如果写端关闭, 读端可以继续读取缓冲区的剩余数据, 如果缓冲区没有剩余数据, 读取操作(read)直接返回0。

在管道操作中, 如果读端关闭, 写端继续向管道中写数据的时候, 会导致写操作(write)触发SIGPIPE信号, 进而导致进程异常终止。

一般我们会使用两根管道构建两个进程间的全双工通信。

如何创建管道?

  1. 通过echo hello > 1.pipe向管道缓冲区写入数据, 我们会发现这个写入操作会直接写阻塞; 这是因为使用命名管道 , 是从一端读取数据, 从另一端写入数据, 必须读端写端同时打开, 无法单独去打开读端或者写端。
  2. 当我们另开一个窗口(新的进程)使用cat 1.pipe读取管道数据, 相当于开启了管道的读端, 这可以使之前打开的写端正常写入, 从阻塞态变为非阻塞态写入; 等写端写入完成之后, 读端读出写入数据。

当然也可以通过代码实现

写端

#include <header.h>

int main(int argc,char*argv[]){
    int fd_write = open("1.pipe", O_WRONLY);
    char buf[4096] = {0};
    int times = 0;
    while(1){
      ssize_t  ret= read(STDIN_FILENO,buf,4096);  
        write(fd_write, buf, ret);
    }
    close(fd_write);
    return 0;
}

读端

#include <header.h>

int main(int argc,char*argv[]){
    int fd_read = open("1.pipe", O_RDONLY);
    char buf[4096] = {0};                             
    while(1){
       ssize_t ret= read(fd_read, buf, sizeof(buf));
       if(ret!=0){
        printf("read:%s \n",buf);}
    }

    close(fd_read);
    return 0;
}

假设我们通过pipe实现一个通信, 写端一直在写, 读端不读, 会发生什么? 会发生写阻塞; 因为管道的大小是有限制的。

管道的真实可用缓冲区的大小取决于单个单个管道缓冲区大小管道缓冲区的数目共同决定。而且在不同的操作系统设置是不一样的。

ulimit -a

512 bytes:表示单个数据块的单位大小。

8:表示块数量,因此总缓冲区大小为 512 * 8 = 4096 bytes(4KB)。

 我们也可以从操作系统的源码中查看, 管道缓冲区个数为16

假设我们创建一个1.pipe的管道, 我们在不读的情况下可以连续写入16个4096, 即可把管道写满, 再试图写就会写阻塞; 写满之后, 假设又读出4096字节,我们可以再接着写4096个字节

 

现在我们有一份新的需求,实现一份代码, 让两个程序基于基于pipe管道的双工通信。工作流程图如下:

A程序 

int main() {
    int fd_write = open("1.pipe", O_WRONLY);
    int fd_read = open("2.pipe", O_RDONLY);

    while (1) {
        // 先读取标准输入并发送
        char buf[60];
        bzero(buf, sizeof(buf));
        int read_stdin = read(STDIN_FILENO, buf, sizeof(buf));
        if (read_stdin == 0) break;
        write(fd_write, buf, read_stdin);  // 仅写入实际读取的字节数

        // 然后读取对方回复
        bzero(buf, sizeof(buf));
        int read_num = read(fd_read, buf, sizeof(buf));
        if (read_num == 0) {
            printf("对方断开链接\n");
            break;
        }
        printf("UserB: %s", buf);  // 假设对方发送的数据已包含换行
    }

    close(fd_write);
    close(fd_read);
    return 0;
}

 B程序

int main() {
    int fd_read = open("1.pipe", O_RDONLY);
    int fd_write = open("2.pipe", O_WRONLY);

    while (1) {
        // 先读取对方消息
        char buf[60];
        bzero(buf, sizeof(buf));
        int read_num = read(fd_read, buf, sizeof(buf));
        if (read_num == 0) {
            printf("对方断开链接\n");
            break;
        }
        printf("UserA: %s", buf);

        // 然后读取标准输入并回复
        bzero(buf, sizeof(buf));
        int read_stdin = read(STDIN_FILENO, buf, sizeof(buf));
        if (read_stdin == 0) break;
        write(fd_write, buf, read_stdin);
    }

    close(fd_write);
    close(fd_read);
    return 0;
}

在上面的代码过程中, 我们需要注意一些问题:

  1. 1.pipe和2.pipe在A和B中的打开顺序, 有可能导致产生竞争条件导致死锁。
  2. 上面的对答流程属于一问一答式的模式, 需要由A发起聊天。连续发送多条数据对面无法立即显示。
  3. 你要先创建1.pipe和2.pipe

上面流程明显不符合实际生活对话流程, 该怎么改进这个过程?

上面一问一答式的对话流程, 是因为在上面代码执行中, 它是一个串行执行的逻辑, 如果模拟现实情况, 我们更希望要做的是怎么把这个串行逻辑改为并行逻辑。这里我们就可以用到IO多路复用技术。

IO多路复用

操作系统允许单个进程或线程同时监视多个文件描述符的一种技术。

当其中的一个或多个文件状态变为非阻塞状态(例如: 文件由阻塞态, 变得可读、可写或有异常待处理)时,该进程或线程会收到一个对应通知。

而我们的逻辑中收到这个通知, 就可以根据对应变得可读的文件描述符处理是读取标准输入, 还是接收对端数据, 还是都处理。

这样, 就允许程序同时处理多个文件就绪, 或者称为谁就绪就处理谁,而不是只能按照固定处理顺序处理每一个任务,从而提高效率。

select函数

select是实现IO多路复用的一种方式。(其他的还有poll, epoll)

select的最基本的原理, 就是把要监视的文件描述符, 构建一个文件描述符监听集合。这个集合交给select, select促使操作系统内核, 通过轮询的方式监听这个文件描述符集合。直到监听集合中, 至少有一个文件按照条件就绪(条件:预设的监听是读就绪OR写就绪...), 这一次的select监听宣告结束, 并携带就绪的文件描述符集合返回, 继续执行用户书写的代码逻辑。

select是一个在Unix系统中就已经出现了的, 传统的IO多路复用接口。(man select)

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
// synchronous I/O multiplexing
// 同步      I/O    多路复用
int select(
    int nfds, // 被监听文件描述符集合最大的文件描述符+1 (最大的文件描述符+1)
    fd_set *readfds, // 要监听的: 读操作文件描述符集合
    fd_set *writefds, // 要监听的: 写操作文件描述符集合
    fd_set *exceptfds, // 要监听的: 异常操作文件描述符集合
    struct timeval *timeout // 监听时候的阻塞时间:NULL代表一直等待直到指定就绪,0代表不等待检查文件描述符立即返回
);
// 返回值: 正数表示就绪的文件描述符数量; 0表示监听超时; -1表示是失败
// 构建监听文件描述符:
void FD_ZERO(fd_set *set); // 初始化文件描述符集合
void FD_SET(int fd, fd_set *set); // 向文件描述符集合添加要监听的文件描述符
void FD_CLR(int fd, fd_set *set); // 从文件描述符集合移除一个文件描述符,不再监听移除项
int  FD_ISSET(int fd, fd_set *set); // 判断某个文件描述符, 是否在文件描述符集合中

调用select之后, select会阻塞进程, 去监听设置的文件描述符状态; 直到监听到至少一个文件描述符就绪, select解除阻塞状态, 并携带就绪的文件描述符返回。

监听集合和监听完毕之后携带的就绪集合, 是同一个fd_set存储。(传入传出参数, 非const指针) (意味着在循环中, 每次都要重置监听集合set)

我们可以在select函数中设置指定的时间, 来限制select的阻塞时间。

// (man select )
struct timeval {
    long    tv_sec;         // seconds: 秒
    long    tv_usec;        // microseconds: 微秒 (1秒 = 1000 000微秒)
};

select底层顺序

  1. 创建监听集合fd_set, 并初始化
  2. 把要监听的文件描述符加到fd_set集合中
  3. 调用select开始监听
  4. select函数, 把处于用户态的监听集合拷贝到内核态空间
  5. 内核进程根据拷贝到内核态的监听集合, 轮询访问文件描述符对象, 监听状态变化 (轮询范围,根据select的最大文件描述符参数)
  6. 在一次轮询过程中, 发现有文件状态就绪, 把就绪状态文件描述符放回拷贝到内核态的监听集合中, 并触发select结束阻塞
  7. 把内核态的存储就绪的文件描述符集合, 拷贝回用户态
  8. select结束

select的缺陷

// ps: 了解
// 虽然我们通过ulimit -a看到的open files 大小因为是1024, 意味着操作系统的一个进程可以打开的文件对象也是1024, 看上去和select监听最大范围相匹配, 但是进程可以打开的文件对象是可以修改的, 而select监听的最大文件描述符为1024是没办法修改的(要想修改只能重新编译操作系统了)

// 我们可以找到fd_set类型, 进而确定它的监听的最大文件描述符为1024 
    gcc -E 文件.c -o 文件.i
    vim 文件.i
    // 查找: fd_set
 typedef struct{
     __fd_mask __fds_bits[1024 / (8 * (int) sizeof (__fd_mask))];
 } fd_set;
  1. 监听集合和就绪集合, 需要反复从内核态空间和用户态空间来回拷贝(再需要循环监听的时候: 还需要反复设置监听集合)
  2. 监听和就绪不分离, 每次需要充值监听集合
  3. 不适合海量监听, 少量就绪的情况 (需要遍历每个被监听的文件描述符, 确定是否就绪)

select监听的最大文件描述符为1024 (靠位图实现)

UserA:

int main(int argc,char*argv[]){
    int fd_write = open("1.pipe", O_WRONLY);
    int fd_read = open("2.pipe", O_RDONLY);
    char buf[60];
    // 监听集合
    fd_set set;

    while(1){
        // 初始化集合
        FD_ZERO(&set);
        // 添加要监听的文件描述符
        FD_SET(STDIN_FILENO, &set);
        FD_SET(fd_read, &set);

        struct timeval time_val;
        time_val.tv_sec = 10;
        time_val.tv_usec = 0;

        // 调用select: 监听就绪
        int res_select = select(10, &set, NULL,NULL, &time_val);

        // 打印剩余时间
        printf("tv_sec: %ld \n", time_val.tv_sec);

        if(FD_ISSET(fd_read, &set)){
            bzero(buf, sizeof(buf));
            int read_num = read(fd_read, buf, sizeof(buf));
            if(read_num == 0){
                printf("对方断开链接 \n");
                break;
            }
            printf("UserA: %s", buf);
        }
        if(FD_ISSET(STDIN_FILENO, &set)){
            bzero(buf, sizeof(buf));
            int read_stdin = read(STDIN_FILENO, buf, sizeof(buf));
            if(read_stdin == 0){
                // 用户按下ctrl+d; 输入文件终止符; 终止标准输入; read返回0
                break;
            }
            write(fd_write, buf, sizeof(buf));
        }
    }
    close(fd_write);
    close(fd_read);
    return 0;
}

UserB:

当UserA用户10秒未发生信息, UserB关闭连接。

int main(int argc,char*argv[]){
    int fd_read = open("1.pipe", O_RDONLY);
    int fd_write = open("2.pipe", O_WRONLY);
    char buf[60];

    fd_set set;

    struct timeval time_val;
    time_val.tv_sec = 10;
    time_val.tv_usec = 0;
    while(1){
        FD_ZERO(&set);
        FD_SET(STDIN_FILENO, &set);
        FD_SET(fd_read, &set);

        int res_select = select(10, &set, NULL, NULL, &time_val);

        if(FD_ISSET(fd_read, &set)){
            bzero(buf, sizeof(buf));
            int read_num = read(fd_read, buf, sizeof(buf));
            if(read_num == 0){
                printf("对方断开链接 \n");
                break;
            }
            printf("UserA: %s", buf);

            time_val.tv_sec = 10;
            time_val.tv_usec = 0;
        }else{
            if(time_val.tv_sec <= 0){
                printf("对方十秒未说话 \n");
                break;
            }
        }
        if(FD_ISSET(STDIN_FILENO, &set)){
            bzero(buf, sizeof(buf));
            int read_stdin = read(STDIN_FILENO, buf, sizeof(buf));
            if(read_stdin == 0){
                // 用户按下ctrl+d; 输入文件终止符; 终止标准输入; read返回0
                break;
            }
            write(fd_write, buf, sizeof(buf));
        }
    }
    close(fd_write);
    close(fd_read);
    return 0;
}

匿名管道

匿名管道/pipe函数是Linux系统中用于实现父子进程间通信的一种简单方式。 (man pipe)

  1. 管道是单向的,数据从一端流入,从另一端流出,因此它们是半双工的。(这就意味着: 使用匿名管道,一个进程将数据写入管道pipe[1],而另一个进程则可以从管道读取数据pipe[0])
  2. 匿名管道只使用于存在亲缘关系的进程之间进行通信(Eg: 父子进程)。
  3. 先在一个进程中创建一个管道,然后再利用fork, 可以让父子进程同时持有管道, 就可以实现进程间通信了。(单个进程的管道的是没有什么价值的)
  4. 如果要实现父子进程之间全双工通信,需要调用pipe函数两次来创建两条管道。而且, fork的次数和管道的数量无关,每一次使用 pipe函数就会在内核创建一个管道缓冲区。

此函数的声明如下:

#include <unistd.h>
// create pipe
int pipe(
    int pipefd[2]//包含两个文件描述符的整型数组。pipefd[0]读取端文件描述符,pipefd[1]写端文件描述符。
);
// 返回值: 成功0, 失败-1

实现原理图如下图所示:

image-20250225212819848

#include <unistd.h>    // 提供 pipe(), fork(), read(), write(), close() 等系统调用
#include <stdio.h>     // 提供 printf() 函数
#include <sys/wait.h>  // 提供 wait() 函数

int main(int argc, char* argv[]) {
    // 1. 创建管道
    int pipefd[2];           // pipefd[0]是读端,pipefd[1]是写端
    pipe(pipefd);            // 创建无名管道,成功返回0,失败返回-1

    // 2. 创建子进程
    if (fork() == 0) {       // 子进程进入该分支(fork返回0)
        // 子进程:负责从管道读取数据
        close(pipefd[1]);    // 关闭写端(子进程只需要读)
        
        char buf[5];         // 定义缓冲区
        read(pipefd[0], buf, 5);  // 从管道读取5字节数据(阻塞直到有数据)
        
        printf("child get message: %s \n", buf);  // 打印读取的数据
    } else {                // 父进程进入该分支(fork返回子进程PID)
        // 父进程:负责向管道写入数据
        close(pipefd[0]);    // 关闭读端(父进程只需要写)
        
        write(pipefd[1], "hello", 5);  // 向管道写入5字节数据("hello")
        wait(NULL);          // 等待子进程结束(避免僵尸进程)
    }
    
    return 0;  // 父子进程都会执行(但子进程在printf后已退出)
}

IO 多路复用 (I/O Multiplexing) 是一种允许多个 I/O 流共享同一个线程的技术,在网络编程中有重要作用。 ### 原理 IO 多路复用的原理是通过一个机制,让单线程可以同时监听多个文件描述符(如网络连接、文件、管道等)。程序监视多个文件描述符(通常是套接字),等待其中一个或多个文件描述符变为就绪状态。一旦某个文件描述符就绪,即该文件描述符上可以进行无阻塞读写操作,操作系统就会通知应用程序,然后应用程序对该文件描述符进行相应的读写操作,避免了线程阻塞在单个 I/O 操作上[^2][^3]。 ### 实现方式 常见的IO多路复用实现方式有 select、poll 和 epoll。虽然引用中未详细提及这些实现方式,但以下是它们的简单介绍: - **select**:它是最早的 IO 多路复用实现方式,通过一个 **fd_set** 数据结构来管理多个文件描述符,调用 select 函数时会将该数据结构从用户空间复制到内核空间,内核检查这些文件描述符的状态并返回就绪的文件描述符数量。不过它有文件描述符数量限制(一般为1024),每次调用都需要重新设置 **fd_set**。 ```python import select import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('localhost', 8888)) server.listen(5) inputs = [server] while True: readable, _, _ = select.select(inputs, [], []) for sock in readable: if sock is server: conn, addr = server.accept() inputs.append(conn) else: data = sock.recv(1024) if data: # 处理数据 pass else: inputs.remove(sock) sock.close() ``` - **poll**:它和 select 类似,但使用 **pollfd** 结构体数组来管理文件描述符,没有文件描述符数量的限制,不过仍然需要将数据从用户空间复制到内核空间。 ```c #include <stdio.h> #include <stdlib.h> #include <sys/poll.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #define MAX_EVENTS 10 #define BUFFER_SIZE 1024 int main() { int server_fd, new_socket; struct sockaddr_in address; int opt = 1; int addrlen = sizeof(address); char buffer[BUFFER_SIZE] = {0}; // 创建套接字 if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { perror("socket failed"); exit(EXIT_FAILURE); } // 设置套接字选项 if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) { perror("setsockopt"); exit(EXIT_FAILURE); } address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; address.sin_port = htons(8888); // 绑定套接字 if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) { perror("bind failed"); exit(EXIT_FAILURE); } // 监听连接 if (listen(server_fd, 3) < 0) { perror("listen"); exit(EXIT_FAILURE); } struct pollfd fds[MAX_EVENTS]; fds[0].fd = server_fd; fds[0].events = POLLIN; for (int i = 1; i < MAX_EVENTS; i++) { fds[i].fd = -1; } while (1) { int ready = poll(fds, MAX_EVENTS, -1); if (ready == -1) { perror("poll"); exit(EXIT_FAILURE); } for (int i = 0; i < MAX_EVENTS; i++) { if (fds[i].fd == -1) continue; if (fds[i].revents & POLLIN) { if (fds[i].fd == server_fd) { if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) { perror("accept"); exit(EXIT_FAILURE); } for (int j = 1; j < MAX_EVENTS; j++) { if (fds[j].fd == -1) { fds[j].fd = new_socket; fds[j].events = POLLIN; break; } } } else { int valread = read(fds[i].fd, buffer, BUFFER_SIZE); if (valread <= 0) { close(fds[i].fd); fds[i].fd = -1; } else { // 处理数据 printf("Received: %s\n", buffer); } } } } } return 0; } ``` - **epoll**:它是 Linux 特有的高效 IO 多路复用机制,通过 **epoll_ctl** 函数来管理文件描述符,使用事件驱动的方式,内核会在文件描述符就绪时通过回调机制通知应用程序,避免了大量的文件描述符复制和遍历操作,性能较高,适合处理大量并发连接。 ```python import socket import select server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('localhost', 8888)) server.listen(5) epoll = select.epoll() epoll.register(server.fileno(), select.EPOLLIN) try: while True: events = epoll.poll() for fileno, event in events: if fileno == server.fileno(): conn, addr = server.accept() epoll.register(conn.fileno(), select.EPOLLIN) else: sock = socket.fromfd(fileno, socket.AF_INET, socket.SOCK_STREAM) data = sock.recv(1024) if data: # 处理数据 pass else: epoll.unregister(fileno) sock.close() finally: epoll.unregister(server.fileno()) epoll.close() server.close() ``` ### 应用场景 - **高并发网络服务器**:如 Web 服务器、聊天服务器等,需要同时处理大量客户端连接,IO 多路复用可以让单线程处理多个连接,减少线程创建和切换的开销,提高服务器的并发处理能力。 - **实时监控系统**:监控多个设备或数据源的状态变化,当某个数据源有数据更新时及时处理。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值