他强任他强,清风拂山岗;他横由他横,明月照大江。- 《倚天屠龙记》

FAQ

短链接和长链接对比

短连接

优点

  • 简单实现:每次请求都是独立的连接,不需要管理复杂的连接状态。
  • 负载均衡:短连接天然适合负载均衡,每次请求都可以随机分配给不同的服务器。
  • 避免脏数据:连接短暂,数据不会残留在连接中,减少了处理脏数据的复杂性。

缺点

  • 效率较低:建立和关闭连接需要时间,通常在 50µs 到 200µs 之间,频繁的连接操作增加了开销。
  • 产生大量 TIME_WAIT:每次关闭连接后,TCP 连接进入 TIME_WAIT 状态,占用系统资源,可能导致端口耗尽。
  • 资源消耗:频繁的连接建立和断开会消耗更多的系统资源,尤其是在高并发场景下,可能导致性能瓶颈。

处理措施

  1. 优化连接建立速度
    • 调整 TCP 参数,如减少握手时间,可以通过调整 TCP_QUICKACKTCP_NODELAY 来减少延迟。
    • 使用连接池技术,即使使用短连接,也可以在客户端维护一组预先建立的连接,用于快速复用。
  2. 管理 TIME_WAIT 状态
    • 调整端口范围:通过修改 /proc/sys/net/ipv4/ip_local_port_range 增加可用端口范围。
    • 缩短 TIME_WAIT 时长:通过设置 tcp_tw_recycletcp_tw_reuse 参数加速 TIME_WAIT 状态的回收(注意,这些选项在某些情况下会导致 NAT 问题,因此要谨慎使用)。
    • 使用 SO_REUSEADDR:允许端口在 TIME_WAIT 状态下被重新使用。
  3. 负载均衡
    • 采用硬件或软件负载均衡器(如 Nginx、HAProxy)来分发短连接请求,确保各服务器的负载均衡。

长连接

优点

  • 高效:避免了频繁的连接建立和关闭,减少了时间和资源消耗。
  • 适合高并发:在高并发场景下,可以承载更多的请求,减少了系统的开销。

缺点

  • 脏数据处理:长连接中,如果出现异常或逻辑错误,可能会有不需要的数据残留在连接中。
  • 连接管理复杂:需要处理连接的生命周期、异常恢复、连接池管理等问题。
  • 延迟问题:长连接可能会出现数据传输延迟或连接长时间无响应的问题。

处理措施

  1. 脏数据处理
    • 主动检测脏数据:在每次发送请求前,可以使用 recv() 检查连接中是否有残留的数据。如果检测到脏数据(例如意外的响应数据),可以选择直接关闭连接。
    • 安全断开连接:当发现脏数据时,最简单的方式是直接关闭连接并重新建立连接,而不是尝试清除脏数据。
    • 逻辑处理:如果逻辑允许,可以通过读取到 EAGAIN 或类似信号来清除连接中的残留数据,但这种方法依赖于程序逻辑,会增加代码复杂度。
  2. 连接池管理
    • 使用连接池:通过连接池管理长连接,可以减少连接建立的开销,并在多个线程之间共享连接。连接池还可以优化资源分配,使得每个线程能够高效地获取和释放连接。
    • 连接池的规模管理:通过压力测试确定合理的连接池大小,避免过度占用资源。
  3. 并发处理
    • 线程池管理:在长连接的情况下,建议使用线程池来管理连接的并发处理。通过异步队列和线程池,可以有效地调度请求,避免线程数过多导致的上下文切换开销。
    • 异步 I/O 模型:在高并发场景下,可以使用异步 I/O 模型如 epollkqueue 等,减少对线程数量的依赖,提高系统的并发处理能力。
  4. 延迟问题
    • 心跳检测:在长连接中,定期发送心跳包检测连接的存活状态。如果发现连接不可用,及时关闭并重新建立连接。
    • 超时管理:设置合理的读写超时时间,避免长时间无响应导致的资源浪费。
    • 拥塞控制:监控网络的拥塞状态,并根据实际情况调整 TCP 的参数,以减少延迟。

总结

  • 短连接 适合简单的系统设计,尤其是在请求频率低或负载均衡复杂的场景下。但是它的效率较低,会产生大量的 TIME_WAIT 状态,需要有效地管理系统资源。
  • 长连接 适合高并发、高效率的场景,减少了连接建立和关闭的开销。但是它需要更复杂的连接管理策略,包括处理脏数据、管理连接池、应对延迟等问题。

读和写操作

读操作 (read/recv)

1. 读操作的本质

  • 缓冲区复制readrecv 操作的本质是将数据从内核缓冲区复制到用户指定的缓冲区。这意味着数据在到达用户程序之前已经被操作系统接收并存储在内核缓冲区中。

2. 阻塞模式下的读操作

  • 行为:在阻塞模式下,如果内核缓冲区中没有数据,readrecv 会阻塞等待,直到数据到达并复制到用户缓冲区中。

  • 返回条件:当内核缓冲区有数据时,readrecv 会将数据复制到用户缓冲区并立即返回,即使复制的数据量小于请求的长度。

  • 循环读取:由于 readrecv 并不保证一次调用就能读取到指定长度的数据,因此在读取数据时通常需要使用循环读取的方式,直到读取到所需的完整数据或达到终止条件。

    示例代码:循环读取

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    ssize_t total_bytes = 0;
    ssize_t bytes_read;
    size_t to_read = buffer_size;

    while (total_bytes < buffer_size) {
    bytes_read = read(sockfd, buffer + total_bytes, to_read);
    if (bytes_read <= 0) {
    // Handle error or EOF
    break;
    }
    total_bytes += bytes_read;
    to_read -= bytes_read;
    }

3. 非阻塞模式下的读操作

  • 行为:在非阻塞模式下,如果没有数据可读,readrecv 会立即返回,而不是等待数据的到来。如果有数据,它们会读取尽可能多的数据并返回。

  • 返回值:如果没有数据,readrecv 会返回 -1,并将 errno 设置为 EAGAINEWOULDBLOCK

  • 循环读取:由于非阻塞模式下 readrecv 可能只读取到部分或没有数据,因此在需要读取完整的数据时,也必须采用循环读取。

    示例代码:非阻塞读取

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    while (true) {
    bytes_read = read(sockfd, buffer, buffer_size);
    if (bytes_read > 0) {
    // Process the data
    } else if (bytes_read == -1 && errno == EAGAIN) {
    // No data available, continue or sleep
    continue;
    } else {
    // Handle error or EOF
    break;
    }
    }

写操作

阻塞模式下的写操作:

1
2
3
4
5
6
7
8
9
10
11
12
void send_file(int sockfd, char *file_buffer, size_t file_size) {
ssize_t total_bytes_sent = 0;
while (total_bytes_sent < file_size) {
ssize_t bytes_sent = write(sockfd, file_buffer + total_bytes_sent, file_size - total_bytes_sent);
if (bytes_sent <= 0) {
// 错误处理
perror("Error writing to socket");
break;
}
total_bytes_sent += bytes_sent;
}
}

非阻塞模式下的写操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void send_file_nonblocking(int sockfd, char *file_buffer, size_t file_size) {
ssize_t total_bytes_sent = 0;
while (total_bytes_sent < file_size) {
ssize_t bytes_sent = write(sockfd, file_buffer + total_bytes_sent, file_size - total_bytes_sent);
if (bytes_sent > 0) {
total_bytes_sent += bytes_sent;
} else if (bytes_sent == -1 && errno == EAGAIN) {
// 网络拥塞,稍后重试
usleep(1000); // 暂停一段时间再重试
continue;
} else {
// 错误处理
perror("Error writing to socket");
break;
}
}
}

