网络编程基础漫谈(二)之 socket 的阻塞模式和非阻塞模式

对 socket 在阻塞和非阻塞模式下的各个函数的行为差别深入的理解是掌握网络编程的基本要求之一,是重点也是难点。

阻塞和非阻塞模式下,我们常讨论的具有不同行为表现的 socket 函数一般有如下几个,见下表:

connect

accept

send (Linux 平台上对 socket 进行操作时也包括write函数,下文中对 send 函数的讨论也适用于write函数)

recv (Linux 平台上对 socket 进行操作时也包括read函数,下文中对 recv 函数的讨论也适用于read函数)

限于文章篇幅,本文只讨论 send 和recv函数,connect 和 accept 函数我们将在该系列的后面文章中讨论。在正式讨论之前,我们先解释一下阻塞模式和非阻塞模式的概念。所谓阻塞模式就当某个函数“执行成功的条件”当前不能满足时,该函数会阻塞当前执行线程,程序执行流在超时时间到达或“执行成功的条件”满足后恢复继续执行。而非阻塞模式恰恰相反,即使某个函数的“执行成功的条件”不当前不能满足,该函数也不会阻塞当前执行线程,而是立即返回,继续运行执行程序流。如果读者不太明白这两个定义也没关系,后面我们会以具体的示例来讲解这两种模式的区别。

如何将 socket 设置成非阻塞模式

无论是 Windows 还是 Linux 平台,默认创建的 socket 都是阻塞模式的。

在 Linux 平台上,我们可以使用fcntl() 函数ioctl() 函数给创建的 socket 增加O_NONBLOCK标志来将 socket 设置成非阻塞模式。示例代码如下:

ioctl() 函数fcntl()函数使用方式基本一致,这里就不再给出示例代码了。

当然,Linux 下的socket()创建函数也可以直接在创建时将 socket 设置为非阻塞模式,socket()函数的签名如下:

type参数增加一个SOCK_NONBLOCK标志即可,例如:

不仅如此,Linux 系统下利用 accept() 函数返回的代表与客户端通信的 socket 也提供了一个扩展函数accept4(),直接将 accept 函数返回的 socket 设置成非阻塞的。

只要将accept4()函数最后一个参数flags设置成SOCK_NONBLOCK即可。也就是说以下代码是等价的:

在 Windows 平台上,可以调用ioctlsocket() 函数将 socket 设置成非阻塞模式,ioctlsocket()签名如下:

cmd参数设置为FIONBIOargp设置为即可将 socket 设置成阻塞模式,而将argp设置成非即可设置成非阻塞模式。示例如下:

