将数据从内核复制到用户时,非阻塞 I/O 是否会进入休眠状态?

Will non-blocking I/O be put to sleep during copying data from kernel to user?

我问这个问题是因为我正在研究 Go 中的多路复用 I/O,它使用 epollwait

当一个socket就绪时,一个goroutine会被唤醒,开始以非阻塞模式读取socket。如果 read 系统调用在从内核向用户复制数据期间仍然会被阻塞,我假设 gorouine 附加到的内核线程也会进入睡眠状态。

我不是很清楚,如果我说错了,希望有人能帮助指正。

我没能完全解析你写的内容。

我会尝试做一个纯粹的猜测,并让人联想到您可能正在监督 write(2)read(2) 系统调用(以及类似的系统调用,例如 send(2)recv(2)) 在设置为 非阻塞模式 的套接字上可以免费使用(和 return)比请求更少的数据。
换句话说,在非阻塞套接字上的 write(2) 调用被告知写入 1 兆字节的数据将消耗与当前适合相关内核缓冲区的数据一样多的数据,并且 return 立即,表明它只消耗了很多数据。下一次立即调用 write(2) 可能会 return EWOULDBLOCK.

read(2) 调用也是如此:如果您向它传递一个足以容纳 1 兆字节数据的缓冲区,并告诉它读取该数量的字节,则该调用只会耗尽内核缓冲区和 return 立即表示它实际复制了多少数据。下一次立即调用 read(2) 可能会 return EWOULDBLOCK.

因此,任何向套接字获取数据或将数据放入套接字的尝试几乎都会立即成功:在数据被铲到内核缓冲区和用户之间后 space 或立即 - 使用 EAGAIN return代码。

当然,据推测 OS 线程有可能在执行此类系统调用的过程中挂起,但这不算作 "blocking in a syscall."


更新 到原始答案以回应 OP 的以下评论:

<…>
This is what I see in book "UNIX Network Programming" (Volume 1, 3rd), chapter 6.2:

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes. Using these definitions, the first four I/O models—blocking, nonblocking, I/O multiplexing, and signal-driven I/O—are all synchronous because the actual I/O operation (recvfrom) blocks the process.

它使用"blocks"来描述非阻塞I/O操作。这让我很困惑。
我还是不明白,如果进程实际上没有被阻塞,为什么这本书使用"blocks the process"。

我只能猜测这本书的作者是想强调从进入系统调用到 return 从系统调用开始,进程确实被阻塞了。读取和写入非阻塞套接字 do block 以在内核和用户 space 之间传输数据(如果可用)。我们通俗地说这不会阻塞,因为我们的意思是"it does not block waiting and doing nothing for an indeterminate amount of time"。

本书的作者可能会将此与所谓的异步 I/O(在 Windows™ 上称为 "overlapping")进行对比——您基本上为内核提供了一个缓冲区 with/for 数据并要求它与您的代码完全并行地删除它——从某种意义上说,相关的系统调用 return 立即并且 I/O 在后台执行(关于您的用户- space代码)。
据我所知,Go 在它不支持的平台上都不使用内核的异步 I/O 功能。你可能看起来 there for the developments regarding Linux and its contemporary io_uring subsystem.

哦,还有一点。这本书可能(至少在那个时候通过叙述)正在讨论一个简化的 "classic" 方案,其中没有进程内线程,并且并发的唯一单位是进程(具有单个执行线程)。在这个方案中,任何系统调用显然都会阻塞整个过程。相比之下,Go 仅适用于支持线程的内核,因此在 Go 程序中,系统调用永远不会阻塞整个进程——只会阻塞调用它的线程。


让我再试一次解释这个问题——我认为——OP 已经陈述了它。

为多个客户端请求提供服务的问题并不新鲜——其中一个更明显的第一个陈述是 "The C10k problem"
快速回顾一下,在其管理的套接字上进行阻塞操作的单线程服务器实际上一次只能处理一个客户端。
要解决它,有两种直接的方法:

  • 派生一个服务器进程的副本来处理每个传入的客户端连接。
  • 在支持线程的 OS 上,在同一个进程中派生一个新线程来处理每个传入的客户端。

它们各有利弊,但它们在资源使用方面都很糟糕,而且——更重要的是——它们不能很好地应对大多数客户端的速率和带宽相对较低的事实 I/O它们的性能与典型服务器上可用的处理资源有关。
换句话说,当服务于与客户端的典型 TCP/IP 交换时,服务线程大部分时间 write(2)read(2) 调用中休眠 在客户端套接字上。
是大多数人在谈论套接字"blocking operations"时的意思:如果一个套接字是阻塞的,对其的操作将阻塞直到它真正可以执行,并且发起线程将休眠一段不确定的时间。

另一个需要注意的重要事项是,当套接字准备就绪时,完成的工作量与两次唤醒之间的休眠时间相比通常是微不足道的。 当胎面休眠时,它的资源(例如内存)实际上被浪费了,因为它们不能用于做任何其他工作。

输入"polling"。它通过注意到网络套接字的就绪点相对较少并且介于两者之间来解决资源浪费的问题,因此由单个线程提供大量此类套接字是有意义的:它允许保持线程几乎与理论上尽可能忙,并且还允许在需要时横向扩展:如果单个线程无法处理数据流,请添加另一个线程,依此类推。

这种方法确实很酷,但它有一个缺点:必须重写读取和写入数据的代码以使用回调样式而不是原始的普通顺序样式。用回调编写很难:您通常必须实施复杂的缓冲区管理和状态机来处理这个问题。
Go运行time通过为其执行流程单元——goroutines增加了一层调度来解决这个问题:对于goroutines来说,对socket的操作总是阻塞的,但是当一个goroutine即将阻塞在一个socket上时,这是通过仅挂起 goroutine 本身来透明地处理——直到请求的操作能够继续——并使用 goroutine 运行正在继续做其他工作的线程¹。
这允许兼顾两种方法的优点:程序员可以编写经典的无脑顺序无回调网络代码,但用于处理网络请求的线程得到充分利用²。

至于阻塞的原始问题,当套接字上的数据传输发生时,goroutine 和它所在的线程 运行 确实被阻塞了,但是由于发生的是内核之间的数据铲除和 user-space 缓冲区,延迟大部分时间很小,与经典 "polling" 情况没有什么不同。

请注意,在 Go 中执行系统调用(包括 I/O 在不可轮询的描述符上)(至少直到 Go 1.14,包括 Go 1.14)确实 阻止了调用 goroutine 及其 运行s on 的线程,但处理方式与可轮询描述符的处理方式不同:当一个特殊的监控线程注意到 goroutine 在系统调用中花费的时间超过一定时间(20 µs,IIRC)时, 运行time 从 gorotuine 下拉出所谓的 "processor"(一个 运行time 的东西,运行s goroutines on OS threads)并试图使它成为运行 另一个 OS 线程上的另一个 goroutine;如果有一个 goroutine 想要 运行 但没有空闲 OS 线程,Go 运行time 会创建另一个。
因此 "normal" 阻塞 I/O 在两种意义上在 Go 中仍然是阻塞:它阻塞了 goroutines 和 OS 线程,但是 Go 调度器确保程序作为一个整体仍然能够取得进展。

这可以说是使用内核提供的真正异步 I/O 的完美案例,但目前还没有。


¹ 请参阅 this classic essay 了解更多信息。

² Go 运行time 当然不是第一个提出这个想法的人。例如,查看 the State Threads library (and the more recent libtask),它在纯 C 中实现了相同的方法; ST 库有很好的文档解释了这个想法。