为什么网络程序会没有任何预兆的就退出了

网络程序在没有预兆的情况下退出,通常是因为遇到了某些特定的情况,导致程序收到了某个信号并执行了默认的处理行为。其中,一个常见的情况就是 SIGPIPE 信号。

SIGPIPE 信号

SIGPIPE 信号是在以下情况下产生的:

  • 当一个进程向一个已经关闭读取端的套接字(socket)写入数据时,内核会向该进程发送 SIGPIPE 信号。
  • 这种情况通常发生在网络通信中,当一方已经关闭连接,而另一方仍然尝试写入数据时。

默认行为

默认情况下,SIGPIPE 信号的处理行为是终止进程。这意味着,如果程序没有显式地处理这个信号,当它收到 SIGPIPE 信号时,程序会立即退出。

忽略 SIGPIPE 信号

为了避免程序因为 SIGPIPE 信号而意外退出,可以在程序启动时显式地忽略这个信号。这可以通过调用 signal 函数来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <signal.h>

int main() {
// 忽略 SIGPIPE 信号
signal(SIGPIPE, SIG_IGN);

// 程序的其他初始化代码
// ...

// 主程序逻辑
// ...

return 0;
}

在这个例子中,signal(SIGPIPE, SIG_IGN) 告诉系统忽略 SIGPIPE 信号。这样,即使程序在写入一个已经关闭读取端的套接字时,也不会因为收到 SIGPIPE 信号而退出。

处理 SIGPIPE 信号的其他方法

除了忽略 SIGPIPE 信号,还可以通过其他方式来处理这种情况:

  1. 检查返回值
    • 在写入操作后检查返回值,如果返回 -1 并且 errno 设置为 EPIPE,则表示发生了 SIGPIPE 错误。
1
2
3
4
5
6
7
8
9
10
ssize_t n = write(fd, buffer, length);
if (n == -1) {
if (errno == EPIPE) {
// 处理 EPIPE 错误
// ...
} else {
// 处理其他错误
// ...
}
}
  1. 使用 send 函数的 MSG_NOSIGNAL 标志
    • 在调用 send 函数时,可以使用 MSG_NOSIGNAL 标志来避免产生 SIGPIPE 信号。
1
2
3
4
5
6
7
8
9
10
ssize_t n = send(fd, buffer, length, MSG_NOSIGNAL);
if (n == -1) {
if (errno == EPIPE) {
// 处理 EPIPE 错误
// ...
} else {
// 处理其他错误
// ...
}
}

总结

网络程序在没有预兆的情况下退出,通常是因为遇到了 SIGPIPE 信号。为了避免这种情况,可以在程序启动时忽略 SIGPIPE 信号,或者在写入操作后检查返回值来处理 EPIPE 错误。这样可以确保程序在遇到 SIGPIPE 错误时不会意外退出,而是能够进行适当的错误处理。

write出去的数据, read的时候知道长度吗?

TCP 数据传输的特点

  1. write 操作的长度不一定等于一次性发送的数据量
    • 虽然在应用层你可能一次性调用了 write(fd, buf, n),但是底层的 TCP 可能不会在一次操作中将这 n 字节的数据全部发送出去。特别是在网络拥塞或缓冲区不够的情况下,可能需要多次 write 才能将所有数据发送完毕。
  2. 接收端无法直接知道发送端每次 write 的数据长度
    • TCP 是一种流式协议,这意味着接收端在调用 read(fd, buf, n) 时,可能会接收到一部分、全部或多次 write 发送的数据。接收端无法直接通过 read 的返回值确定发送端在一次 write 中究竟发送了多少字节。
  3. TCP 数据在网络中可能被分片
    • 即使发送端一次性 write 了大块数据,这些数据在传输过程中可能被拆成多个 TCP 包。接收端在调用 read 时,可能会读取到部分的 TCP 包或者多个包拼接在一起的数据。
  4. TCP 将接收到的数据放入缓冲区后再供 read 使用
    • TCP 协议会将接收到的数据放入内核缓冲区,当应用层调用 read 时,就会从这个缓冲区中读取数据。因此,接收端的 read 操作与发送端的 write 操作在时间上并不需要同步。

如何确保正确的数据交互

由于上述 TCP 的特性,简单地依赖 read 的返回值来判断发送端传输的数据长度是不可靠的。下面是一些常用的策略来确保正确的数据交互:

1. 约定固定长度的数据包

  • 概念:双方约定每次传递的数据包长度是固定的,比如每次传输的数据包固定为 1024 字节。
  • 示例:假设双方约定每次发送 1024 字节的数据包,那么接收端每次调用read时,也读取 1024 字节。
    • 优点:实现简单,不需要额外的逻辑来处理数据长度问题。
    • 缺点:不灵活,对于大小不一的数据包,这种方式会导致浪费带宽(填充数据)或无法灵活处理数据。

2. 使用特殊的结束符

  • 概念:在数据的结尾使用一个特殊的结束符来标识数据包的结束。
  • 示例:在 HTTP 协议中,头部结束是由\r\n\r\标识的。接收端可以通过查找这个结束符来确定数据包的结束位置。
    • 优点:适用于长度不定的数据包,灵活且常用于文本协议。
    • 缺点:需要确保数据中不会误包含结束符,否则会导致解析错误。

3. 定长头部 + 变长数据

  • 概念:数据包前面有一个固定长度的头部,头部包含数据的长度信息,接收端先读取头部,再根据头部信息读取实际数据。

  • 示例

    :在很多协议中,前 4 字节可能用于表示接下来数据的长度。接收端首先读取这 4 字节,然后根据这个长度信息读取后续的数据。

    • 优点:灵活且适用于各种数据长度,特别是大数据传输。
    • 缺点:需要处理两次 read 操作,先读头部,再读数据。

如何查看和观察句柄泄露问题

句柄泄漏 指的是程序在运行时打开了文件或套接字等资源,但没有在不需要时关闭它们,导致文件描述符表中的项逐渐耗尽。如果不解决这个问题,程序最终会无法打开新的文件或套接字,导致崩溃或其他异常行为。

1. 通过 /proc 文件系统来观察

Linux 提供了 /proc 文件系统,它是一个虚拟文件系统,用于访问内核和进程的信息。每个进程在 /proc 下都有一个对应的目录,命名为进程的 PID(进程 ID)。

  • 查看进程的文件描述符

    • 你可以通过查看 /proc/<PID>/fd 目录,来观察一个进程当前打开的所有文件描述符。这个目录列出了所有打开的文件描述符,每个文件描述符对应一个符号链接,指向实际打开的文件或套接字。
  • 实际操作

    • 假设你有一个进程的 PID 是 1234,你可以通过以下命令查看它的文件描述符:

      1
      ls -l /proc/1234/fd
    • 这将列出进程 1234 当前打开的所有文件描述符。如果你发现这个目录中的文件描述符数量不断增加,或者在程序应该关闭文件描述符的地方没有减少,这可能表明程序有句柄泄漏问题。

2. 使用 valgrind 检查句柄泄漏

valgrind 是一个强大的工具,主要用于检测内存泄漏和内存管理问题。但是,它也可以用于检测文件描述符泄漏。

  • valgrind 的工作原理

    • valgrind 可以跟踪程序运行过程中打开的文件描述符,并在程序退出时报告哪些文件描述符在程序结束时仍然未关闭。
  • 启用文件描述符跟踪

    • 你可以使用 valgrind--track-fds=yes 参数来启用文件描述符的跟踪。当程序退出时,valgrind 会报告哪些文件描述符仍然打开,以及它们是在哪里被打开的。
  • 实际操作

    • 假设你有一个程序example,你可以使用以下命令来运行它,并检查文件描述符泄漏:

      1
      valgrind --track-fds=yes ./example
    • 如果程序有未关闭的文件描述符,valgrind 会在输出中显示这些文件描述符的详细信息,包括它们是在哪个文件的哪一行被打开的,以及具体的调用栈。

