Linux TCP 编程

TCP(Transmission Control Protocol) 是由 IETF 的 RFC 793 定义的一种面向连接的、可靠的、基于字节流的传输层通信协议。

TCP 报文段

TCP 数据被封装在一个 IP 数据报中,如下图所示:

TCP数据在IP数据报中的封装

下图是 TCP 首部的数据格式,如果不计任选字段,它通常是20个字节:

TCP包首部

下面介绍重要的几个数据:

  • 32位序号:表示数据当前发送的第一个字节在字节流中的序号
  • 32位确认号:表示发送端所期望收到的下一个序号,因此该序号位上一次收到的序号加一
  • 6个特殊标志bit: (按照排列顺序)
    • URG: 紧急指针有效
    • ACK:确认序号有效
    • PSH:接收方应该尽快将这个报文段交给应用层
    • RST:重建连接
    • SYN:同步序号,用来发起一个连接
    • FIN:发送端完成任务,关闭发送端到接收端连接

其余的解释请参考 TCP/IP 协议详解。

TCP 连接的状态图

TCP状态图

TCP 连接的建立与终止

TCP 是一个面向连接的通信协议,这要求通信双方在进行通信之前,需要先建立其连接。在常见的客户端、服务器模式的程序中,通常是服务器绑定端口,并在该端口上监听客户端连接请求;客户端主动向服务器发起连接请求,待服务器响应后,双方建立起一条通信链路。

建立

TCP 连接建立时通信双方的分组报文如下图所示:

TCP 三路握手

如图所示,客户端发起 connect,此时客户端发送 SYN 报文;服务端使用 accept 接受该连接请求,同时反馈 SYNACK;等到客户端相应了 ACK后,双方建立起完整连接。

将上述过程映射到 TCP 状态图上进行观察,在服务器端:

  • 刚开始服务器处于 CLOSED 状态
  • 服务器初始化时绑定了具体的端口,并使用 listen 监听该端口,进入了 LISTEN 状态
  • 服务端接收到了来自客户端的 SYN 请求,发送 SYNACK 给客户端,然后进入 SYN_RCVD 状态
  • 当服务端接收到了客户端紧接着到达的 ACK 时,进入 ESTABLISHED 状态

客户端方面:

  • 刚开始同样处于 CLOSED 状态
  • 应用主动调用 CONNECT 发起连接,发送 SYN 给服务器,然后进入 SYN_SEND 状态
  • 当接受到服务器的 SYNACK 后,发送对应的 ACK 给服务器,并进入 ESTABLISHED 状态

当双方都进入 ESTABLISHED 状态时,表示连接已经建立成功。

当然,客户端在发送了 SYN 后,等待超时,并重试几次后,便会触发 Timeout 进入 CLOSED,在应用层则表示为 connect 失败。

同时建立连接

与常见的模式不同的是,TCP 允许连接双方同时发起建立连接的请求。此时分组报文如下图所示:

TCP同时建立连接

连接双方同时发送 SYN 到对方,然后同样地返回 SYNACK 给对方。将该过程对应到状态图中:

  • 刚开始同样处于 CLOSED 状态
  • 应用主动调用 CONNECT 发起连接,发送 SYN 给服务器,然后进入 SYN_SEND 状态
  • 接收到 SYN 后进入 SYN-RCVD 状态
  • 接收到 ACK 后建立连接,进入 ESTABLISHED 状态

关闭连接

FIN 用于通知对方关闭本方向的连接。由于 TCP 是一个全双工的通信协议,像管道一样,支持关闭某一方向上的连接,所以在 TCP 中关闭连接需要双方都发送 FIN 报文。此时分组报文如下图所示:

TCP关闭时的分组交换

当某一方关闭连接时,发送 FIN 给另一方,对方回复 ACK 后,同时也发送 FIN;等到双方都收到最后的 ACK 后,连接关闭。当然,如果另一方只回复了 ACK 而没有发起 FIN,则表示对方仍然想要发送数据,这种情况称为 TCP 的半关闭。只有当双方都发送了 FIN 并接收到对方的 ACK 后,才算真正的连接关闭。所以上图中 Server 端的 FIN 包可以在接收到 Client 的 FIN 包后,隔一段时间再发送。

