[内核学习]阻塞与非阻塞IO

/ 0评 / 0赞

read() 打到驱动上只有两种态度:有数据就拿走,没数据要么告诉我没有(-EAGAIN),要么睡到有为止.听起来简单,但"睡得下去,醒得过来"八个字里埋着内核最经典的一类竞态,丢失唤醒.这篇把这条链拆开:

先看 O_NONBLOCK 怎么落地.找个真实驱动,drivers/input/evdev.c 的 evdev_read(事件设备,cat /dev/input/event0 就是它),精简后:

看真实驱动,drivers/input/evdev.c 的 evdev_read(事件设备,cat /dev/input/event0 就是它),精简后:

for (;;) {
	if (!evdev->exist || client->revoked)
		return -ENODEV;

	if (client->packet_head == client->tail &&
	    (file->f_flags & O_NONBLOCK))
		return -EAGAIN;			// 非阻塞:没数据立刻表态

	/* ...拷数据,拷到就 break 返回... */

	if (!(file->f_flags & O_NONBLOCK)) {
		error = wait_event_interruptible(client->wait,
				client->packet_head != client->tail ||
				!evdev->exist || client->revoked);
		if (error)
			return error;
	}
}

三个语义要点.O_NONBLOCK 是提示不是错误,没数据返回 -EAGAIN,POSIX 的约定,libc 翻译成 EAGAIN,用户态 open 时的标志就躺在 file->f_flags 里.阻塞版不是 while(1) 空转,一句 wait_event_interruptible() 睡过去,注意条件里有三个,除了有数据还有"设备没了"和"权限被收了",睡眠条件必须包含所有该醒的理由,不然设备拔了进程还挂着,用户该骂街了.外层 for(;;) 是等待队列的标准姿势:唤醒只是提示你该查条件了,不是数据已经在你手里.

同一个文件的 evdev_poll 也挂在同一个 client->wait 上(poll_wait),epoll 那套复用的就是这条队列,不是另起炉灶.

再看等待队列本身长什么样.数据结构小到离谱:

struct wait_queue_head {
	spinlock_t		lock;
	struct list_head	head;
};

struct wait_queue_entry {
	unsigned int		flags;		// WQ_FLAG_EXCLUSIVE 等
	void			*private;	// 睡觉的 task_struct
	wait_queue_func_t	func;		// 被唤醒时执行的回调
	struct list_head	entry;
};

一个自旋锁一个链表,每个睡着的进程挂一个 entry,entry 里最值钱的是 func 回调:唤醒不是改个状态那么简单,不同场景要干不同的事,唤醒是按回调分发的.历史上这个结构有过 task_struct 直接挂队的年代,后来为了 poll 复用和回调灵活性拆成了 entry.驱动侧一般 DECLARE_WAIT_QUEUE_HEAD() 静态声明,或在设备结构体里嵌一个再 init_waitqueue_head().

接下来把 wait_event() 宏展开看看到底干了什么.wait_event_interruptible(wq, cond) 这类宏全部落到同一个核心宏 ___wait_event(wait.h:302):

wait_event_interruptible(wq, cond) 这类宏全部落到同一个核心宏 ___wait_event(wait.h:302):

#define ___wait_event(wq_head, condition, state, exclusive, ret, cmd)	
({									
	struct wait_queue_entry __wq_entry;				
	long __ret = ret;						
									
	init_wait_entry(&__wq_entry, exclusive ? WQ_FLAG_EXCLUSIVE : 0);
	for (;;) {							
		long __int = prepare_to_wait_event(&wq_head, &__wq_entry, state);
									
		if (condition)						
			break;						
									
		if (___wait_is_interruptible(state) && __int) {		
			__ret = __int;					
			goto __out;					
		}							
									
		cmd;	/* 通常是 schedule() */				
									
		if (condition)						
			break;						
	}								
	finish_wait(&wq_head, &__wq_entry);				
__out:	__ret;								
})

手把手过一遍.init_wait_entry 初始化栈上的 entry,回调装默认的 autoremove_wake_function.prepare_to_wait_event 把自己挂进队列,并把 current 状态设为 state(TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE),返回值非零表示有信号待处理.然后查条件,注意顺序:先把自己挂上钩再查条件,这个顺序是本篇主菜,下面专门讲.接着 schedule() 主动让位,当前任务因为状态不是 TASK_RUNNING 不会被放回运行队列,这就是"睡着"的全部含义:睡眠不是特殊动作,只是把自己标记为不可运行然后让位.被唤醒后 schedule 返回,再查一次条件,不满足就转回去再睡,因为被叫醒不代表条件成立,可能是信号可能是虚假唤醒.finish_wait 把状态设回 TASK_RUNNING,把自己摘出队列.