为什么socket写错误,但用recv检查依然成功?

1. TCP 是双向通信的

TCP 是全双工的协议,这意味着发送和接收是相互独立的。即使在发送数据时(通过 writesend),出现了错误(如发送超时或网络问题),接收数据(通过 recv)的通道可能依然是正常的。换句话说,发送方向出现的问题不一定会立即影响接收方向的状态。

2. recv 的检查方式

在非阻塞模式下,如果网络中没有数据到达,recv 返回 -1 并设置 errnoEAGAIN,这表示当前没有数据可读,但连接仍然是正常的。这种检查方式的假设前提是:在请求-响应模型中,客户端在发起新请求前,网络中不应该有上一次请求的残留数据;如果有数据到达,recv 应该能够读取到正确的数据。

3. 服务端的响应行为

在请求-响应模式中,客户端发送请求后,接收服务端的响应。如果 recv 成功读取到数据(ret > 0),则说明连接是正常的,并且服务端正常响应了请求。

4. 写操作失败的原因

写操作失败的原因可能是多种多样的,比如网络拥堵、数据量过大、或者远程服务器出现问题。这些问题不一定意味着连接已经断开。因此,即使写操作失败,recv 仍然可能在短时间内读取到之前发送的请求的响应。

为什么接收端失败,但客户端仍然是write成功

这个是正常现象,write数据成功不能表示数据已经被接收端接收导致,只能表示数据已经被复制到系统底层的缓冲(不一定发出), 这个时候的网络异常都是会造成接收端接收失败的.

长连接的情况下出现了不同程度的延时

长连接与短连接的区别

  • 长连接:在长连接中,客户端和服务器之间的连接在多个请求和响应之间保持打开状态。这种方式减少了频繁建立和关闭连接的开销,适用于需要多次交互的场景。
  • 短连接:每次请求-响应都独立进行,完成后立即关闭连接。短连接虽然简单,但在频繁请求的场景下,连接的建立和关闭会增加开销。

问题描述:长连接中的延时

在长连接的情况下,可能会出现一种现象:发送一个小的数据包后,虽然 write 操作成功,但接收端却需要等待一段时间(例如 40ms)才能接收到数据。而当改用短连接时,这种延时消失了。这种延时可能由以下几个原因导致:

1. Nagle 算法与 TCP_NODELAY

Nagle 算法的作用

  • Nagle 算法:Nagle 算法是一种优化传输的小数据包的算法。它将多个小数据包合并成一个更大的数据包,以减少网络中的包数量,避免网络拥塞。
    • 优点:在一些场景下,减少了网络中小包的数量,提高了网络利用率。
    • 缺点:对于一些实时性要求高的应用(如游戏、即时通讯),Nagle 算法会导致延迟,因为它可能会等待更多小包组合成一个大包再发送。

TCP_NODELAY 选项

  • TCP_NODELAY:禁用 Nagle 算法的选项。设置了 TCP_NODELAY 后,数据会立即发送,不会等待更多数据包的到来。

    • 示例

      1
      2
      int flag = 1;
      setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(int));
    • 作用:在长连接中,如果发送的数据包非常小,启用 TCP_NODELAY 可以避免延时,数据会立即发送。

为什么需要 TCP_NODELAY?

在长连接中,如果发送小数据包时未设置 TCP_NODELAY,Nagle 算法可能会导致数据包的发送延时,典型的就是 40ms。这是因为 Nagle 算法会等待更多的数据包,以便合并成一个更大的包再发送,这对于交互性强的应用(如即时消息)是不利的。

2. TCP_CORK 和 TCP_QUICKACK

TCP_CORK

  • TCP_CORK:一种更加精细控制数据发送的选项。启用 TCP_CORK 后,数据不会立即发送,而是会等到应用程序明确关闭 TCP_CORK 或数据达到一定大小时才发送。通常用于需要发送多个小数据包并希望它们被合并成一个大包的场景。

    • 示例

      1
      2
      3
      4
      5
      int flag = 1;
      setsockopt(sock, IPPROTO_TCP, TCP_CORK, &flag, sizeof(int));
      // write data ...
      flag = 0;
      setsockopt(sock, IPPROTO_TCP, TCP_CORK, &flag, sizeof(int));
    • 作用:在发送多个小数据包时,可以将它们合并发送,减少网络延时。

TCP_QUICKACK

  • 在默认情况下,TCP 协议栈可能会延迟发送确认(ACK)包,以等待更多的数据包到达,从而可以一起确认,这种机制称为延迟确认(Delayed ACK)。延迟确认可以减少网络中的 ACK 包数量,但会增加数据传输的延迟。

  • TCP_QUICKACK:控制接收方何时发送 ACK(确认)包。启用 TCP_QUICKACK 可以使接收方立即发送 ACK,而不是等待默认的延时(通常是 40ms)。

    • 示例

      1
      2
      int flag = 1;
      setsockopt(sock, IPPROTO_TCP, TCP_QUICKACK, &flag, sizeof(int));
    • 作用:减少发送端等待 ACK 的时间,从而减少延时。

3. 延时的原因分析与解决办法

长连接中的 40ms 延时

  • 如果在长连接中出现固定的 40ms 延时,通常是由于 Nagle 算法未被禁用(即未设置 TCP_NODELAY)。在没有设置 TCP_NODELAY 的情况下,Nagle 算法会等待更多数据包来组合成一个大包发送,这会导致延时。
  • 如果设置了 TCP_NODELAY 但仍然有延时,可能是由于 TCP 协议栈中的拥塞控制机制(如拥塞窗口控制),导致数据包发送延迟。

解决办法

  1. 设置 TCP_NODELAY:在长连接中,启用 TCP_NODELAY,禁用 Nagle 算法,确保小数据包能够立即发送,减少延时。
  2. 使用 TCP_CORK 控制数据包发送:在需要发送多个小数据包时,启用 TCP_CORK 以合并数据包,发送完毕后关闭 TCP_CORK,确保数据立即发送。
  3. 设置 TCP_QUICKACK:在服务端启用 TCP_QUICKACK,减少 ACK 的延迟发送,确保发送方尽快收到 ACK,从而减少整体延时。
  4. 协议栈和内核调整:如果上述方法仍然无法完全消除延时,可能需要通过调整 Linux 内核参数或使用定制的内核版本来优化 TCP 协议栈的行为。

实际例子说明

假设你开发了一个即时通讯应用,客户端和服务端之间使用长连接来保持通信。你发现客户端发送小消息时,服务端收到消息的时间总是比预期延迟了大约 40ms。

步骤 1:设置 TCP_NODELAY

为了避免 Nagle 算法引入的延时,你在客户端和服务端都设置了 TCP_NODELAY

1
2
int flag = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(int));

步骤 2:使用 TCP_CORK 合并小数据包

如果你需要在一次通信中发送多个小数据包,希望它们作为一个大包发送,你可以使用 TCP_CORK

1
2
3
4
5
int flag = 1;
setsockopt(sock, IPPROTO_TCP, TCP_CORK, &flag, sizeof(int));
// send multiple small messages...
flag = 0;
setsockopt(sock, IPPROTO_TCP, TCP_CORK, &flag, sizeof(int)); // 取消TCP_CORK,立即发送数据