在状态图中对应了主动关闭和被动关闭,首先观察主动关闭:

  • 当应用使用 close 后,发送 FIN 给对方,并由 ESTABLISHED 状态进入 FIN_WAIT_1 状态
  • 如果收到 ACK 后,进入 FIN_WAIT_2 状态
  • 此时等待对方的 FIN 到达,并发送 ACK 给对方,进入 TIME_WAIT 状态
  • 如果在 FIN_WAIT_1 状态直接接收到 FINACK,则直接进入 TIME_WAIT 状态
  • TIME_WAIT 状态等待了 2 MSL 后,进入 CLOSED 状态,此时连接关闭

被动关闭则简单得多:

  • 当收到对方的 FIN 后,发送 ACK 并由 ESTABLISHED 进入 CLOSE_WAIT 状态
  • 等到用户层发出 close 后,发送 FIN 同时进入 LAST_ACK 状态
  • 等到接收到对方的 ACK 后,进入 CLOSED 状态,连接关闭

TIME_WAIT 状态可能时状态图中最不易懂的地方,它也被称为 2 MSL 状态。每一个具体 TCP 实现必须选择一个报文段最大生存时间 MSL(Maximum Segment Lifetime),表示任何报文段被丢弃前能在网络中存活的时间。当 TCP 执行主动关闭并发送了 ACK 给对方进入 TIME_WAIT 状态后,该连接必须在 TIME_WAIT 状态停留 2 倍的 MSL 。这样可以保证 TCP 在超时后再次发送最后的 ACK 以防止这个 ACK 丢失。使用 2 MSL 的另外一点是,当前的 socket 关闭后,可能立即被用于建立另一个 TCP 连接,而网络中可能存在着尚未到达具有 TIME_WAIT 状态一方的包,需要保证这些包不会影响到接下来即将建立的连接。2 MSL 的时间间隔中不允许 socket 被重新使用,同时也能够保证消耗掉网络中的包。所以 TIME_WAIT 状态存在有两个理由:

  • 可靠地实现 TCP 全双工连接的终止
  • 允许老的重复的包在网络中消逝

关于保证 TCP 超时后再次发送最后的 ACK 进行补充:在tcp协议中处于last_ack状态的连接,如果一直收不到对方的ack,会一直处于这个状态吗?- 知乎

同时关闭

如 TCP 同时打开一样,TCP 也存在同时关闭状态,此时双方均进入 FIN_WAIT_1 状态,并再接收到 FIN 后进入 CLOSING 状态。等到接收到 ACK 后,则进入 TIME_WAIT 状态。

TCP 复位

在 TCP 首部中 RST 位表示表示复位,用来异常的关闭连接,在 TCP 的设计中它是不可或缺的。发送 RST 包关闭连接时,不必等缓冲区的包都发出去,直接就丢弃缓存区的包发送 RST 包。而接收端收到 RST 包后,也不必发送 ACK 包来确认。TCP 处理程序会在自己认为的异常时刻发送 RST 包。

下面来分析一下 TCP 中 RST 包出现的主要场景。

到不存在的端口的连接请求

产生复位的一种常见情况是当连接请求到达时,目的端口没有进程在监听。例如,A 向 B 发起连接,但 B 之上并未监听相应的端口,这时 B 操作系统上的 TCP 处理程序会发 RST 包。

异常终止一个连接

终止一个连接的正常方式是一方发送 FIN,这也成为有序释放,因为在所有排队数据都已经发送之后才发送 FIN ,正常情况下没有数据丢失。但是也可以使用 RST 来直接释放一个连接,这种方式称为异常释放。使用异常终止有两个有点:

  • 丢弃任何待发送数据并立即发送复位报文段
  • RST 的接收方会区分另一端是异常还是正常关闭

检测半打开连接

