Linux阻塞与非阻塞IO

阻塞与非阻塞的区别

  • 阻塞:在对fd执行IO操作时,若不能获得资源,则挂起进程直到满足可操作的条件后再进行操作。被挂起的进程进入睡眠状态,被从调度器的运行队列移走,直到等待的条件被满足
  • 非阻塞:在不能对fd进行IO操作时,并不挂起,它要么放弃,要么不停地查询,直至可以进行操作为止
image-20250322192317171

应用层的代码上两者的区别:

1
2
3
4
5
6
7
// 阻塞IO
char buf;
fd = open("/dev/ttyS1", O_RDWR);
...
res = read(fd,&buf,1);// 只有读到数据了才会返回
if(res==1)
printf("%c\n", buf);
1
2
3
4
5
6
7
8
// 非阻塞IO
char buf;
fd = open("/dev/ttyS1", O_RDWR|O_NONBLOCK);
...
while(read(fd,&buf,1)!=1)
{continue;}
/* 串口上无输入也返回,因此要循环尝试读取串口 */
printf("%c\n", buf);

如果要使用非阻塞IO的话,用open的时候,要加O_NONBLOCK宏,或者在打开了后,用fcntl()改变

驱动层中,如果要支持非阻塞IO,需要在自己实现的xxx_read等IO操作中,对file->f_flags来判断打开这个file时,采用了阻塞IO还是非阻塞,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static ssize_t xxx_write(struct file *file, const char *buffer, size_t count,loff_t *ppos)
{
...
/* 等待设备缓冲区可写 */
do {
avail = device_writable(...); //数据是否可写需要自己定义一个变量来判断
if (avail < 0) {
if (file->f_flags &O_NONBLOCK) {
ret = -EAGAIN;
goto out;
}
}
...
} while (avail < 0);

驱动层实现阻塞IO

我们在应用层对一个文件描述符进行I/O操作时,本质上调用的是该文件类型所对应的file_operations中具体的实现,故我们上面提到的阻塞I/O(数据没就绪时,阻塞该线程)之类的机制,需要我们自己实现

下面举个简单的实现例子:

调用 read()

  • 如果数据不可用,内核将当前线程加入 wait_queue,并将其状态设置为不可调度(TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE)
  • 内核调用 schedule() 让出 CPU 资源,调度器将执行其他线程

数据就绪:

  • 数据准备好后,内核会触发 I/O 事件(比如中断),在其中通过 wake_up() 函数唤醒等待在该 wait_queue 中的所有线程。
  • 线程被唤醒后,其状态被改为 TASK_RUNNING,然后重新被调度器执行

线程被唤醒:

  • 唤醒后的线程重新运行,从之前阻塞的位置继续执行,并完成 read() 调用

等待队列具体API看驱动开发详解的第8章,重要API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 往一个等待队列里插入/删除元素
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
void remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);

// 等待等待队列queue被唤醒,且condition必须为真,不然继续阻塞
wait_event(queue, condition);
wait_event_interruptible(queue, condition);
wait_event_timeout(queue, condition, timeout);
wait_event_interruptible_timeout(queue, condition, timeout);

// 唤醒queue中的所有元素
void wake_up(wait_queue_head_t *queue);
void wake_up_interruptible(wait_queue_head_t *queue);

// 将当前进程的state设为TASK_UNINTERRUPTIBLE,并创建个元素插入等待队列
sleep_on(wait_queue_head_t *q );
interruptible_sleep_on(wait_queue_head_t *q );

image-20250322194151301

  • 由图可见,等待队列里每个元素都对应一个进程/线程

驱动层实现IO多路复用

IO多路复用的定义:Linux内核提供了selectpollepoll这3个系统调用用监控多个fd的某些IO操作(如读、写…)是否准备就绪,如果没有就阻塞当前进程

关于应用层如何使用IO多路复用,看Linux应用编程里的文件IO笔记

要想使设备在应用层支持IO多路复用,那么得在内核中此类文件的file_operations中实现poll成员变量

  • 虽然有3个系统调用,但是只用实现以下这个函数就行了
1
unsigned int(*poll)(struct file * filp, struct poll_table* wait);
  • poll_table:被阻塞的、待唤醒的等待队列需要注册到里面
  • 返回设备资源的可获取状态,即POLLIN、POLLOUT、POLLPRI、 POLLERR、POLLNVAL等宏的位“或”结果。每个宏的含义都表明设备的一种状态,如POLLIN(定义为 0x0001)意味着设备可以无阻塞地读,POLLOUT(定义为0x0004)意味着设备可以无阻塞地写在

在驱动层对poll的实现中,一般有以下2个重要步骤:

  • 1.检查文件状态:根据poll_tablekey检查文件描述符的状态
  • 2.加入等待队列:如果文件描述符未就绪,调用 poll_wait 函数将当前进程加入等待队列。poll_wait 是内核提供的一个宏,用于简化等待队列的管理
1
void poll_wait(struct file *filp, wait_queue_heat_t *queue, poll_table * wait)

作用:将当前进程加入文件描述符的等待队列中,以便在文件描述符就绪时(例如,数据可读或可写)能够唤醒该进程

驱动层实现poll的一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
unsigned int my_poll(struct file *filp, struct poll_table *wait) {
unsigned int mask = 0;

// 将当前进程加入等待队列
poll_wait(filp, &my_wait_queue, wait);

// 检查文件状态
if (data_available()) {
mask |= POLLIN | POLLRDNORM; // 数据可读
}
if (space_available()) {
mask |= POLLOUT | POLLWRNORM; // 数据可写
}

return mask;
}