步骤 3:启用 TCP_QUICKACK

为了确保服务端能够尽快发送 ACK,减少客户端等待时间,你在服务端设置了 TCP_QUICKACK

1
2
int flag = 1;
setsockopt(sock, IPPROTO_TCP, TCP_QUICKACK, &flag, sizeof(int));

TIME_WAIT有什么样的影响?

在 TCP 协议中,当一个连接的任意一方(通常是主动关闭连接的一方)关闭连接后,连接的端口将进入 TIME_WAIT 状态。这个状态的主要目的是确保所有的传输数据包都能被正确处理,防止旧连接的数据包干扰新连接。

  • TIME_WAIT 状态:这是 TCP 连接在关闭后进入的一个等待状态,目的是确保连接的最后一个 ACK(确认)包能够安全发送并被对方接收,以及防止延迟的数据包影响后续的连接。

TIME_WAIT 的影响

1. 端口占用问题

  • 主动关闭连接的端口进入 TIME_WAIT:当一个端口进入 TIME_WAIT 状态时,它会保持一段时间(通常是 60 秒,在一些系统中是 2 * MSL,MSL 是最大报文生存时间)。在这个时间段内,端口不会立即释放。
  • 端口被占用,导致端口耗尽:如果有大量的短连接(即连接建立后很快关闭),并且这些连接都是由客户端主动关闭的,那么这些连接的端口将会进入 TIME_WAIT 状态。由于端口在 TIME_WAIT 状态下无法立即重新使用,频繁的短连接会导致系统的可用端口耗尽。这种情况下,新连接将无法建立,并且会出现 Cannot assign requested address 错误。
    • 示例:假设系统的可用端口范围是 3276861000,总共有不到 3 万个端口。如果每秒有 500 个短连接请求,那么在一分钟内就会有 3 万个端口进入 TIME_WAIT 状态,这将导致端口耗尽,新的连接无法建立。

2. 解决方法

  • 增大可用端口范围:可以通过修改 /proc/sys/net/ipv4/ip_local_port_range 来增大系统分配给客户端的本地端口范围,从而减少端口耗尽的可能性。

    • 示例:将端口范围从默认的32768-61000增加到1024-65535,可以通过以下命令实现:

      1
      echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range
  • 设置 SO_LINGER 选项:通过 SO_LINGER 选项可以控制套接字关闭时的行为。如果设置了 SO_LINGER,可以使套接字在关闭时立即释放资源,而不进入 TIME_WAIT 状态。不过,这种做法在高负载情况下可能会导致数据包丢失。

    • 示例

      1
      2
      struct linger linger_option = {1, 0}; // 开启SO_LINGER,立即关闭连接
      setsockopt(sock, SOL_SOCKET, SO_LINGER, &linger_option, sizeof(linger_option));
  • 启用 tcp_tw_reuse:这个内核参数允许 TCP 端口在 TIME_WAIT 状态下被复用。启用 tcp_tw_reuse 后,客户端可以在新连接时复用那些处于 TIME_WAIT 状态的端口,从而避免端口耗尽。

    • 示例

      1
      echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse
  • 启用 tcp_tw_recycle:这个参数可以动态调整 TIME_WAIT 状态的持续时间,允许端口更快地从 TIME_WAIT 状态中释放。不过,这个选项在 NAT 环境中可能会导致连接问题,因此使用时需谨慎。

    • 示例

      1
      echo 1 > /proc/sys/net/ipv4/tcp_tw_recycle

3. 服务器端的 TIME_WAIT 状态

  • 服务器端通常不会出现端口耗尽问题:服务器端通常使用固定的端口来监听连接请求,所有的连接都是通过这个固定的端口建立的,因此不会因为 TIME_WAIT 状态而导致端口耗尽。然而,大量的 TIME_WAIT 状态可能会占用系统资源(如内存)。

  • TIME_WAIT 槽位溢出:在高负载情况下,服务器端可能会出现 “TCP: time wait bucket table overflow” 的情况。这意味着系统中用于存储 TIME_WAIT 状态的内存槽位已满,系统将直接跳过 TIME_WAIT 状态,发送 RST(重置)包来关闭连接。

    • 示例
      在服务器日志中看到类似以下的警告:

      1
      TCP: time wait bucket table overflow

      这表示系统正在丢弃一些 TIME_WAIT 状态的连接,但一般情况下,这不会对系统造成太大的负面影响。

4. 其他应对措施

  • 优化应用程序:减少短连接的数量,尽量使用长连接或连接池来管理连接,减少因为频繁关闭连接而导致的 TIME_WAIT 状态。
  • 分析 TIME_WAIT 过多的原因:如果在服务器端观察到大量的 TIME_WAIT 状态,可能需要分析是否有异常的短连接行为,或者系统在短时间内是否处理了过多的连接请求。

什么情况下会出现CLOSE_WAIT状态?

在 TCP 协议中,CLOSE_WAIT 状态表示一方已经接收到对方发送的 FIN 包(表明对方请求关闭连接),但本方尚未完全关闭连接。这种状态通常出现在被动关闭的情况下,也就是对方主动关闭连接,而自己还未关闭连接。

CLOSE_WAIT 状态出现的原因

1. 被动关闭连接

  • 被动关闭:当对方(主动关闭的一方)调用 close() 函数时,会发送一个 FIN 包给本方。这时,本方的 TCP 连接进入 CLOSE_WAIT 状态,表示它已经知道对方要关闭连接,但还没有完成自己的关闭操作。
  • 典型流程
    1. 客户端和服务器之间有一个 TCP 连接。
    2. 客户端决定关闭连接,调用 close(),此时发送一个 FIN 包。
    3. 服务器接收到 FIN 包,进入 CLOSE_WAIT 状态。
    4. 服务器需要调用 close() 来释放连接资源,完成整个关闭过程。

2. CLOSE_WAIT 状态持续时间过长的原因

通常情况下,CLOSE_WAIT 状态只是短暂的,因为在大多数应用逻辑中,接收到 FIN 包后会立即关闭连接。但在某些情况下,CLOSE_WAIT 状态可能会持续较长时间,甚至大量积累,导致系统资源紧张。这通常是由于以下几个原因:

(1) 没有正确处理网络异常

  • 未处理 read() 返回值为 0 的情况:当网络连接被对方关闭时,调用 read() 函数会返回 0,表示对方已经关闭连接并且没有更多数据发送过来。如果此时程序没有正确处理这种情况(例如没有调用 close() 关闭连接),就会导致连接处于 CLOSE_WAIT 状态。

    • 示例

      1
      2
      3
      4
      5
      int n = read(sock, buffer, sizeof(buffer));
      if (n == 0) {
      // 对方关闭了连接,应该关闭本方连接
      close(sock);
      }

(2) 句柄泄露

  • 资源泄露:如果程序中出现了文件描述符或套接字句柄的泄露,意味着程序没有正确关闭或释放这些资源。当对方主动关闭连接后,本方未能及时关闭连接,这些连接就会停留在 CLOSE_WAIT 状态。【exit关键字!】
    • 示例:程序中没有调用 close() 来释放已经不再使用的文件描述符,导致资源泄露。

(3) 连接池的使用

  • 连接池技术:在一些应用中,为了提高连接的复用率,可能会使用连接池技术来维护多个长连接。连接池中的连接在需要时被复用,而不是每次请求都重新建立连接。
  • 服务端主动断开空闲连接:某些服务器有机制会主动断开长时间空闲的连接。当服务端断开连接时,客户端可能并未意识到连接已经断开,这时这些连接会停留在 CLOSE_WAIT 状态。
    • 示例:假设一个客户端使用连接池管理多个长连接,而服务端在一定时间内主动断开空闲连接。如果客户端没有主动检查连接的状态(例如通过心跳机制),这些连接可能会一直处于 CLOSE_WAIT 状态。