如果一方已经关闭或异常终止而另一方还不知道,这样的 TCP 连接被称为半打开的。比如系统断电而不是正常结束就可能造成半打开的连接。如果发生异常的一方重启后重新连接到远程服务,则会发生错误,此时远程服务器会发送 RST 关闭此连接。比如,AB 正常建立连接了,正在通讯时,A 向 B 发送了 FIN 包要求关连接,B 发送 ACK 后,网断了,A 通过若干原因放弃了这个连接(例如进程重启)。网通了后,B 又开始发数据包,A 收到后表示压力很大,不知道这野连接哪来的,就发了个 RST 包强制把连接关了,B 收到后会出现 connect reset by peer 错误。

Socket TCP 编程

Socket 中文称为套接字,用于应用程序发出或相应网络请求。POSIX 提供了一套 Socket 编程标准 API,在进一步之前,先看看 Socket TCP 编程流程:

TCP Socket 编程流程

简单的 Socket 编程流程如上图所示,创建了 socket 后的客户端通过 connect 操作连接到了处于 listen 的服务器;当服务器使用 accept 接受新的连接请求后,双方建立起了连接,通过 readwrite 传输数据;最后使用 close 来关闭连接。

简单的例子

进一步深入了解如何使用 socket 编程前,先来看看例子:

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
#include <stdio.h>
#include <error.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>

typedef struct sockaddr *PSA;

int main(int argc, char **argv)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return -1;
}

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_len = sizeof(addr);
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");

if (connect(fd, (PSA) &addr, sizeof(addr)) < 0) {
perror("connect");
return -1;
}

// do something
close(fd);
}

上面是客户端,以及下面的服务器:

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
#include <stdio.h>
#include <error.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>

typedef struct sockaddr *PSA;

int main(int argc, char **argv)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return -1;
}

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_len = sizeof(addr);
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (bind(fd, (PSA) &addr, sizeof(addr)) < 0) {
perror("bind");
return -1;
}

if (listen(fd, 10) < 0) {
perror("listen");
return -1;
}

struct sockaddr_in clientaddr;
socklen_t clientlen;
for (;;) {
int clientfd = accept(fd, (PSA) &clientaddr, &clientlen);
// do something
close(clientfd);
}
}

这两段代码随手写的,没有经过验证。

上述代码是一个基本的客户端服务器 socket 编程模板,它展示了 socket 编程常用的 API 的用法。下面来看看如何使用 socket 编程 API。

套接字地址

每一个 socket 对象在使用时都需要和一个具体的 socket 地址绑定,而每一个协议簇都有自己的套接字地址结构。这些结构以 sockaddr 开头,并以协议簇的唯一后缀结尾。

socket API 兼容多种协议簇。在实现上以一种通用套接字地址结构作为所有套接字地址的基类。(实际上在C语言中可以使用 void* 作为参数,不过 socket API 定义在 ANSI C 之前,此时还没有 void*。)

通用套接字地址结构

<sys/socket.h> 头文件中定义了一个通用的套接字地址结构。

1
2
3
4
5
struct sockaddr {
uint8_t sa_len;
sa_family_t sa_family;
char sa_data[14];
};

对于应用开发人员来说,需要的是使用 API 时,强制将其他协议簇的地址结构指针转换为通用地址结构指针。也就是说:通用 socket 地址结构唯一的作用就是用于对特定协议的地址结构执行强制类型转换,以统一类型

IPv4 地址结构

在实际编程中容易接触到的时 IP 协议簇,而 IP 协议簇又分为 IPv4 和 IPv6 两个版本。先看 IPv4 的 socket 地址结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct in_addr {
in_addr_t s_addr; /* 32 bit IPv4 address,
in network byte ordered */
};

struct sockaddr_in {
uint8_t sin_len; /* length of structure (16) */
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 16 bit port number,in network byte ordered */
struct in_addr sin_addr;/* 32 bit IPv4 address */
char sin_zero[8]; /* unused */
};

