一、结论
提出这个问题说明对网络编程的一些基础原理未搞明白,先说下结论:
一个 socket 是否设置为阻塞模式,只会影响到 connect/accept/send/recv 等四个 socket API 函数,不会影响到 select/poll/epoll_wait 函数,后三个函数的超时或者阻塞时间是由其函数自身参数控制的。
二、原理分析
下面详细的解释,为了方便解释,在这之前我们先明确几个基础概念:
connfd:创建 socket,主动发起连接的一端(客户端),该端调用 connect 函数主动发起连接;
listenfd:创建 socket,绑定地址和端口,调用 listen 函数发起侦听的一端(服务端);
clientfd:调用 accept 函数接受连接,由 accept 函数返回的 socket(服务端)。
示意图如下:
accept 函数并不参与三次握手过程,accept 函数从已经连接的队列中取出连接,返回 clientfd,最后客户端与服务端分别通过 connfd 和 clientf 进行通信(调用 send 或者 recv 函数)。
2.1 socket 是否被设置成阻塞模式对下列 API 造成的影响
- 当 connfd 被设置成阻塞模式时(默认行为,无需设置),connect 函数会一直阻塞到连接成功或超时或出错,超时值需要修改内核参数(超时和重试规则我在《C 服务器开发精髓》一书的 5.8 节详细地介绍了)。
- 当 connfd 被设置成非阻塞模式,无论连接是否建立成功,connect 函数都会立刻返回,那如何判断 connect 函数是否连接成功呢?接下来使用 select 和 poll 函数去判断 socket 是否可写即可,当然,Linux 系统上还需要额外加一步——使用 getsockopt 函数判断此时 socket 是否有错误,这就是所谓的异步 connect 或者叫非阻塞 connect(这是实际网络编程中写的比较多的逻辑,也是面试高频题)。
Windows 上的异步 connect 代码示例
代码语言:javascript复制//代码节选自:
//https://github.com/balloonwj/flamingo/blob/master/flamingoclient/Source/net/IUSocket.cpp
bool CIUSocket::Connect(int timeout /*= 3*/)
{
Close();
m_hSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (m_hSocket == INVALID_SOCKET)
return false;
long tmSend = 3 * 1000L;
long tmRecv = 3 * 1000L;
long noDelay = 1;
setsockopt(m_hSocket, IPPROTO_TCP, TCP_NODELAY, (LPSTR)&noDelay, sizeof(long));
setsockopt(m_hSocket, SOL_SOCKET, SO_SNDTIMEO, (LPSTR)&tmSend, sizeof(long));
setsockopt(m_hSocket, SOL_SOCKET, SO_RCVTIMEO, (LPSTR)&tmRecv, sizeof(long));
//将socket设置成非阻塞的
unsigned long on = 1;
if (::ioctlsocket(m_hSocket, FIONBIO, &on) == SOCKET_ERROR)
return false;
struct sockaddr_in addrSrv = { 0 };
struct hostent* pHostent = NULL;
unsigned int addr = 0;
if ((addrSrv.sin_addr.s_addr = inet_addr(m_strServer.c_str())) == INADDR_NONE)
{
pHostent = ::gethostbyname(m_strServer.c_str());
if (!pHostent)
{
LOG_ERROR("Could not connect server:%s, port:%d.", m_strServer.c_str(), m_nPort);
return false;
}
else
addrSrv.sin_addr.s_addr = *((unsigned long*)pHostent->h_addr);
}
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons((u_short)m_nPort);
int ret = ::connect(m_hSocket, (struct sockaddr*) & addrSrv, sizeof(addrSrv));
if (ret == 0)
{
LOG_INFO("Connect to server:%s, port:%d successfully.", m_strServer.c_str(), m_nPort);
m_bConnected = true;
return true;
}
else if (ret == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK)
{
LOG_ERROR("Could not connect to server:%s, port:%d.", m_strServer.c_str(), m_nPort);
return false;
}
fd_set writeset;
FD_ZERO(&writeset);
FD_SET(m_hSocket, &writeset);
struct timeval tv = { timeout, 0 };
if (::select(m_hSocket 1, NULL, &writeset, NULL, &tv) != 1)
{
LOG_ERROR("Could not connect to server:%s, port:%d.", m_strServer.c_str(), m_nPort);
return false;
}
m_bConnected = true;
return true;
}
Linux 上异步 connect 代码示例:
代码语言:javascript复制//代码节选自:
//https://github.com/balloonwj/mybooksources/blob/master/Chapter04/code/linux_nonblocking_connect.cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#define SERVER_ADDRESS "127.0.0.1"
#define SERVER_PORT 3000
#define SEND_DATA "helloworld"
int main(int argc, char* argv[])
{
//1.创建一个socket
int clientfd = socket(AF_INET, SOCK_STREAM, 0);
if (clientfd == -1)
{
std::cout << "create client socket error." << std::endl;
return -1;
}
//将clientfd设置成非阻塞模式
int oldSocketFlag = fcntl(clientfd, F_GETFL, 0);
int newSocketFlag = oldSocketFlag | O_NONBLOCK;
if (fcntl(clientfd, F_SETFL, newSocketFlag) == -1)
{
close(clientfd);
std::cout << "set socket to nonblock error." << std::endl;
return -1;
}
//2.连接服务器
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
serveraddr.sin_port = htons(SERVER_PORT);
for (;;)
{
int ret = connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (ret == 0)
{
std::cout << "connect to server successfully." << std::endl;
close(clientfd);
return 0;
}
else if (ret == -1)
{
if (errno == EINTR)
{
//connect 动作被信号中断,重试connect
std::cout << "connecting interruptted by signal, try again." << std::endl;
continue;
}
else if (errno == EINPROGRESS)
{
//连接正在尝试中
break;
}
else
{
//真的出错了,
close(clientfd);
return -1;
}
}
}
fd_set writeset;
FD_ZERO(&writeset);
FD_SET(clientfd, &writeset);
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
//3.调用select函数判断socket是否可写
if (select(clientfd 1, NULL, &writeset, NULL, &tv) != 1)
{
std::cout << "[select] connect to server error." << std::endl;
close(clientfd);
return -1;
}
int err;
socklen_t len = static_cast<socklen_t>(sizeof err);
//4.调用getsockopt检测此时socket是否出错
if (::getsockopt(clientfd, SOL_SOCKET, SO_ERROR, &err, &len) < 0)
{
close(clientfd);
return -1;
}
if (err == 0)
std::cout << "connect to server successfully." << std::endl;
else
std::cout << "connect to server error." << std::endl;
close(clientfd);
return 0;
}
使用 poll 或者 epoll_wait 实现异步 connect,代码我就不贴了,代码链接在这里:
https://github.com/balloonwj/mybooksources/blob/master/Chapter04/code/linux_nonblocking_connect_poll.cpp
- 当 listenfd 设置成阻塞模式(默认行为,无需额外设置)时,如果连接 pending 队列中有需要处理的连接,accept 函数会立即返回,否则会一直阻塞下去,直到有新的连接到来。
- 当 listenfd 设置成非阻塞模式,无论连接 pending 队列中是否有需要处理的连接,accept 都会立即返回,不会阻塞。如果有连接,则 accept 返回一个大于 0 的值,这个返回值即是我们上文所说的 clientfd;如果没有连接,accept 返回值小于 0,错误码 errno 为 EWOULDBLOCK(或者是 EAGAIN,这两个错误码值相等)。我们以 Redis 接受连接的代码为例吧:
//代码节选自Redis,
//https://github.com/balloonwj/mybooksources/blob/master/Chapter02/redis-6.0.3/src/networking.c
//971行
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
int cport, cfd, max = MAX_ACCEPTS_PER_CALL;
char cip[NET_IP_STR_LEN];
UNUSED(el);
UNUSED(mask);
UNUSED(privdata);
while(max--) {
//anetTcpAccept函数内部调用的是accept函数
cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
if (cfd == ANET_ERR) {
//fd是非阻塞的listenfd,当没有连接时,accept函数返回-1,错误码errno为EWOULDBLOCK
if (errno != EWOULDBLOCK)
serverLog(LL_WARNING,
"Accepting client connection: %s", server.neterr);
return;
}
serverLog(LL_VERBOSE,"Accepted %s:%d", cip, cport);
acceptCommonHandler(connCreateAcceptedSocket(cfd),0,cip);
}
}
- 当 connfd 或 clientfd 设置成阻塞模式时:send 函数会尝试发送数据,如果对端因为 TCP 窗口太小导致本端无法将数据发送出去,send 函数会一直阻塞直到对端 TCP 窗口变大足以发数据或者超时;recv 函数则正好相反,如果此时没有数据可收获,recv函数会一直阻塞直到收取到数据或者超时,有的话,取到数据后返回。send 和 recv 函数的超时时间可以分别使用 SO_SNDTIMEO 和 SO_RCVTIMEO 两个 socket 选项来设置。示例代码如下:
long tmSend = 3 * 1000L;
long tmRecv = 3 * 1000L;
//将send函数的超时时间设置为3秒
setsockopt(m_hSocket, SOL_SOCKET, SO_SNDTIMEO, (LPSTR)&tmSend, sizeof(long));
//将recv函数的超时时间设置为3秒
setsockopt(m_hSocket, SOL_SOCKET, SO_RCVTIMEO, (LPSTR)&tmRecv, sizeof(long));
- 当 connfd 或 clientfd 设置成非阻塞模式时,send 和 recv 函数都会立即返回,send 函数即使因为对端 TCP 窗口太小发不出去也会立即返回,recv 函数如果无数据可收也会立即返回,此时这两个函数的返回值都是 -1,错误码 errno 是 EWOULDBLOCK(或 EAGIN,与上面同)。这种情况下,send 和 recv 函数的返回值有三种情形,分别是大于 0,等于0 和小于 0,总结如下表:
img
三、select/poll/epoll_wait 函数的等待或超时时间
select、poll、epoll_wait 函数的超时时间分别由传给各自函数的时间参数决定的,我们来看下这三个函数的签名:
代码语言:javascript复制int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
三个函数最后一个参数是 timeout,只不过 select 函数的 timeout 参数的类型是一个结构体指针,这个结构的定义如下:
代码语言:javascript复制struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
select 函数的总超时时间是 timeout->tv_sec 和 timeout->tv_usec 之和, 前者的时间单位是秒,后者的时间单位是微秒。
select 函数的 timeout 参数含义有三种:
- 当 timeout 为 NULL 时,select 函数将一直阻塞下去,直到出错或者绑定其上的 socket 有事件;
- 当 timeout->tv_sec 和 timeout->tv_usec 同时为 0 时,select 函数会检查一下绑定在其上的 socket 是否有事件,然后立刻返回;
- 当 timeout->tv_sec 和 timeout->tv_usec 之和大于 0 时,select 函数检测到绑定其上的 socket 有时间才会返回或者阻塞时长为 timeout->tv_sec timeout->tv_usec 。
poll 和 epoll_wait 函数的超时时间为毫秒,设置为 0,和 select 函数一样,检测一下绑定其上的 socket 是否有事件,然后立即返回。
四、使用 epoll 模型是否要将 socket 设置成非阻塞的
答案是需要的。
epoll 模型通常用于服务端,那讨论的 socket 只有 listenfd 和 clientfd 了。
listenfd 为什么一定要设置成非阻塞的,我在另外一篇文章中写的很清楚:
高性能网络通信库中为何要将侦听 socket 设置成非阻塞的?
现在就剩下 clientfd 了,如果不将 clientfd 设置成非阻塞模式,那么一旦 epoll_wait 检测到读或者写事件返回后,接下来处理 clientfd 的读或写事件,如果对端因为 TCP 窗口太小,send 函数刚好不能将数据全部发送出去,将会造成阻塞,进而导致整个服务“卡住”。
五、总结与学习建议
题主提出这样的问题,建议还是加强基础原理和概念的理解,搞清楚每一种技术用于何种场景,例如非阻塞 socket 用于何种场景、影响哪些返回,I/O 复用函数为何阻塞或等待。
六、推荐的一些学习资源
可以从哪里系统地学习到上述知识?
有同学私信问我,你这些知识从哪里学习的呢?
如果你是网络编程零基础或者觉得自己网络编程存在夹生饭问题,推荐看看尹圣雨的《TCP/IP 网络编程》,这本书同时兼顾 Windows 和 Linux 两个平台,使用的是 C 语言和操作系统的 Socket API,通过这本书你能学会常用的操作系统 Socket API 和常用的网络模型,认真学完之后,你不会再纠结同步异步、阻塞非阻塞等概念。
接着如果你想编写高性能的网络框架或者高效的服务,推荐游双老师的《Linux 高性能服务器编程》一书。
当然,我自己也出版了一本书《C 服务器开发精髓》:
在 2021 年写一本 C 图书是一种什么体验?
在这本书的第四章等章节,我详细地通过循序渐进的方式介绍了网络编程的二十多个重难点知识,当然也包括上文说的阻塞/非阻塞模式、epoll 模型等,这是图书的第四章目录,有兴趣的读者可以阅读一下:
第4章 网络编程重难点解析 282
4.1 学习网络编程时应该掌握的socket函数 282
4.1.1 在Linux上查看socket函数的帮助信息 283
4.1.2 在Windows上查看socket函数的帮助信息 285
4.2 TCP网络通信的基本流程 286
4.3 设计跨平台网络通信库时的一些socket函数用法 290
4.3.1 socket数据类型 290
4.3.2 在Windows上调用socket函数 290
4.3.3 关闭socket函数 291
4.3.4 获取socket函数的错误码 291
4.3.5 套接字函数的返回值 293
4.3.6 select函数第1个参数的问题 293
4.3.7 错误码WSAEWOULDBLOCK和EWOULDBLOCK 294
4.4 bind函数重难点分析 294
4.4.1 对bind函数如何选择绑定地址 294
4.4.2 bind函数的端口号问题 295
4.5 select函数的用法和原理 302
4.5.1 Linux上的select函数 302
4.5.2 Windows上的select函数 317
4.6 socket的阻塞模式和非阻塞模式 318
4.6.1 如何将socket设置为非阻塞模式 318
4.6.2 send和recv函数在阻塞和非阻塞模式下的表现 320
4.6.3 非阻塞模式下send和recv函数的返回值总结 331
4.6.4 阻塞与非阻塞socket的各自适用场景 333
4.7 发送0字节数据的效果 333
4.8 connect函数在阻塞和非阻塞模式下的行为 339
4.9 连接时顺便接收第1组数据 343
4.10 如何获取当前socket对应的接收缓冲区中的可读数据量 346
4.10.1 分析 346
4.10.2 注意事项 350
4.11 Linux EINTR错误码 351
4.12 Linux SIGPIPE信号 352
4.13 Linux poll 函数的用法 353
4.14 Linux epoll模型 361
4.14.1 基本用法 361
4.14.2 epoll_wait与poll函数的区别 363
4.14.3 LT 模式和ET 模式 363
4.14.4 EPOLLONESHOT 选项 380
4.15 高效的readv和writev函数 386
4.16 主机字节序和网络字节序 387
4.16.1 主机字节序 387
4.16.2 网络字节序 388
4.16.3 操作系统提供的字节转换函数汇总 389
4.17 域名解析API介绍 390
推荐阅读
- 在 2021 年写一本 C 图书是一种什么体验?
- 写给想去字节写 Go 的你
- 《C 服务器开发精髓》签名版请签收
- 为什么你的简历没人看?
- 大厂,那高高的围墙
- 来看一看两道大厂的场景题
- 为什么你字节跳动的面试没下文了?
- 工作 3 万,副业 5 万
- 写代码太苦了,我决定改行送外卖去了
- 我们说 TCP 是流式协议意味着什么?
- 有哪些不错的 Golang 开源项目?
- 曾经想去的二三四五,曾经想娶的女子
欢迎加入 高质量开发微信读者交流群 进行交流,先加我微信 easy_coder,备注"加微信群",我拉你入群,备注不对不加哦。