解决方案

  1. 正确处理网络异常:在应用程序中,确保正确处理 read() 返回值为 0 的情况,并在发现连接已被对方关闭时,及时调用 close() 函数关闭连接。

  2. 避免句柄泄露:在程序中,确保所有的文件描述符和套接字句柄都能正确关闭,防止资源泄露导致连接停留在 CLOSE_WAIT 状态。

  3. 优化连接池的管理:如果使用了连接池技术,确保在每次使用连接前检查其状态。如果服务器可能会主动断开空闲连接,可以在客户端实现健康检查机制(如定期发送心跳包),确保发现连接断开后及时关闭。

    • 示例:在连接池中,定期检查连接的状态,如果发现连接已经断开,主动调用close()

      1
      2
      3
      if (connection_is_broken(sock)) {
      close(sock);
      }
  4. 使用健康检查线程:在连接池中,可以引入健康检查线程定期检查连接的状态,发现无效连接时主动关闭。这可以避免大量的 CLOSE_WAIT 状态积累。

    • 示例:假设有一个连接池管理多个连接,通过一个健康检查线程定期检查每个连接的状态,发现无效连接(处于 CLOSE_WAIT 状态的连接)时主动关闭它们。

顺序发送数据,接收端出现乱序接收到的情况

在网络压力大的情况下(如高并发连接),虽然发送端是按顺序发送数据,但接收端可能会乱序接收到数据。这种情况通常不会发生,但在某些异常情况下确实可能出现。

队列满导致的异常情况

  • 队列满的情况:如果完成队列已经满了,而未完成队列还有空间,新的连接请求会被放入未完成队列。对于客户端来说,它可能已经认为连接建立成功,但实际上三次握手并未完成。
  • 异常情况导致乱序接收
    1. 客户端错误地发起数据传输:客户端认为连接已经建立(即使三次握手未完成),于是开始发送数据。服务器端由于连接未完成,无法立即响应这些数据。
    2. 后续的处理:如果服务器端稍后处理了其他连接,释放了队列中的一些位置,未完成的连接可能会继续完成三次握手,并进入完成队列。
    3. 重传机制:由于客户端发送的数据未得到及时响应,TCP 会进行重传。当连接最终建立后,服务器可能会接收到这些重传的数据包,而这些数据包可能会被乱序接收。
  • 举例
    假设你有一个服务器在高并发下处理连接请求。连接队列已满,客户端 A 认为连接已经建立,于是发送了数据包 1 和数据包 2。由于服务器未完成三次握手,这些数据包暂时没有得到处理。稍后,服务器释放了一些资源,完成了三次握手,并接收了这些数据包。然而,由于网络延迟或重传机制的影响,服务器可能先接收到数据包 2,再接收到数据包 1,从而导致乱序。

解决乱序接收的方案

  • 增加 backlog:调整 listen() 函数的 backlog 参数,确保队列有足够的空间来处理高并发连接。
  • 调整系统参数:通过修改 /proc/sys/net/core/somaxconn 来增加队列的总长度,减少未完成队列满的情况。

【个人理解:上述的乱序问题,TCPSocket会有流重排机制保证有序!!!】

连接偶尔出现超时有哪些可能

1. 服务端处理能力有限

  • 描述:当服务器的 CPU 使用率过高(CPU idle 太低),服务器可能无法及时处理所有传入的请求。这种情况下,服务器可能会变得非常慢,甚至无法响应新的连接请求,最终导致客户端出现连接超时。
  • 示例:假设你有一台服务器在高峰时段处理大量请求,CPU 使用率达到了 100%。此时,服务器可能无法在规定时间内处理新的连接请求,导致客户端连接超时。
  • 解决方案:优化服务器的性能,增加服务器的计算资源,或者通过负载均衡将请求分发到多台服务器上,减轻单台服务器的压力。

2. accept 队列设置过小

  • 描述accept 队列用于存放已经完成三次握手但还未被应用程序处理的连接。如果 accept 队列太小,且短时间内有大量连接请求,队列会被填满,导致新的连接请求被拒绝,客户端可能因此超时。
  • 示例:一个服务器的 backlog 设置为 5,但在高并发场景下,短时间内有数百个客户端同时尝试连接服务器。由于队列太小,很多连接请求会被拒绝或延迟处理,导致客户端超时。
  • 解决方案:通过增大 backlog 参数来增加 accept 队列的大小。可以通过调整 /proc/sys/net/core/somaxconn 系统参数来提高队列的最大值。例如,将 somaxconn 设置为 2048,以允许更多的连接排队等待处理。

3. 程序逻辑问题导致 accept 处理不过来

  • 描述:如果服务器程序的逻辑设计不合理,例如使用简单的线程模型(每个线程一个 accept 调用),但线程被其它耗时操作(如阻塞 I/O)占用,导致 accept 队列中的连接得不到及时处理,最终队列被填满,新的连接请求被丢弃,导致客户端超时。
  • 示例:一个服务器使用单线程模型来处理所有 I/O 操作,包括 accept。由于某个线程被一个长时间的文件操作占用,无法及时处理新的连接请求,导致队列中的连接增多,最终队列溢出,客户端连接超时。
  • 解决方案:优化服务器的并发模型,例如使用多线程或异步 I/O 模型,确保 accept 能够及时处理新的连接请求。还可以通过开启 /proc/sys/net/ipv4/tcp_abort_on_overflow 开关,让服务器在队列溢出时立即返回连接失败,避免客户端长时间等待。

4. 读超时导致的连接关闭和重试

  • 描述:在一些情况下,客户端可能由于读超时而关闭连接并尝试重新连接。如果这种重试行为频繁发生,可能会导致服务器的 accept 队列处理不过来,进一步加剧超时问题。
  • 示例:一个客户端在读取数据时发生超时,关闭连接并立即发起新的连接请求。如果大量客户端都这样操作,服务器的 accept 队列可能会被迅速填满,导致新的连接无法及时处理,客户端再次超时。
  • 解决方案:优化客户端的重试机制,避免频繁的重试操作。此外,服务器可以通过增加 backlog 值或优化处理模型来应对高并发连接。

5. SO_LINGER 设置导致 ACK 丢失

  • 描述:在某些情况下,如果使用了 SO_LINGER 选项关闭连接,可能会导致最后一个 ACK 包丢失,进而导致连接的关闭过程出现问题。虽然这种情况非常罕见,但在高压力环境下可能会导致连接超时。
  • 示例:一个客户端在关闭连接时使用了 SO_LINGER 选项,但由于网络拥堵,最后一个 ACK 包未能到达服务器,导致服务器继续等待 ACK,而客户端则认为连接已经关闭,出现超时现象。
  • 解决方案:在高负载或不稳定的网络环境中,慎用 SO_LINGER 选项,以防止这种极端情况的发生。

6. epoll 使用不当

  • 描述:有些程序使用 epoll 进行事件驱动的 I/O 操作,但没有将 I/O 操作异步化,而是使用阻塞 I/O。这会导致事件循环被阻塞,无法及时处理新的 I/O 事件,进而导致连接超时。
  • 示例:一个服务器使用了 epoll 来监听多个连接的 I/O 事件,但在接收到读写事件后,使用了阻塞式 I/O 操作。由于某个 I/O 操作阻塞了较长时间,其他连接无法及时处理,导致客户端超时。
  • 解决方案:确保在使用 epoll 时,所有 I/O 操作都是非