该结构定义在文件 <netinet/in.h> 中,编程人员主要关心:sin_familysin_addrsin_portsin_family 表示使用的使用的协议簇。sin_addrsin_port 表示具体的 socket 地址,需要注意两者的数据都必须是网络字节序。关于网络字节序可以参考网络字节序-CSDN

IPv6 地址结构

IPv6 地址结构和 IPv4 地址结构定义在同一文件中,其内部布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct in6_addr {
uint8_t s6_addr; /* 128 bit IPv6 address,
in network byte ordered */
};

struct sockaddr_in6 {
uint8_t sin6_len; /* length of structure (28) */
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port */
uint32_t sin6_flowinfo; /* flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* set of interfaces for a scope */
}

地址相关 API

在使用的时候,需要在网络字节序和本地字节序之间进行转换,而 POSIX 提供了对应的字节序转换方法:

1
2
3
4
5
6
7
8
9
#include <netinet/in.h>

// 主机到网络
uint16_t htons(uint16_t val);
uint32_t htonl(uint32_t val);

// 网络到主机
uint16_t ntohs(uint16_t val);
uint32_t ntohl(uint32_t val);

除了提供字节序转换方法外,标准还提供了点分制地址到网络序的二进制值之间进行转换的方法:

1
2
3
4
5
6
7
8
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
in_addr_t inet_network(const char *cp);
char *inet_ntoa(struct in_addr in);

inet_atoncp 对应的点分制的地址转换为网络地址并保存在 inp 中,如果地址正确则返回非零,否则返回0。

inet_addr 则是直接返回网络二进制地址,如果地址错误返回 INADDR_NONE

inet_networkinet_addr 一样,但是返回的地址是主机序的二进制地址,如果错误返回 -1。

inet_ntoa 这个函数和前面的函数作用相反,是将网络序二进制地址转换为点分制的地址。需要注意的是如果再次调用该函数返回的 buffer 会被覆盖。

上面部分的内容是针对 IPv4 地址,对于 IPv6,标准提供了新的函数。

1
2
3
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

这两个函数同时支持 IPv4 和 IPv6 ,所以在使用中,建议使用这两个函数替代原有的函数。对于第一个参数 af 表示具体的协议:AF_INETAF_INET6,如果不是这两个值,则返回一个错误,并将 errno 设置成 EAFNOSUPPORT

第一个函数尝试转换字符串对应的地址,并将得到的二进制数据保存到 dst,若成功返回 1,否则表示对应的 family 协议的字符串不是有效的,返回 0。

第二个函数进行了相反的转换,size 用于保存目标存储单元的大小,用于防止缓冲区溢出。标准定义了一个具体的数值来帮助开辟缓冲区空间:

1
2
3
4
#include <netinet/in.h>

#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46

如果缓冲区过小,那么返回一个空指针,并将 errno 设置为 ENOSPC。调用成功后,返回 dst

// TODO: IPv4 和 IPv6 混合

socket API

socket 函数

1
2
3
4
#include <sys/types.h>   
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

使用 socket 函数创建一个通信的 socket,并返回其描述符。

domain 参数指定具体通行领域,用来告知具体的通信协议,TCP 中使用到了:AF_INETAF_INET6type 参数指定通信的语义,TCP 中主要关心 SOCK_STREAM —— 提供顺序,可靠的双向基于连接的字节流。可能支持带外数据传输机制。protocol 参数在此处只需要填 IPPROTO_TCP,表示使用 TCP 传输协议。

Since Linux 2.6.27, the type argument serves a second purpose: in addition to specifying a socket type, it may include the bitwise OR of any of the following values, to modify the behavior of socket():

SOCK_NONBLOCK Set the O_NONBLOCK file status flag on the new open file description. Using this flag saves extra calls to fcntl to achieve the same result.

SOCK_CLOEXEC Set the close-on-exec (FD_CLOEXEC) flag on the new file descriptor. See the description of the O_CLOEXEC flag in open for reasons why this may be useful.