容易看漏的点:entry 是栈上的局部变量.这能成立是因为任务睡着时栈活着,而 wake 一侧保证不碰已 finish 的 entry,靠的是 autoremove 回调在唤醒成功时顺手摘链.什么时候摘,是等待队列正确性的关键部分.

睡下去的问题解决了,看怎么叫醒人.wake_up_interruptible(&w->wait) 展开成 __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL),核心是 wait.c 的 __wake_up_common:

wake_up_interruptible(&w->wait) 展开成 __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL),核心是 wait.c 的 __wake_up_common:

list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
	unsigned flags = curr->flags;
	int ret;

	ret = curr->func(curr, mode, wake_flags, key);	// 调每个 entry 的回调
	if (ret < 0)
		break;
	if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
		break;
}

默认回调链:autoremove_wake_function,唤醒成功先把自己摘出队列,然后 default_wake_function(kernel/sched/core.c)调 try_to_wake_up,三万行调度器的正门,第6章细讲.

先看丢失唤醒的时序,朴素写法(先查条件再睡)会出事:

朴素写法(先查条件再睡)会出事:

读者                          写者
cond = false
if (cond) ... 没进            cond = true
set_state(INTERRUPTIBLE)      wake_up()  // 队列空的,白叫
schedule()  // 睡死

写者的 wake_up 落在读者"查完条件"和"真正睡着"之间,叫了个寂寞,读者永远睡下去.修法就是把顺序反过来,prepare_to_wait_event 干的就是这件事(wait.c:290):

	spin_lock_irqsave(&wq_head->lock, flags);
	if (signal_pending_state(state, current)) {
		list_del_init(&wq_entry->entry);
		ret = -ERESTARTSYS;
	} else {
		if (list_empty(&wq_entry->entry))
			...	/* 入队 */
		set_current_state(state);	// 带全屏障的状态写入
	}
	spin_unlock_irqrestore(&wq_head->lock, flags);

入队和设状态都发生在这把锁里,而 __wake_up_common 遍历队列持的是同一把锁.于是:写者先拿到锁,读者的条件检查发生在 prepare 之后,能读到新值,第一轮循环就 break 压根不睡;读者先拿到锁,写者的 wake_up 必然看到队列里的 entry,就算读者的 schedule 还没执行,state 已经是 INTERRUPTIBLE,try_to_wake_up 会把它拉回 RUNNING,就算已经睡死也会被正常唤醒后从 schedule 返回重查条件.set_current_state 不是写个标志那么简单,内部是 smp_store_mb,带全内存屏障,保证状态变化对唤醒侧的可见顺序.一句话:睡眠的正确性 = 锁 + 状态写入的屏障语义 + 醒来后重查条件,三件套缺一不可.

唤醒还有个惊群问题要单独说说.wake_up() 展开的 nr_exclusive = 1 就是对策.N 个进程阻塞在同一个队列上(典型:socket accept),一个连接到来:

wake_up() 展开的 nr_exclusive = 1 是惊群对策.N 个进程阻塞在同一个队列上(典型:socket accept),一个连接到来:非 exclusive 等待者全部唤醒,对"状态变了大家都该重查"的场景是对的;exclusive 等待者只唤醒 nr_exclusive 个(默认 1 个),其余接着睡,数据只有一份,叫醒一窝人一个拿到其余再睡回去,纯浪费,高并发下就是惊群事故.实现上很朴素:add_wait_queue_exclusive 把 entry 挂队尾(非 exclusive 挂队头),唤醒循环从头扫,撞到第 1 个"回调成功且带 EXCLUSIVE"的 entry 就停.终端,socket 这些高并发读的驱动全靠这个细节保吞吐,自己写驱动处理"一份资源 N 个等"时记得 wait_event_interruptible_exclusive 一族.

最后是信号的问题,两种睡眠状态的区别就在这.

两种睡眠状态的区别在信号.TASK_UNINTERRUPTIBLE(wait_event):信号叫不醒,拔 NFS 网线后进程挂 D 状态,kill -9 都没用,就是这个睡眠,适合"醒来也没用"的场景.TASK_INTERRUPTIBLE(wait_event_interruptible):信号能打断,醒来后返回 -ERESTARTSYS 交给上层.