混用长连接和短连接可能出现的问题

1. 只要有一端采用短连接,总体可以认为是短连接模式

解释:如果客户端使用短连接,而服务器使用长连接,那么连接的生命周期实际上由客户端控制,因为客户端在完成请求后会主动关闭连接。这种情况下,服务器即使想保持长连接,也无法实现,因为客户端已经关闭了连接。

示例

  • 客户端:发送一个请求后立即关闭连接。
  • 服务器:希望保持连接以便处理后续请求,但由于连接已被客户端关闭,服务器只能重新等待新的连接。

潜在问题:服务器可能在短时间内频繁处理建立和关闭连接的过程,增加了开销。

2. 服务端长连接,客户端短连接

当服务端使用长连接,而客户端使用短连接时,会导致以下问题:

  • 服务器端维护不必要的连接状态:客户端关闭连接后,服务端需要等待接收到关闭信号(FIN 包)后才能知道连接已关闭。在此期间,服务器端资源依然被占用。

    示例

    • 客户端发送完请求后立即关闭连接。
    • 服务器端未立即收到关闭信号(FIN 包),继续保持连接,导致资源浪费。
  • 线程模型可能导致资源浪费:如果服务器端使用同步阻塞模式处理连接(如每个线程处理一个连接),那么这个线程在等待关闭信号期间无法处理其他业务逻辑,浪费了系统资源。

    示例

    • 服务器使用同步阻塞模型,每个线程处理一个连接。
    • 客户端关闭连接后,线程被阻塞在等待关闭信号的状态,无法处理其他任务。
  • backlog 设置不足的问题:如果服务器在测试时使用长连接,backlog 设置较小(例如 5),可能不会出现问题。但如果实际运行中被大量短连接访问,backlog 可能不够用,导致连接请求被拒绝。

    示例

    • 测试时服务器设置了 backlog 为 5,处理长连接没有问题。
    • 实际运行过程中,数百个客户端使用短连接频繁连接和断开,导致 backlog 队列被填满,新的连接请求被拒绝。
  • 客户端端口耗尽:如果客户端频繁关闭连接,而没有设置 SO_LINGER 或调整系统参数,客户端可能会进入 TIME_WAIT 状态,导致可用端口耗尽。

    示例

    • 客户端频繁建立和关闭连接,每次关闭后进入 TIME_WAIT 状态。
    • 由于端口资源有限,客户端可能无法再分配新的端口,导致连接失败。

3. 服务端短连接,客户端长连接

如果服务器端使用短连接,而客户端使用长连接,主要问题是:

  • 数据传输失败:客户端可能会试图在同一个连接上发送多个请求,但服务器端关闭了连接,导致客户端的写操作失败。

    示例

    • 客户端使用长连接,想在同一个连接上发送多次请求。
    • 服务器在处理完第一个请求后关闭连接,导致客户端的后续请求发送失败。
  • 资源浪费:客户端在发送请求或读取响应时,如果服务器已经关闭连接,客户端可能会尝试进行无用的操作(如写入已经关闭的连接),浪费系统资源。

    示例

    • 客户端在连接被服务器关闭后仍然尝试发送数据,导致写操作失败并抛出异常。

建议与解决方案

  • 两端保持一致:一般建议客户端和服务器都使用相同的连接策略(即都使用长连接或都使用短连接),以避免上述问题。

  • 配置灵活性:在设计网络应用时,不建议仅通过配置来决定使用长连接或短连接。相反,可以通过协议或数据包的头部信息来指示连接类型,服务器可以根据客户端的连接类型做出相应的处理。

    示例

    • 客户端在发送数据包时,标记自己使用的连接类型(长连接或短连接)。
    • 服务器根据这个标记决定是否保持连接或立即关闭。
  • 日志记录影响:一些框架或库在处理 read() 返回 0(表明连接关闭)时可能会记录大量日志。在高并发场景下,大量日志记录会对性能产生负面影响。因此,使用这些框架时要特别注意日志记录的设置。

    示例

    • 服务器在处理大量短连接时,每次 read() 返回 0 都会记录日志,导致日志文件迅速增大,影响性能。

对于一个不存在的ip建立连接是超时还是马上返回?

  • 连接不存在的 IP 地址:通常会经历一段超时时间,因为系统会等待 TCP 连接的响应。
    • 标准情况:当试图连接一个不存在的 IP 地址时,操作系统会尝试通过网络发送 TCP SYN 包来建立连接。因为这个 IP 地址不存在,网络上不会有对应的主机来响应这个 SYN 请求,因此发起连接的系统会一直等待响应,直到连接超时为止。这种情况下,连接请求会经历一个超时时间,最终返回一个超时错误。
  • 连接存在的 IP 地址但端口无服务:会立即返回 ECONNREFUSED 错误。
  • 网络不可达:在网络限制的情况下,可能会立即返回 ENETUNREACH 错误。

各种超时怎么设置?

  • 连接超时:通过设置 socket 为非阻塞模式,并使用 select()poll() 来控制连接超时时间。
  • 写超时:使用 SO_SNDTIMEO socket 选项来设置写操作的超时时间,通常在内网中可设置为 50ms200ms
  • 读超时:使用 SO_RCVTIMEO socket 选项来设置读操作的超时时间,根据后端处理时间和数据量选择合适的时间,例如 3s 以上。

程序启动监听端口报端口被占用怎么回事?

1. 端口已经被其他程序占用

这是最常见的情况。某个程序已经在该端口上监听,因此你的程序无法再使用这个端口。你可以使用 lsofnetstat 等工具查看哪个进程在使用该端口。

解决方法

  • 查找占用端口的进程:使用以下命令查看哪个进程占用了端口。

    1
    lsof -i :<port_number>
    1
    netstat -tuln | grep :<port_number>

    其中 <port_number> 是你要监听的端口号。

  • 处理进程:查看是否可以终止占用端口的进程,或者选择其他未占用的端口来启动你的程序。

2. TIME_WAIT 状态导致端口暂时不可用

当一个 TCP 连接关闭后,连接的端口会进入 TIME_WAIT 状态,通常持续 60 秒。在此期间,系统会保留该端口,以确保延迟到达的 TCP 包不会被误认为是新连接的一部分。如果你在 TIME_WAIT 状态结束之前试图重新使用同一个端口,并且没有设置 SO_REUSEADDR 选项,系统会拒绝绑定该端口。

解决方法

  • 设置 SO_REUSEADDR 选项:在绑定端口之前设置 SO_REUSEADDR 选项,允许端口在 TIME_WAIT 状态时被重新绑定。

    1
    2
    int opt = 1;
    setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  • 等待 TIME_WAIT 状态结束:也可以简单地等待 TIME_WAIT 状态结束后再重新启动服务。

3. 端口冲突与 SO_REUSEADDR 的使用

即使你已经设置了 SO_REUSEADDR,仍可能出现端口被占用的情况。原因可能是其他程序在服务退出时,使用了相同的端口来发起外部连接(作为客户端),并且这些连接没有设置 SO_REUSEADDR。这种情况下,当你尝试重新启动服务时,可能会出现端口被占用的错误。

解决方法

  • 更改监听端口:如果可能,修改服务的监听端口,避免与系统的本地端口范围冲突。你可以通过查看 /proc/sys/net/ipv4/ip_local_port_range 来确认本地端口范围。

    1
    cat /proc/sys/net/ipv4/ip_local_port_range
  • 避免监听端口在本地端口范围内:确保你的服务监听的端口不在系统的本地端口范围内,这样可以减少与其他程序的端口冲突。

  • 避免设置 SO_REUSEADDR 在客户端:虽然在客户端设置 SO_REUSEADDR 可以解决部分端口占用问题,但可能会引入“自连接”问题,即服务程序可能会不小心连接到自己。因此,最好通过调整端口范围或选择不同的端口来解决冲突。