当函数成功后,将返回新套接字的文件描述符。出错时返回-1,并适当设置 errnoerrno 的具体错误值可能如下:

  • EAFNOSUPPORT 该实现不支持指定的地址族。
  • EINVAL 未知协议或协议族不可用或类型中的标记无效。
  • EMFILE 已达到打开文件描述符数的限制。
  • ENOBUFS or ENOMEM 内存不足可用。在释放足够的资源之前,无法创建套接字。
  • EPROTONOSUPPORT 该域中不支持协议类型或指定的协议。

bind 函数

1
2
3
4
#include <sys/types.h>          
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind 将由 addr 指定的地址分配给文件描述符 sockfd 引用的套接字。addrlen 指定 addr 指向的地址结构的大小(以字节为单位)。 传统上,这个操作称为“为套接字分配名称”。通常需要在 SOCK_STREAM 套接字接收(accept)连接之前使用 bind 分配本地地址。当函数成功后,将返回新套接字的文件描述符。成功返回 0 ,出错时返回-1,并适当设置 errnoerrno 的具体错误值可能如下:

  • EADDRINUSE 地址已经被使用了。
  • EBADF sockfd 不是不可用。
  • EINVAL 当前 socket 已经绑定过地址了。或者 addrlen 错误,或者 addr 不是合法的地址。
  • ENOTSOCK sockfd 不是一个 socket 描述符。

在通常的使用中,客户端程序没有调用 bind 直接使用 connect 创建连接,因为 socket 从系统内部选择一个端口组成 addr ,并将之与对应的 socket 绑定。也就是说,bind并不是仅仅用于 listen,也可以配合 connect 使用。如果没有使用 bind 绑定地址,可以使用 getsockname 获取地址信息。

connect 函数

1
2
3
4
#include <sys/types.h>          
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

connect 系统调用将文件描述符 sockfd 引用的套接字连接到 addr 指定的地址。addrlen 参数指定 addr 的大小。对于 TCP ,connect 触发三路握手,并在建立连接成功或者发生错误时返回,其中可能有以下几种情况:

  • EADDRINUSE 地址已经被使用了。
  • EBADF sockfd 不是不可用。
  • timeout 如果 TCP 没有收到 SYN 分节的响应,则返回 ETIMEOUT。
  • reset 如果对方相应的时 RST ,表示服务器主机在我们指定的端口上没有程序监听,这是一种硬错误(hard error),此时返回 ECONNREFUSED。
  • unreachable 如果目标主机不在当前网络中,发生了 ICMP 错误,则认为是一种软错误(soft error),并返回 EHOSTUNREACH 或 ENETUNRECH 错误。

如果 connect 出现错误而失败,则不能再重新使用,需要使用 close 关闭。如果需要重新连接,则需要从头创建描述符。

listen 函数

listen 函数仅仅由 TCP 服务器调用,它做两件事情:

  1. socket 建立的主动 socket (默认为主动)转换为被动的 socket,因此此 socket 可以使用 accept 来接收到来的连接请求。然后 socket 对应的状态由 CLOSED 状态变为 LISTEN 状态
  2. 它指定了 socket 在内核中的排队连接的数量
1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>

int listen(int sockfd, int backlog);

sockfd 为对应的 socket 描述符。backlog 参数定义 sockfd 的挂起连接队列可能的最大长度。 如果连接请求在队列已满时到达,则客户端可能会收到带有 ECONNREFUSED 指示的错误,或者如果底层协议支持重传,则该请求可能会被忽略,以便以后重新尝试连接成功。

在 UNP 一书中说:内核为任何一个监听套接字维护两个队列。

  • 未完成连接队列:其中的套接字表示正在完成三路握手过程。这些套接字此时处于 SYN_RCVD 状态。
  • 已经完成队列:表示这些套接字已经完成了三路握手过程,处于 ESTABLISHED 状态,等到 accept 读取。

成功返回 0 ,出错时返回-1,并适当设置 errnoerrno 的具体错误值可能如下:

  • EADDRINUSE 地址已经被使用了。
  • EBADF sockfd 不是不可用。
  • ENOTSOCK sockfd 不是一个 socket 描述符。
  • EOPNOTSUPP sockfd 对应的 socket 不支持 listen 操作。