-ERESTARTSYS 是内核内部错误码,不会漏到用户态.系统调用出口会看:如果调用带自动重启语义且信号没被递送,把 PC 回拨到 SVC 指令重来一遍,否则翻译成 EINTR.所以驱动作者只管老实返回 -ERESTARTSYS,别自己返回 -EINTR,那是用户态词汇表.中间态还有 TASK_KILLABLE(wait_event_killable),只有致命信号能打断,给"能睡很久但用户 Ctrl+C 应该能解脱"的场景用.

纸上谈兵到此,写个真设备上板.二十几行的 sleeper:write 投一张票,read 阻塞等票:

纸上谈兵到此,写个真设备上板.二十几行的 sleeper:write 投一张票,read 阻塞等票:

static int tickets;
static DECLARE_WAIT_QUEUE_HEAD(sleeper_wq);

static ssize_t sleeper_read(struct file *f, char __user *buf, size_t n, loff_t *off)
{
	if ((f->f_flags & O_NONBLOCK) && tickets == 0)
		return -EAGAIN;

	if (wait_event_interruptible(sleeper_wq, tickets > 0))
		return -ERESTARTSYS;

	tickets--;
	if (copy_to_user(buf, "T", 1))
		return -EFAULT;
	return 1;
}

static ssize_t sleeper_write(struct file *f, const char __user *buf, size_t n, loff_t *off)
{
	tickets += n;
	wake_up_interruptible(&sleeper_wq);
	return n;
}

板上(RK3399, 6.18.56)四组测试,全是真输出:

1) 非阻塞没票

$ /tmp/ticket nb
read=-1 errno=11 (Resource temporarily unavailable)

-EAGAIN,errno 11,教科书行为.

2) 阻塞读,3 秒后另一个终端投一张票

$ ( /tmp/ticket & TP=$!; sleep 3; echo -n T > /dev/sleeper0; wait $TP )
read=1 errno=0 (ok)

睡了 3 秒,wake_up_interruptible 叫醒,拿到票.睡眠和唤醒链路通了.

3) 阻塞读,被 SIGINT 打断

这里翻出了有意思的东西.教科书说"信号打断返回 EINTR",实测不是.先看内核栈,睡眠中的任务 /proc/PID/stack:

[<0>] sleeper_read+0x110/0x198 [sleeper]
[<0>] vfs_read+0xa8/0x314
[<0>] ksys_read+0x70/0x108
[<0>] __arm64_sys_read+0x1c/0x34
[<0>] invoke_syscall.constprop.0+0x4c/0xc0
[<0>] do_el0_svc+0x40/0xc0
[<0>] el0_svc+0x40/0x180
[<0>] el0t_64_sync_handler+0xa0/0xf0
[<0>] el0t_64_sync+0x198/0x19c

睡在 sleeper_read,下面一整串就是上一篇讲的异常入口链,实机又印了一遍.kill -INT 之后再看:

$ kill -INT $TP; sleep 0.5
$ head -2 /proc/$TP/stack
[<0>] sleeper_read+0x110/0x198 [sleeper]
[<0>] vfs_read+0xa8/0x314
$ awk '{print $3}' /proc/$TP/stat
S

发完 SIGINT 半秒,它还睡在同一个位置.为什么:测试程序装了 SIGINT handler(装 handler 是为了进程别直接被默认动作杀掉),信号到达,-ERESTARTSYS 出系统调用,出口层安排重启(把 PC 回拨到 SVC 前面),handler 跑完后 sigreturn,read 自动重新执行,又阻塞回去了.用户态根本看不到 EINTR,看到的是"信号好像没发生过".这就是 -ERESTARTSYS 的重启语义,也是为什么它叫 RESTART.最后 kill -9 才干掉它,致命信号不走重启.要真拿到 EINTR,得是信号没有重启语义的场景(比如信号被 block 期间 pending 再解除,或多次信号),平时 Ctrl+C 一个 read 返回 EINTR 的直觉,在装了 handler 且系统调用可重启时是错的.

顺带一个 shell 的坑,实测撞上的:第一次测 SIGINT 时 reader 怎么杀都不醒,查了半天发现非交互 shell 里 & 起的后台作业,SIGINT 和 SIGQUIT 默认被设成忽略(POSIX 行为),信号根本没递进去.测信号相关的行为,别在后台作业里测,或者程序里显式装 handler(装了就不会被忽略).

4) 唤醒延迟的体感

投一张票,reader 从 schedule() 返回到用户态拿到数据,这中间是 wake_up → try_to_wake_up → 调度切换的完整路径,同一核上通常几十微秒量级.想量化的话在 write 侧和 read 侧各打一个时间戳相减就行,这里不展开了,数字留给感兴趣的读者自己测.

最后留几个容易忽略的细节:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注