RST情况总结

场景 1:对端在接收到数据后未读取完毕就关闭连接

描述:

假设你有一个客户端和服务器之间的 TCP 连接,客户端发送数据到服务器。服务器接收到数据,但在未完全读取数据的情况下直接关闭了连接。此时,服务器可能会发送一个 RST 包给客户端。在这种情况下,客户端在后续的 write()read() 操作中可能会收到 ECONNRESET 错误。

示例代码:

服务器端:

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
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr;

listen_fd = socket(AF_INET, SOCK_STREAM, 0);
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);

bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(listen_fd, 10);

conn_fd = accept(listen_fd, (struct sockaddr *)NULL, NULL);

// 只读取部分数据
char buffer[1024];
ssize_t bytes_read = read(conn_fd, buffer, 512);
printf("Read %zd bytes\n", bytes_read);

// 提前关闭连接,未读取完所有数据
close(conn_fd);

close(listen_fd);
return 0;
}

客户端:

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
int main() {
int sockfd;
struct sockaddr_in server_addr;

sockfd = socket(AF_INET, SOCK_STREAM, 0);
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8080);

connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

// 发送数据
char *message = "Hello, Server!";
ssize_t bytes_written = write(sockfd, message, strlen(message));
printf("Sent %zd bytes\n", bytes_written);

// 这里可能会收到 ECONNRESET 错误
char buffer[1024];
ssize_t bytes_read = read(sockfd, buffer, sizeof(buffer));
if (bytes_read < 0) {
if (errno == ECONNRESET) {
printf("Connection reset by peer\n");
} else {
perror("read");
}
} else {
printf("Read %zd bytes\n", bytes_read);
}

close(sockfd);
return 0;
}

处理方法:

  • 检查脏数据:在关闭连接前确保对端数据已经读取完毕。服务器端程序应在关闭连接前读取所有数据,避免发送 RST 包。
  • 延迟关闭连接:在服务器端读取到部分数据后,可以通过使用 shutdown() 函数来优雅地关闭连接,这样可以给对端一个信号,指示它不再发送数据,但仍可以读取剩余的数据。

场景 2:使用 SO_LINGER 选项后立即关闭连接

描述:

SO_LINGER 是一个套接字选项,它决定了在 close() 调用后,TCP 连接的处理方式。如果启用了 SO_LINGER 并设置了一个非零超时时间,close() 将会等待,直到所有未发送的数据都被发送出去或超时。如果 SO_LINGER 设定为立即生效,未发送的数据将被丢弃,并发送 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
25
26
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr;

listen_fd = socket(AF_INET, SOCK_STREAM, 0);
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);

bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(listen_fd, 10);

conn_fd = accept(listen_fd, (struct sockaddr *)NULL, NULL);

// 设置SO_LINGER选项
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0; // 立即发送RST包
setsockopt(conn_fd, SOL_SOCKET, SO_LINGER, &so_linger, sizeof(so_linger));

// 提前关闭连接
close(conn_fd);

close(listen_fd);
return 0;
}

客户端:

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
int main() {
int sockfd;
struct sockaddr_in server_addr;

sockfd = socket(AF_INET, SOCK_STREAM, 0);
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8080);

connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

// 发送数据
char *message = "Hello, Server!";
ssize_t bytes_written = write(sockfd, message, strlen(message));
printf("Sent %zd bytes\n", bytes_written);

// 这里可能会收到 ECONNRESET 错误
char buffer[1024];
ssize_t bytes_read = read(sockfd, buffer, sizeof(buffer));
if (bytes_read < 0) {
if (errno == ECONNRESET) {
printf("Connection reset by peer\n");
} else {
perror("read");
}
} else {
printf("Read %zd bytes\n", bytes_read);
}

close(sockfd);
return 0;
}

处理方法:

  • 避免使用 SO_LINGER 的立即关闭选项:除非有特殊需求,一般不建议使用 SO_LINGER 的立即关闭选项,因为这会导致未完成的数据传输被丢弃。
  • 优雅地关闭连接:使用 shutdown() 函数优雅地关闭连接,确保所有数据都被正确地发送和接收。

shutdown() 函数为啥能优雅关闭,而close不能优雅关闭呢?

  • close() 函数立即结束所有与套接字相关的操作。如果套接字的接收缓冲区中仍有未读取的数据,或者发送缓冲区中有未发送的数据,close() 并不会等待这些数据被处理完毕,再去关闭连接,这就可能导致对端收到 RST 包,连接被“粗暴”地终止。
  • shutdown()SHUT_WR 选项可以实现一个“半关闭”状态,即仅关闭写操作而保留读取功能。这允许本端在发送完所有数据后,通知对端自己不再发送数据(通过发送 FIN 包),但仍然可以继续接收对端的数据。

【使用shutdown更能保证粒度】

场景 3:客户端或服务器在对方未响应的情况下强制关闭连接

描述:

当一方在等待数据时,另一方强制关闭连接(例如使用 kill -9 强制终止进程),那么对端会收到 RST 包,并且可能会遇到 ECONNRESET 错误。

示例代码:

客户端:

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
int main() {
int sockfd;
struct sockaddr_in server_addr;

sockfd = socket(AF_INET, SOCK_STREAM, 0);
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8080);

connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

// 发送数据
char *message = "Hello, Server!";
ssize_t bytes_written = write(sockfd, message, strlen(message));
printf("Sent %zd bytes\n", bytes_written);

// 模拟等待服务器响应
sleep(10);

// 尝试读取服务器响应
char buffer[1024];
ssize_t bytes_read = read(sockfd, buffer, sizeof(buffer));
if (bytes_read < 0) {
if (errno == ECONNRESET) {
printf("Connection reset by peer\n");
} else {
perror("read");
}
} else {
printf("Read %zd bytes\n", bytes_read);
}

close(sockfd);
return 0;
}

服务器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr;

listen_fd = socket(AF_INET, SOCK_STREAM, 0);
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);

bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(listen_fd, 10);

conn_fd = accept(listen_fd, (struct sockaddr *)NULL, NULL);

// 这里故意不进行任何操作,模拟服务器进程被杀
sleep(5);

// 强制终止进程
_exit(0);

close(listen_fd);
return 0;
}

处理方法:

  • 超时检测:在客户端设置合理的超时时间,如果长时间未收到数据,可以主动关闭连接并重新连接。
  • 错误处理:在 read()write() 调用中检查是否发生了 ECONNRESET 错误,并根据需要进行重新连接或其他处理。

场景 4:连接过程中遇到 ECONNRESET

描述:

在 TCP 的三次握手过程中,尤其是在 SYN-ACK 之后,客户端可能会收到 RST 包,这通常是由于服务器的 listen 队列已满或服务器突然关闭了连接引起的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int sockfd;
struct sockaddr_in server_addr;

sockfd = socket(AF_INET, SOCK_STREAM, 0);
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8080);

if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
if (errno == ECONNRESET) {
printf("Connection reset by peer during handshake\n");
} else {
perror("connect");
}
}

处理方法:

  • 重试连接:在 connect() 返回 ECONNRESET 错误时,可以尝试重新连接,或者在合理的时间后再次尝试。
  • 增加 listen 队列长度:服务器端可以通过增加 listen() 中的 backlog 参数来增加 SYN 队列的长度,减少因队列满而导致的 RST 包。