accept 函数

1
2
3
4
#include <sys/types.h>     
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept 函数从 sockfd已经完成队列中取出 socket。addr 表示接受的远程地址,addrlen 则是地址空间长度。在成功时,这些系统调用返回一个非负整数,它是接受的套接字的描述符。 出错时返回-1,并适当设置errno。在 linux 中还有一个新版本的函数 accept4

1
2
3
4
> #define _GNU_SOURCE             /* See feature_test_macros(7) */
> #include <sys/socket.h>
> int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);
>

If flags is 0, then accept4() is the same as accept(). The following values can be bitwise ORed in flags to obtain different behavior:

  • SOCK_NONBLOCK Set the O_NONBLOCK file status flag on the new open file description. Using this flag saves extra calls to fcntl(2) to achieve the same result.

  • SOCK_CLOEXEC Set the close-on-exec (FD_CLOEXEC) flag on the new file descriptor. See the description of the O_CLOEXEC flag in open(2) for reasons why this may be useful.

getsockname 和 getpeername 函数

这两个函数分别返回与某个 socket 关联的本地地址,以及远程地址。

1
2
3
4
#include <sys/socket.h>

int getsockname(int sockfd, int sockaddr *localaddr, socklen_t *addrlen);
int getpeername(int sockfd, int sockaddr *remoteaddr, socklen_t *addrlen);

两个函数的用法一致。如果正确返回 0 ,错误返回 -1,并设置 errno

关闭 socket 连接

终止 socket 连接的通常方法是使用 close 函数,不过 close 函数有两个限制,而 shutdown 则可以避免:

  • close 只是将引用计数减一,只有计数为 0 时才关闭套接字。而 shutdown 则可以不管引用技术直接触发 TCP 的正常连接终止序列。
  • close 会将读写两个方向的连接都关闭,而某些情况下 TCP 需要保持一方的连接。而 shutdown 则可以关闭某一方的连接,也就是 TCP 的半关闭状态。

shutdown 函数的原型如下:

1
2
3
#include <sys/socket.h>

int shutdown(int sockfd, int how);

该函数的行为依赖于 how 的值:

  • SHUT_RD 关闭本端的读这一半,socket 不再接收新数据,同时丢弃缓冲区中的数据。
  • SHUT_WR 关闭写的这一半,当前缓冲区的数据将被发送。此时进程无法再对该 socket 进行写操作。
  • SHUT_RDWR 将读写都关闭,这等价于先调用 shutdown(fd, SHUT_RD) 然后调用 shutdown(fd, SHUT_WR)

要注意,shutdown(fd, SHUT_RDWR)仅仅是断开了 socket 连接,但是并不意味着 socket 被关闭了,此时还需要调用 close(fd) 来释放文件描述符,否则会造成描述符泄露

socket options

有多种办法获取或设置 socket 的选项:

  • getsockoptsetsockopt 函数
  • fcntl 函数
  • ioctl 函数

getsockopt & setsockopt

这两个函数仅用于 socket:

1
2
3
4
5
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

其中 sockfd 必须指向打开的套字符, level 指定如何解释后面的选项;optname 则是具体的选项内容;optval 指向某个具体变量,setsockoptoptval 指向的变量中读值,getsockopt 则将值写入 optval;显而易见的 optlenoptval 所指向变量的大小。

