socket 通信中可能发生哪些异常

socket 是网络编程的基础,业务开发中常用的 HttpClinet、RPC 框架等底层网络通信基础都离不开 socket,在使用这些框架的时候,可能会遇到 connect refused、connect reset by peer 等异常情况,这些异常的含义很容易理解。

但是当发生这些异常的时候,socket 的一些行为细节,更近一步的,TCP 协议中的发生了什么,可能就不是那么直观清晰了。

所以我专门针对 socket 编程中可能出现的异常进行了调研,针对不同的异常情况,给出了 C 语言 socket 编程代码示例,并分析了发生对应情况时 TCP 协议层的具体动作。

拒绝连接异常

拒绝连接异常就是服务端拒绝了客户端的连接,在如下这个代码示例中,当我们试图连接到一个本地未监听的端口时,就会收到这样的一个异常信息。

根据 ip 地址和 端口,建立 TCP 连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int tcp_client(const char *address, int port) {
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(port);
inet_pton(AF_INET, address, &server_addr.sin_addr);

socklen_t server_len = sizeof(server_addr);
// 调用 connect 函数建立连接
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
// 如果连接发生异常,打印错误日志
error(1, errno, "connect failed ");
}

return socket_fd;
}

int main(int argc, char** argv) {
char *ip = argv[1];
int port = atoi(argv[2]);
int socket_fd = tcp_client(ip, port);
}

不启动服务端的情况下,尝试建立连接,终端输出信息如下:

1
connect failed : Connection refused (61)

可以看到当发生 Connection refused 异常时,socket 层是由 connect 函数返回了对应的错误码,那么 TCP 协议层发生了什么呢?

我们知道 TCP 建立连接时需要进行 3 次握手,客户端首先发送 sync 报文段,如果报文段到达了目标主机,但是发现目标端口上没有正在监听的服务,则会发出 RST 报文段作为回复,拒绝此次连接。客户端收到 RST 回答后,通过 connect 函数报告这一错误。

产生 RST 的情况由如下 3 种:

  • sync 报文段到达,但目标端口没有正在监听的服务(如上所述)
  • TCP 想取消一个已有连接
  • TCP 接收到一个根本不存在的连接上的报文段

连接超时异常

还是上一小节的代码示例,如果在建立连接时,指定了一个无法连接的 ip 地址,比如和本机不在同一个局域网的私有 ip 地址,终端会输出如下错误信息:

1
connect failed : Operation timed out (60)

connect 函数默认情况下是阻塞调用,直到连接建立成功或者报告错误。如果客户端发出的 SYN 包没有任何响应,最终会返回 TIMEOUT 错误。

Linux 下默认的超时时间是 75s,实际应用中这个超时时间是不能接受的,所以在实际的网络通信中我们通常会IO多路复用的方式来建立连接,在调用 select 或者 poll 或者 epoll 函数时指定超时时间。

发生这种情况代表目标主机不可达,可能是 ip 地址写错,或者是网络出现故障。但有时我们会收到明确的 destination unreachable 错误,表示客户端和服务器端路由不通,这是因为客户端收到了路由器或者防火墙报告的 icmp 差错报文。

目标不可达

ICMP 类型 3 表示“Destination Unreachable”(目标不可达),而类型 3 中的不同代码用于指示导致目标不可达的具体原因,常见的代码有如下几种:

  • Code 0: Net Unreachable(网络不可达)
  • Code 1: Host Unreachable(主机不可达)
  • Code 2: Protocol Unreachable(协议不可达)
  • Code 3: Port Unreachable(端口不可达)

所以,当端口不可达时,客户端除了收到 RST 回答,还可能收到 ICMP 差错报文,这可能会因不同的网络设备、操作系统或防火墙设置而有所不同。

连接断开异常

上述两个异常是建立连接时可能遇到的,那么当连接建立成功后,因为某种原因连接断开了,会发生什么呢?

