IO多路复用

1, 管道

1.1.1 管道概述

Linux中的命名管道 (named pipe) 是一种特殊类型的文件,又称为FIFO,它外的外在表现好像是一个磁盘文件,实际上本质只是一个数据流或缓冲区。管道允许一个进程的输出直接作为另一个进程的输入,在Linux系统中是一种重要的实现进程间通信(IPC)的机制。

  • 除了命名管道以外, 还有一中用于进程间通信的匿名管道。(后面再讲)

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

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

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

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

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

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

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

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

1.1.2 创建管道文件

命名管道创建的管道文件不是普通的磁盘文件, 所以创建的方式也和普通文件创建方式不同。

  • 通过mkfifo name.pipe创建管道文件

  • 不允许使用vim打开管道文件

1.1.3 使用管道

直接使用

  • 假设先创建一个管道: mkfifo 1.pipe

  • 通过echo hello > 1.pipe向管道缓冲区写入数据, 我们会发现这个写入操作会直接写阻塞; 这是因为使用命名管道 , 是从一端读取数据, 从另一端写入数据, 必须读端写端同时打开, 无法单独去打开读端或者写端。

  • 当我们另开一个窗口(新的进程)使用cat 1.pipe读取管道数据, 相当于开启了管道的读端, 这可以使之前打开的写端正常写入, 从阻塞态变为非阻塞态写入; 等写端写入完成之后, 读端读出写入数据。

在代码中使用管道

EgCode: 写端

EgCode: 读端

1.1.4 通信的方式

在通信系统中,根据数据传输的方向和方式,通信可以被分为单工、双工和半双工三种模式

通信方式描述
单工通信永远只能由一方向另一方发送数据
半双工通信双方都可以收发数据, 但是在同一时刻只能一端发另一端收
全双工通信两端可以同时收发数据

EgCode: 命名管道是半双工通信

1.1.5 管道写阻塞

假设我们通过pipe实现一个通信, 写端一直在写, 读端不读, 会发生什么?

EgCode:写端

EgCode:读端

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

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

1.1.6 管道构建双工通信

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

创建两个pipe文件:

EgCode: User_A

EgCode: User_B

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

  • 1.pipe和2.pipe在A和B中的打开顺序, 有可能导致产生竞争条件导致死锁。

  • 上面的对答流程属于一问一答式的模式, 需要由A发起聊天。连续发送多条数据对面无法立即显示。

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

2, IO多路复用

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

IO多路复用

操作系统允许单个进程或线程同时监视多个文件描述符的一种技术。当其中的一个或多个文件状态变为非阻塞状态(例如: 文件由阻塞态, 变得可读、可写或有异常待处理)时,该进程或线程会收到一个对应通知。而我们的逻辑中收到这个通知, 就可以根据对应变得可读的文件描述符处理是读取标准输入, 还是接收对端数据, 还是都处理

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

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

2.1 Select

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

2.1.1 Select函数

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

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

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

2.1.2 代码示例

EgCode:

  • UserA 和UserB代码除了打开pipe的顺序, 和确定pipe的读端写端以外, 其余的通信逻辑是相同的。

2.1.3 超时设置

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

  • 如果设置了阻塞时间, 可以通过select的返回值确定到底之超时返回,还是就绪返回。

  • timeout会随着select的阻塞时间而递减。如果因为就绪事件导致select返回, timeout留下的是剩余时间。

练习

设计一份代码:当UserA用户10秒未发生信息, UserB关闭连接。

思考: 如果在多人聊天的情况下, 上述实现有什么缺陷?

改进: 假设使用time_t计时可以实现吗? 有什么问题?

2.1.5 写就绪: 了解

基于上面的写满情况, 利用select监听其写就绪功能。

EgCode: 读端

EgCode: 写端

2.1.6 补充: 重要

select底层顺序

  • 创建监听集合fd_set, 并初始化

  • 把要监听的文件描述符加到fd_set集合中

  • 调用select开始监听

  • select函数, 把处于用户态的监听集合拷贝到内核态空间

  • 内核进程根据拷贝到内核态的监听集合, 轮询访问文件描述符对象, 监听状态变化 (轮询范围,根据select的最大文件描述符参数)

  • 在一次轮询过程中, 发现有文件状态就绪, 把就绪状态文件描述符放回拷贝到内核态的监听集合中, 并触发select结束阻塞

  • 把内核态的存储就绪的文件描述符集合, 拷贝回用户态

  • select结束

select的缺陷

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

  • 监听集合和就绪集合, 需要反复从内核态空间和用户态空间来回拷贝(再需要循环监听的时候: 还需要反复设置监听集合)

  • 监听和就绪不分离, 每次需要重置监听集合

  • 不适合海量监听, 少量就绪的情况 (需要遍历每个被监听的文件描述符, 确定是否就绪)

练习

编写程序A和B。A负责将文件的名字、长度和内容通过管道发送B,B需要新建一个目录/文件,并将该文件存储起来。

EgCode: A端

EgCode: B端