level 分别指出 optnamesocketip 还是 TCP 的选项。首先来看 socketSOL_SOCKET 所对一个的选项,只列出了重要的部分:

  • SO_REUSEADDR & SO_REUSEPORTSO_REUSEADDR 主要有两个工作:1、改变了在处理源地址冲突时对通配地址(“any ip address”)的处理方式的处理方法;2、处于TIME_WAIT状态中的socket可以重用。关于这两者的行为及其异同不详述,请参考SO_REUSEADDR & SO_REUSEPORT 的异同

  • SO_RECVBUF / SO_SNDBUF 先明确一个概念:每个TCP socket在内核中都有一个发送缓冲区和一个接收缓冲区,TCP的全双工的工作模式以及TCP的滑动窗口便是依赖于这两个独立的buffer以及此buffer的填充状态。接收缓冲区把数据缓存入内核,应用进程一直没有调用read进行读取的话,此数据会一直缓存在相应socket的接收缓冲区内。再啰嗦一点,不管进程是否读取socket,对端发来的数据都会经由内核接收并且缓存到socket的内核接收缓冲区之中。read所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的buffer里面,仅此而已。进程调用send发送的数据的时候,最简单情况(也是一般情况),将数据拷贝进入socket的内核发送缓冲区之中,然后send便会在上层返回。换句话说,send返回之时,数据不一定会发送到对端去(和write写文件有点类似),send仅仅是把应用层buffer的数据拷贝进socket的内核发送buffer中。如果应用进程一直没有读取,buffer满了之后,发生的动作是:通知对端TCP协议中的窗口关闭。这个便是滑动窗口的实现。保证TCP套接口接收缓冲区不会溢出,从而保证了TCP是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。这就是TCP的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方TCP将丢弃它。

  • SO_KEEPALIVE SO_KEEPALIVE 如果一方已经关闭或异常终止连接,而另一方却不知道,我们将这样的TCP连接称为半打开的。TCP通过保活定时器(KeepAlive)来检测半打开连接。设置该选项后,如果2小时内在此套接口的任一方向都没有数据交换,TCP 就自动给对方发一个保持存活探测分节(keepalive probe)。这是一个对方必须响应的TCP分节.它会导致以下三种情况:

    1. 对方接收一切正常:以期望的 ACK 响应,2小时后,TCP 将发出另一个探测分节。
    2. 对方已崩溃且已重新启动:以 RST 响应。套接口的待处理错误被置为 ECONNRESET,套接口本身则被关闭。
    3. 对方无任何响应:源自 berkeley 的 TCP 发送另外 8 个探测分节,相隔 75 秒一个,试图得到一个响应。在发出第一个探测分节 11 分钟 15 秒后若仍无响应就放弃。套接口的待处理错误被置为 ETIMEOUT,套接口本身则被关闭。如 ICMP 错误是“host unreachable(主机不可达)”,说明对方主机并没有崩溃,但是不可达,这种情况下待处理错误被置为 EHOSTUNREACH

      有关 SO_KEEPALIVE 的三个参数详细解释如下:

    • tcp_keepalive_intvl: 保活探测消息的发送频率。默认值为 75s。发送频率tcp_keepalive_intvl 乘以发送次数 tcp_keepalive_probes ,就得到了从开始探测直到放弃探测确定连接断开的时间,大约为11min。
    • tcp_keepalive_probes,TCP 发送保活探测消息以确定连接是否已断开的次数。默认值为9(次)。注意:只有设置了 SO_KEEPALIVE 套接口选项后才会发送保活探测消息。
    • tcp_keepalive_time,在 TCP 保活打开的情况下,最后一次数据交换到 TCP 发送第一个保活探测消息的时间,即允许的持续空闲时间。默认值为 7200s(2h)。
  • SO_LINGER SO_LINGER 将决定系统如何处理残存在套接字发送队列中的数据。处理方式无非两种:丢弃或者将数据继续发送至对端,优雅关闭连接。事实上,SO_LINGER 并不被推荐使用,大多数情况下我们推荐使用默认的关闭方式。关于 SO_LINGER 具体描述可以参考:SO_LINGER 选项设置

  • SO_RCVLOWAT / SO_SNDLOWAT 分别表示TCP接收缓冲区和发送缓冲区的低水位标记。它们一般被I/O复用系统调用用来判断socket是否可读或可写。当TCP接收缓冲区中可读数据的总数大于其低水位标记时,I/O复用系统调用将通知应用程序可以从对应的socket上读取数据;当TCP发送缓冲区中的空闲空间(可以写入数据的空间)大于其低水位标记时,I/O复用系统调用将通知应用程序可以往对应的socket上写入数据。默认情况下,TCP接收缓冲区的低水位标记为1字节和TCP发送缓冲区的低水位标记均为2048字节。

  • SO_RCVTIMEO / SO_SNDTIMEO 这两个选项给套接字的接收和发送设置一个超时值。注意,访问函数的参数是指向timeval结构的指针。通过设置值为0秒和0微妙禁止超时。缺省情况下,两个超时都是禁止的。