socket 中通常只有两种方式来感知 TCP 链路的异常中断,一种是以 read 为核心的读操作,一种是以 write 为核心的写操作。下面我们看下不同场景下通过读写操作来感知 TCP 连接的异常。

对端有 FIN 包发出

read 直接感知 FIN 包

在正常的连接断开过程中,对端会发送 FIN 报文段,客户端通过 read 接口感知到后,会做出相应的处理,比如关闭连接,释放连接资源。

服务端程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
int tcp_server(int port) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(port);

int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}

int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}

int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);

if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}

return connfd;
}


int main(int argc, char **argv) {
int connfd;
char buf[1024];

connfd = tcp_server(SERV_PORT);

for (;;) {
int n = read(connfd, buf, 1024);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}

sleep(5);

ssize_t write_nc = write(connfd, buf, n);
printf("send bytes: %zd \n", write_nc);
if (write_nc < 0) {
error(1, errno, "error write");
}
}
exit(0);
}

客户端程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main(int argc, char** argv) {
int socket_fd = tcp_client("localhost", SERV_PORT);

char buf[129];
int rc;

while (1) {
rc = read(socket_fd, buf, sizeof(buf));
if (rc < 0)
error(1, errno, "read failed");
else if (rc == 0)
error(1, 0, "peer connection closed\n");
else
fputs(buf, stdout);
}
exit(0);
}

分别启动服务端和客户端后,kill 掉服务端进程,客户端终端输出如下:

1
peer connection closed

因为阻塞的 read 操作读取到了 FIN 包后返回值为 0,客户端程序感知到 FIN 包后,执行正常退出逻辑。

通过 write 产生 RST,read 调用感知 RST

客户端程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(int argc, char **argv) {
int connfd;
char buf[1024];

connfd = tcp_server(SERV_PORT);

for (;;) {
int n = read(connfd, buf, 1024);
if (n < 0) {
error(1, errno, "error read");
} else if (n == 0) {
error(1, 0, "client closed \n");
}

sleep(5);

ssize_t write_nc = write(connfd, buf, n);
printf("send bytes: %zd \n", write_nc);
if (write_nc < 0) {
error(1, errno, "error write");
}
}
exit(0);
}

分别启动服务端和客户端后,kill 掉服务端进程,然后在客户端终端输入“Hello World”,回车后会看到如下错误信息:

1
read failed: Connection reset by peer (54)

客户端程序启动后,会阻塞在从标准输入读取数据的 fgets 方法上,因为无法感知到连接已经断开。

当从标准输入获取数据后,会将数据通过 socket 发送给服务端,因为服务端进程已经不存在,所以会返回一个 RST 包,接下来客户端的 read 调用感知到 RST,会返回异常信息,表明连接已断开。

注意

以上实验结果是在 MacOS 14.2.1 上进行的,在 Linux 系统上,这里的 read 调用可能读取到服务端程序关闭时发出的 FIN 包,从而正常关闭,而不是返回 RST 错误。说明不同系统内核中的的 TCP 协议实现可能略有不同。

向一个以关闭连接连续写,导致 SIGPIPE

客户端程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void sig_pipe(int signo) {
printf("SIGPIPE(%d)\n", signo);
}

int main(int argc, char** argv) {
int socket_fd = tcp_client("localhost", SERV_PORT);

char buf[129];
int len;
int rc;

signal(SIGPIPE, sig_pipe);

while (fgets(buf, sizeof(buf), stdin) != NULL) {
len = strlen(buf);
rc = write(socket_fd, buf, len);
if (rc < 0)
error(1, errno, "write failed");
}
exit(0);
}

分别启动服务端和客户端后,kill 掉服务端进程,然后在客户端终端连续输入数据,第二次回车后会看到如下错误信息:

1
2
SIGPIPE(13)
write failed: Broken pipe (32)

