1 回答
TA贡献1818条经验 获得超7个赞
我无法完全解析您所写的内容。
我会尝试做一个纯粹的猜测并想象你可能正在监督这样一个事实,即进入非阻塞模式的套接字上的write(2)
andread(2)
系统调用(以及它们的同类调用,例如send(2)
and )可以自由使用(并分别返回)比请求的数据少。 换句话说,一个非阻塞套接字上的调用被告知写入 1 兆字节的数据将消耗当前适合相关内核缓冲区的数据并立即返回,表明它只消耗了同样多的数据。下一次立即调用可能会返回。recv(2)
write(2)
write(2)
EWOULDBLOCK
调用也是如此read(2)
:如果你传递一个足够大的缓冲区来保存 1 兆字节的数据,并告诉它读取该字节数,调用只会耗尽内核缓冲区的内容并立即返回,表明有多少它实际复制的数据。下一次立即调用read(2)
可能会返回EWOULDBLOCK
。
因此,任何获取或放入套接字数据的尝试几乎都会立即成功:无论是在数据在内核缓冲区和用户空间之间被铲起之后,还是马上——使用EAGAIN
返回码。
当然,在执行这样的系统调用的过程中,操作系统线程有可能被挂起,但这不能算作“系统调用中的阻塞”。
更新原始答案以响应 OP 的以下评论:
<...>
这是我在《UNIX 网络编程》(第 1 卷,第 3 卷)第 6.2 章中看到的内容:同步 I/O 操作会导致请求进程被阻塞,直到该 I/O 操作完成。使用这些定义,前四个 I/O 模型——阻塞、非阻塞、I/O 多路复用和信号驱动 I/O——都是同步的,因为实际的 I/O 操作 (recvfrom) 会阻塞进程。
它使用“块”来描述非阻塞 I/O 操作。这让我很困惑。
如果进程实际上没有被阻塞,我仍然不明白为什么这本书使用“阻塞进程”。
我只能猜测这本书的作者打算强调该过程确实在进入系统调用之后一直被阻塞,直到从它返回。读取和写入非阻塞套接字确实会阻塞以在内核和用户空间之间传输数据(如果可用)。我们通俗地说这不会阻塞,因为我们的意思是“它不会阻塞等待并且在不确定的时间内什么都不做”。
这本书的作者可能会将此与所谓的异步 I/O(在 Windows™ 上称为“重叠”)进行对比——在这种情况下,您基本上为内核提供了一个带有/用于数据的缓冲区,并要求它与您的代码——从某种意义上说,相关的系统调用立即返回并且 I/O 在后台执行(关于您的用户空间代码)。
据我所知,Go 在它支持的任何一个平台上都没有使用内核的异步 I/O 设施。您可以在那里寻找有关 Linux 及其当代io_uring
子系统的发展。
哦,还有一点。这本书可能(至少在叙述的时候)正在讨论一种简化的“经典”方案,其中没有进程内线程,唯一的并发单元是进程(具有单个执行线程)。在这个方案中,任何系统调用显然都会阻塞整个过程。相比之下,Go 只在支持线程的内核上工作,因此在 Go 程序中,系统调用永远不会阻塞整个进程——只会阻塞它被调用的线程。
让我再次尝试解释这个问题 - 我认为 - OP 声明了它。
服务多个客户端请求的问题并不新鲜——其中最明显的第一个陈述是“C10k 问题”。
快速回顾一下,在它管理的套接字上具有阻塞操作的单线程服务器实际上一次只能处理一个客户端。
为了解决这个问题,有两种直接的方法:
派生服务器进程的副本以处理每个传入的客户端连接。
在支持线程的操作系统上,在同一进程中创建一个新线程来处理每个传入的客户端。
它们各有优缺点,但它们在资源使用方面都很糟糕,而且——更重要的是——它们不能很好地适应大多数客户端在处理方面的 I/O 速率和带宽相对较低这一事实典型服务器上可用的资源。
换句话说,当与客户端进行典型的 TCP/IP 交换时,服务线程大部分时间都处于休眠状态,write(2)
并read(2)
在客户端套接字上调用。
这就是大多数人在谈论套接字上的“阻塞操作”时的意思:如果一个套接字是阻塞的,并且对它的操作将阻塞,直到它可以真正执行,并且源线程将被置于睡眠状态不确定数量的时间。
另一个需要注意的重要事情是,当套接字准备就绪时,与两次唤醒之间的睡眠时间相比,完成的工作量通常是微不足道的。当胎面休眠时,它的资源(例如内存)实际上被浪费了,因为它们不能用于做任何其他工作。
输入“轮询”。它通过注意到网络套接字的就绪点相对较少且介于两者之间来解决浪费资源的问题,因此让单个线程服务大量此类套接字是有意义的:它允许保持线程几乎理论上尽可能繁忙,并且还允许在需要时横向扩展:如果单个线程无法处理数据流,则添加另一个线程,等等。
这种方法确实很酷,但它有一个缺点:必须重写读取和写入数据的代码以使用回调样式而不是原始的普通顺序样式。用回调编写很难:你通常必须实现复杂的缓冲区管理和状态机来处理这个问题。
Go 运行时通过为其执行流单元增加另一层调度来解决这个问题——goroutines:对于 goroutines,socket 上的操作总是阻塞的,但是当一个 goroutine 即将阻塞在一个 socket 上时,这是透明的,只通过挂起goroutine 本身——直到请求的操作能够继续——并使用 goroutine 运行的线程来做其他工作¹。
这允许两种方法中最好的:程序员可以编写经典的无脑顺序无回调网络代码,但用于处理网络请求的线程已被充分利用²。
至于阻塞的原始问题,当套接字上的数据传输发生时,goroutine 和它运行的线程确实都被阻塞了,但是由于发生的是内核和用户空间缓冲区之间的数据铲斗,所以延迟是大多数时候都很小,与经典的“投票”案例没有什么不同。
请注意,在 Go(至少直到,包括 Go 1.14)中执行系统调用(包括在不可轮询描述符上的 I/O)确实会阻塞调用 goroutine 和它运行的线程,但处理方式与可轮询的不同描述符:当一个特殊的监控线程注意到一个 goroutine 在系统调用中花费了超过一定时间(20 µs,IIRC)时,运行时从下面拉出所谓的“处理器”(在 OS 线程上运行 goroutines 的运行时事物) gorotuine 并尝试让它在另一个 OS 线程上运行另一个 goroutine;如果有一个 goroutine 想要运行但没有空闲的 OS 线程,Go 运行时会创建另一个。
因此,“正常”的阻塞 I/O 在 Go 中仍然在两种意义上都是阻塞的:它阻塞了 goroutine 和 OS 线程,但是 Go 调度程序确保程序作为一个整体仍然能够取得进展。
这可以说是使用内核提供的真正异步 I/O 的完美案例,但目前还没有。
- 1 回答
- 0 关注
- 131 浏览
添加回答
举报