对一些常见错误号的分析

image-20240902163228800

socket可写就表示连接成功吗

答案当然是不是啦

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

我们先用 nc 命令启动一个服务器程序:

1
nc -v -l 0.0.0.0 port

然后编译客户端程序并执行:

1
2
 penge@penge-virtual-machine  ~/Desktop/test  ./main  
[select] connect to server successfully.

我们把服务器程序关掉,再重新启动一下客户端,这个时候应该会连接失败,程序输出结果如下:

1
2
 penge@penge-virtual-machine  ~/Desktop/test  ./main  
[select] connect to server successfully.

怎么回事呢?

章节博客给出了答案:

  • 在 Linux 系统上一个 socket 没有建立连接之前,用 select 函数检测其是否可写,你也会得到可写得结果,所以上述流程并不适用于 Linux 系统。正确的做法是,connect 之后,不仅要用 select 检测可写,还要检测此时 socket 是否出错,通过错误码来检测确定是否连接上,错误码为 0 表示连接上,反之为未连接上。

完整代码

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/epoll.h>

#define SERVER_ADDRESS "127.0.0.1"
#define SERVER_PORT 3000
#define EPOLL_MAX_EVENTS 10
#define SEND_DATA "helloworld"

// 设置 socket 为非阻塞模式
int set_nonblocking(int sockfd)
{
int flags = fcntl(sockfd, F_GETFL, 0);
if (flags == -1)
{
return -1;
}
return fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
}

int main(int argc, char *argv[])
{
// 1. 创建一个 socket
int clientfd = socket(AF_INET, SOCK_STREAM, 0);
if (clientfd == -1)
{
std::cerr << "create client socket error." << std::endl;
return -1;
}

// 2. 设置 socket 为非阻塞
if (set_nonblocking(clientfd) == -1)
{
std::cerr << "set socket to nonblock error." << std::endl;
close(clientfd);
return -1;
}

// 3. 连接服务器
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
serveraddr.sin_port = htons(SERVER_PORT);

int ret = connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (ret == 0)
{
// 如果连接立即成功,则直接返回
std::cout << "connect to server successfully (immediately)." << std::endl;
close(clientfd);
return 0;
}
else if (ret == -1 && errno != EINPROGRESS)
{
// 如果连接失败,并且错误不是 EINPROGRESS,则直接退出
std::cerr << "connect error." << std::endl;
close(clientfd);
return -1;
}

// 4. 创建 epoll 实例
int epollfd = epoll_create1(0);
if (epollfd == -1)
{
std::cerr << "epoll_create1 error." << std::endl;
close(clientfd);
return -1;
}

// 5. 将 socket 注册到 epoll 实例中,监听可写事件
struct epoll_event ev, events[EPOLL_MAX_EVENTS];
ev.events = EPOLLOUT; // 关注可写事件,表示连接完成
ev.data.fd = clientfd;

if (epoll_ctl(epollfd, EPOLL_CTL_ADD, clientfd, &ev) == -1)
{
std::cerr << "epoll_ctl error." << std::endl;
close(clientfd);
close(epollfd);
return -1;
}

// 6. 等待 epoll 事件
int nfds = epoll_wait(epollfd, events, EPOLL_MAX_EVENTS, 3000); // 3 秒超时
if (nfds == -1)
{
std::cerr << "epoll_wait error." << std::endl;
close(clientfd);
close(epollfd);
return -1;
}
else if (nfds == 0)
{
// 如果超时
std::cerr << "connection timeout." << std::endl;
close(clientfd);
close(epollfd);
return -1;
}

// 7. 检查 epoll 返回的事件
for (int i = 0; i < nfds; ++i)
{
if (events[i].data.fd == clientfd && (events[i].events & EPOLLOUT))
{
// 当 socket 可写时,而不应该直接默认连接成功!
// std::cout << "connect to server successfully." << std::endl;
// 当 socket 可写时,检查连接是否成功
// int error = 0;
// socklen_t len = sizeof(error);
// if (getsockopt(clientfd, SOL_SOCKET, SO_ERROR, &error, &len) == -1)
// {
// std::cerr << "getsockopt error." << std::endl;
// close(clientfd);
// close(epollfd);
// return -1;
// }

// if (error == 0)
// {
// // 连接成功
// }
// else
// {
// // 连接失败
// std::cerr << "connection failed with error: " << strerror(error) << std::endl;
// }
}
}

// 8. 关闭 socket 和 epoll 实例
close(clientfd);
close(epollfd);

return 0;
}

服务器端发数据时,如果对端一直不收,怎么办?

  1. 设置发送缓冲区大小上限
    • 在将数据放入发送缓冲区之前,先检查缓冲区的剩余空间。如果剩余空间不足以容纳新数据,则认为连接出现问题,关闭该连接并回收资源。
  2. 定时检查发送缓冲区
    • 设置定时器,定期检查各连接的发送缓冲区。如果发现某个连接的发送缓冲区中有数据积压超过一定时间(如3秒),则认为该连接出现问题,关闭该连接并回收资源。

客户端程序中的 socket 是否可以调用 bind 函数

很多人觉得只有服务器程序可以调用 bind 函数绑定一个端口号,其实不然,在一些特殊的应用中,我们需要客户端程序以指定的端口号去连接服务器,此时我们就可以在客户端程序中调用 bind 函数绑定一个具体的端口。

一般情况下:

客户端程序通常不需要显式地调用 bind() 函数。原因如下:

  1. 隐式绑定
    • 当客户端调用 connect() 函数时,如果没有显式调用 bind(),操作系统会自动选择一个临时的本地 IP 地址和一个临时的本地端口,并隐式地绑定到 socket。这样,客户端就可以通过该 socket 与服务器进行通信。
    • 这对于大多数客户端应用程序来说是足够的,客户端通常不关心使用哪个本地端口,只要能成功连接服务器即可。
  2. 动态端口分配
    • 如果客户端不调用 bind(),操作系统会在客户端发起连接时自动分配一个空闲的动态端口。这种方式对客户端来说是最常见的,因为客户端通常只需要知道服务器的 IP 和端口,而不关心自己使用的端口。

特殊情况:

尽管通常不需要,但在某些特殊情况下,客户端可能需要显式地调用 bind() 函数。以下是一些可能的场景:

  1. 指定本地 IP 地址或端口

    • 如果客户端有多个网卡(网络接口),或者绑定了多个 IP 地址(例如物理网卡和虚拟网卡),并且希望使用特定的本地 IP 地址或特定的本地端口进行通信,则需要使用 bind() 来绑定 socket 到该特定的本地 IP 地址或端口。

    • 例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      struct sockaddr_in localAddr;
      memset(&localAddr, 0, sizeof(localAddr));
      localAddr.sin_family = AF_INET;
      localAddr.sin_addr.s_addr = inet_addr("192.168.1.100"); // 本地 IP 地址
      localAddr.sin_port = htons(12345); // 本地端口

      if (bind(clientfd, (struct sockaddr*)&localAddr, sizeof(localAddr)) == -1) {
      perror("bind failed");
      close(clientfd);
      return -1;
      }

如果端口被占用则不行。

  • 端口冲突:如果客户端显式调用 bind() 绑定到一个特定的端口,并且该端口已经被其他进程占用,bind() 会失败,从而导致连接失败。特别是在高并发的网络环境下,动态端口分配是更好的选择,因为操作系统会自动选择一个未使用的端口。

网络通信中收发数据的正确姿势

和muduo一致。

本篇博客根据[网络编程常见问题总结 ]问题进行扩展学习!