Windows 平台需要注意一个地方,如果对一个 socket 调用了WSAAsyncSelect()WSAEventSelect()函数后,再调用ioctlsocket()函数将该 socket 设置为非阻塞模式会失败,你必须先调用WSAAsyncSelect()通过将lEvent参数为或调用WSAEventSelect()通过设置lNetworkEvents参数为来清除已经设置的 socket 相关标志位,再次调用ioctlsocket()将该 socket 设置成阻塞模式才会成功。因为调用WSAAsyncSelect()WSAEventSelect()函数会自动将 socket 设置成非阻塞模式。MSDN 上原文(https://docs.microsoft.com/en-us/windows/desktop/api/winsock/nf-winsock-ioctlsocket)如下:

关于WSAAsyncSelect()WSAEventSelect()这两个函数,后文中会详细讲解。

注意事项:无论是 Linux 的 fcntl 函数,还是 Windows 的 ioctlsocket,建议读者在实际编码中判断一下函数返回值以确定是否调用成功。

send 和 recv 函数在阻塞和非阻塞模式下的行为

send 和 recv 函数其实名不符实。

send 函数本质上并不是往网络上发送数据,而是将应用层发送缓冲区的数据拷贝到内核缓冲区(下文为了叙述方便,我们以“网卡缓冲区”代指)中去,至于什么时候数据会从网卡缓冲区中真正地发到网络中去要根据 TCP/IP 协议栈的行为来确定,这种行为涉及到一个叫 nagel 算法和 TCP_NODELAY 的 socket 选项,我们将在《nagle算法与 TCP_NODELAY》章节详细介绍。

recv 函数本质上也并不是从网络上收取数据,而只是将内核缓冲区中的数据拷贝到应用程序的缓冲区中,当然拷贝完成以后会将内核缓冲区中该部分数据移除。

可以用下面一张图来描述上述事实:

通过上图我们知道,不同的程序进行网络通信时,发送的一方会将内核缓冲区的数据通过网络传输给接收方的内核缓冲区。在应用程序 A 与 应用程序 B 建立了 TCP 连接之后,假设应用程序 A 不断调用 send 函数,这样数据会不断拷贝至对应的内核缓冲区中,如果 B 那一端一直不调用 recv 函数,那么 B 的内核缓冲区被填满以后,A 的内核缓冲区也会被填满,此时 A 继续调用 send 函数会是什么结果呢? 具体的结果取决于该 socket 是否是阻塞模式。我们这里先给出结论:

当 socket 是阻塞模式的,继续调用 send/recv 函数会导致程序阻塞在 send/recv 调用处。

当 socket 是非阻塞模式,继续调用 send/recv 函数,send/recv 函数不会阻塞程序执行流,而是会立即出错返回,我们会得到一个相关的错误码,Linux 平台上该错误码为 EWOULDBLOCK 或 EAGAIN(这两个错误码值相同),Windows 平台上错误码为 WSAEWOULDBLOCK。

我们实际来编写一下代码来验证一下以上说的两种情况。

socket 阻塞模式下的 send 行为

服务端代码(blocking_server.cpp)如下:

客户端代码(blocking_client.cpp)如下:

在 shell 中分别编译这两个 cpp 文件得到两个可执行程序blocking_serverblocking_client

我们先启动blocking_server,然后用 gdb 启动blocking_client,输入run命令让blocking_client跑起来,blocking_client会不断地向blocking_server发送"helloworld"字符串,每次 send 成功后,会将计数器count的值打印出来,计数器会不断增加,程序运行一段时间后,计数器count值不再增加且程序不再有输出。操作过程及输出结果如下:

blocking_server端:

此时程序不再有输出,说明我们的程序应该“卡在”某个地方,继续按Ctrl + C让 gdb 中断下来,输入bt命令查看此时的调用堆栈,我们发现我们的程序确实阻塞在send函数调用处:

上面的示例验证了如果一端一直发数据,而对端应用层一直不取数据(或收取数据的速度慢于发送速度),则很快两端的内核缓冲区很快就会被填满,导致发送端调用 send 函数被阻塞。这里说的“内核缓冲区” 其实有个专门的名字,即 TCP 窗口。也就是说 socket 阻塞模式下, send 函数在 TCP 窗口太小时的行为是阻塞当前程序执行流(即阻塞 send 函数所在的线程的执行)。

说点题外话,上面的例子,我们每次发送一个“helloworld”(10个字节),一共发了 355390 次(每次测试的结果略有不同),我们可以粗略地算出 TCP 窗口的大小大约等于 1.7 M左右 (10 * 355390 / 2)。

让我们再深入一点,我们利用 Linux tcpdump 工具来动态看一下这种情形下 TCP 窗口大小的动态变化。需要注意的是,Linux 下使用 tcpdump 这个命令需要有 root 权限。

我们开启三个 shell 窗口,在第一个窗口先启动blocking_server进程,在第二个窗口用 tcpdump 抓经过 TCP 端口 3000 上的数据包:

接着在第三个 shell 窗口,启动blocking_client。当blocking_client进程不再输出时,我们抓包的结果如下:

抓取到的前三个数据包是blocking_clientblocking_server建立三次握手的过程。

示意图如下:

当每次blocking_clientblocking_server发数据以后,blocking_server会应答blocking_server,在每次应答的数据包中会带上自己的当前可用 TCP 窗口大小(看上文中结果从127.0.0.1.3000 > 127.0.0.1.40846方向的数据包的win字段大小变化),由于 TCP 流量控制和拥赛控制机制的存在,blocking_server端的 TCP 窗口大小短期内会慢慢增加,后面随着接收缓冲区中数据积压越来越多, TCP 窗口会慢慢变小,最终变为 0。

另外,细心的读者如果实际去做一下这个实验会发现一个现象,即当 tcpdump 已经显示对端的 TCP 窗口是 0 时,blocking_client仍然可以继续发送一段时间的数据,此时的数据已经不是在发往对端,而是逐渐填满到本端的内核发送缓冲区中去了,这也验证了 send 函数实际上是往内核缓冲区中拷贝数据这一行为。

socket 非阻塞模式下的 send 行为

我们再来验证一下非阻塞 socket 的 send 行为,server端的代码不变,我们将blocking_client.cpp中 socket 设置成非阻塞的,修改后的代码如下:

编译nonblocking_client.cpp得到可执行程序nonblocking_client

运行nonblocking_client,运行一段时间后,由于对端和本端的 TCP 窗口已满,数据发不出去了,但是 send 函数不会阻塞,而是立即返回,返回值是-1(Windows 系统上 返回 SOCKET_ERROR,这个宏的值也是-1),此时得到错误码是EWOULDBLOCK。执行结果如下:

socket 阻塞模式下的 recv 行为

在了解了 send 函数的行为,我们再来看一下阻塞模式下的 recv 函数行为。服务器端代码不需要修改,我们修改一下客户端代码,如果服务器端不给客户端发数据,此时客户端调用 recv 函数执行流会阻塞在 recv 函数调用处。继续修改一下客户端代码:

编译blocking_client_recv.cpp并使用启动,我们发现程序既没有打印 recv 调用成功的信息也没有调用失败的信息,将程序中断下来,使用bt命令查看此时的调用堆栈,发现程序确实阻塞在 recv 函数调用处。

socket 非阻塞模式下的 recv 行为

非阻塞模式下如果当前无数据可读,recv 函数将立即返回,返回值为-1,错误码为EWOULDBLOCK。将客户端代码修成一下:

执行结果与我们预期的一模一样, recv 函数在无数据可读的情况下并不会阻塞情绪,所以程序会一直有“There is no data available now.”相关的输出。

当然,网络编程中关于 send 和 recv 函数的重难点远非这篇文章中所介绍的这么多,限于公众号单篇文章的长度限制,后面还会有几篇文章继续讲解它们,欢迎有兴趣的读者继续关注这个系列。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20181219G0DV9600?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券