和前一个例子类似,当向一个已经关闭的连接发送数据时,会得到 RST 回复。如果客户端再次向这个连接发送数据,则会收到一个 SIGPIPE 信号。如果不捕捉这个信号,应用程序会在毫无征兆的情况下直接退出。

因为我们在程序中捕获了 SIGPIPE 信号,所以可以看到两行输出,第一行是对 SIGPIPE 信号的处理逻辑打印的通知信息,第二行是 write 调用的返回的错误信息。

注意:

在某些 Linux 版本中,第二次 write 操作不会收到 SIGPIPE 信号,而是返回 RST 错误信息。

对端无 FIN 包

网络中断导致对端没有 FIN 包

当网络中断时,如果网络中其他设备,比如路由器发出了 ICMP 差错报文,说明网络或者主机不可达,这是 read 或者 write 调用会返回 unreachable 错误。

如果没有 ICMP 报文,TCP 并不能及时感知到异常信息。如果此时程序阻塞在 read 方法上,则将无法回复运行,所以通常我们通过设置超时时间来避免这个问题,具体方式下一节分析读取超时异常时会介绍。

如果此时程序首先调用 write 发送了数据,然后阻塞在 read 调用上,系统的 TCP 协议栈会不断尝试将发送缓冲区的数据发送出去,大概在重传 12 次、合计时间约为 9 分钟之后,协议栈会标识该连接异常,这时,阻塞的 read 调用会返回一条 TIMEOUT 错误信息。如果程序继续 wirite 数据,则会收到一个 SIGPIPE 信号。

系统崩溃导致对端没有 FIN 包

这种情况和网络中断的情况是类似的,在没有 ICMP 报文的情况下,只能通过 read 或者 write 感知异常。

不同的地方在于,如果系统崩溃之后重新启动,重传的数据到达重启后的系统后,因为系统中没有该连接信息,会返回一个 RST 包。

如果客户端阻塞在 read 调用上,会立即返回 connect reset 错误;如果是 write 调用,也会立即失败,并收到一个 SIGPIPE 信号。

读取超时异常

前面提到过,服务端可能因为各种原因断开连接后,没有发出 FIN 包。如果是阻塞套接字,会一直阻塞在 read 调用上,没有办法感知套接字的异常。

所以我们通常会给套接字的 read 操作设置超时。可以说,读取超时异常是上层应用最常见的网络异常之一。

客户端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int main(int argc, char** argv) {
int socket_fd = tcp_client("localhost", SERV_PORT);

char buf[129];
int len;
int rc;

// socket read 超时设置为 1s
struct timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;
setsockopt(socket_fd, SOL_SOCKET, SO_RCVTIMEO, (const char *) &tv, sizeof tv);

while (fgets(buf, sizeof(buf), stdin) != NULL) {
len = strlen(buf);
rc = write(socket_fd, buf, len);
if (rc < 0)
error(1, errno, "write failed");
sleep(3);
rc = read(socket_fd, buf, sizeof(buf));
if (rc < 0)
error(1, errno, "read failed");
else if (rc == 0)
error(1, 0, "peer connection closed\n");
else
fputs(buf, stdout);
}
exit(0);
}

分别启动服务端和客户端后,在客户端终端输入数据,1s 后会看到如下错误信息:

1
read failed: Resource temporarily unavailable (35)

因为客户端程序中,socket 超时时间设置为 1s,而服务端发送数据前 sleep 了 5s,所以这里 read 函数返回读取超时错误信息。

总结

本文中总结了 socket 编程可能遇到的几种常见异常场景,并结合 TCP 协议进行了分析,其中,当服务端连接意外中断时,客户端对连接异常信息的检测,可能因不同的条件而产生 Connection Reset、Broken Pipe 等不同的错误信息。读完本篇文章后,如果应用层遇到了类似的问题,我想可以有更加清晰的理解和排查思路。

另外,文章中涉及的代码已经上传到了 githug,地址如下:https://github.com/qinjianmin/socket-exception。

参考

网络编程实战