另外,实际编程中还关心 TCP 相关的选项 IPPROTO_TCP

  • TCP_NODELAY / TCP_CHORK 是否采用 Nagle 算法把较小的包组装为更大的帧。HTTP服务器经常使用 TCP_NODELAY 关闭该算法。相关的还有 TCP_CORK

  • TCP_DEFER_ACCEPT 推迟 accept,实际上是当接收到第一个数据之后,才会创建连接。(对于像HTTP等非交互式的服务器,这个很有意义,可以用来防御空连接攻击。)

  • TCP_KEEPCNT / TCP_KEEPIDLE / TCP_KEEPINTVL 这三个参数配合 SO_KEEPALIVE 使用,通过 TCP_KEEPIDLETCP_KEEPINTVLTCP_KEEPCNT 设置 keepalive 的开始时间、间隔、次数等参数。保活时间:keepalive_time = TCP_KEEPIDLE + TCP_KEEPINTVL * TCP_KEEPCNTTCP_KEEPIDLE 时间开始,向对端发送一个探测信息,然后每过 TCP_KEEPINTVL 发送一次探测信息。如果在保活时间内,就算检测不到对端了,仍然保持连接。超过这个保活时间,如果检测不到对端,服务器就会断开连接,如果能够检测到对方,那么连接一直持续。

非阻塞socket

阻塞是指调用结果返回前,当前线程会被挂起。当函数结果返回时当前线程才恢复执行。非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

前面的socket函数默认是阻塞模式,使用fcntl可以将socket设置为非阻塞模式。

1
2
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

非阻塞socket编程与阻塞编程的区别主要在于一些可能造成阻塞的操作在无法完成操作的情况下直接返回EAGAINEWOULDBLOCK。比如使用read,而此时输入缓冲区中没有任何数据,那么直接返回EWOULDBLOCK。这样服务器可以将CPU用于处理其他逻辑,而非等待数据到达。

对于非阻塞socket,可能写出下面的代码:

1
2
3
4
5
6
7
int fds[MAX_FDS];
// ...
for (int i = 0; i < max_fd; ++i) {
if (read(fds[i], buf, sizeof(buf)) != EWOULDBLOCK) {
// do something
}
}

IO多路复用

对于非阻塞式socket,如果使用轮询实现,每次都要陷入内核态,且依次轮询效率非常低,所以提出了IO多路复用机制。所谓IO多路复用,在实现上是将轮询机制转换为观察者模式。用户需要注册文件描述符以及需要监听事件,而内核负责在发生某些事件(可读等)时通知用户。也就是说原来需要在每条连接上进行监听,而使用IO多路复用后,监听过程交给了内核,由内核将消息分发到每一条连接上。

按照IO多路复用的发展历程,出现了selectpollepoll(在BSD上对应kqueue)。

关于select使用参考Linux select 详解

关于poll使用参考poll调用详解

关于epoll使用参考通过完整示例来理解如何使用epoll

References

  1. TCP - Wikis
  2. TCP/IP 详解 卷一:协议
  3. UNIX 网络编程 卷一:套接字联网API
  4. 如何正确关闭 TCP 连接 - 知乎
  5. 浅谈服务端编程
  6. TCP/IP Socket心跳机制so_keepalive的三个参数详解
  7. SO_RCVLOWAT和SO_SNDLOWAT选项
  8. TCP选项之SO_RCVLOWAT和SO_SNDLOWAT
  9. TCP选项之SO_RCVBUF和SO_SNDBUF
Hiển thị bình luận từ Gitment