TCP套接字编程函数流程如下图所示:

基本TCP客户服务器程序的套接字函数

socket

为了进行网络I/O,进程做的第一件事就是调用socket函数,指定期望的通信协议类型。

#include <sys/socket.h>
int socket(int family, int type, int protocol);
// 返回值:成功则为非负的描述符,出错则为-1

参数family指明协议族,常见协议族如下:

family说明
AF_INETIPv4协议
AF_INET6IPv6协议
AF_LOCALUnix域协议
AF_ROUTE路由套接字

参数type指定套接字类型,常见套接字类型如下:

type说明
SOCK_STREAM字节流套接字
SOCK_DGRAM数据报套接字
SOCK_SEQPACKET有序分组套接字
SOCK_RAW原始套接字

参数protocol用来指定协议类型,但常被设置为0,以选择给定familytype后的系统默认值。然而并非所有套接字的familytype的组合都是有效的,下面给出一些有效的组合和对应的协议(空白选项是无效组合,“是”表示是有效组合但无具体协议名称)。

协议族/类型AF_INETAF_INET6AF_LOCALAF_ROUTE
SOCK_STREAMTCP/SCTPTCP|SCTP
SOCK_DGRANUDPUDP
SOCK_SEQPACKETSCTPSCTP
SOCK_RAWIPv4IPv6
为什么protocol参数常被设置为0呢?

最开始AF_前缀表示地址族,PF_前缀表示协议族,并且设计上单个协议族可以支持多个地址族,PF_值用来创建套接字,AF_值用于套接字地址结构。然而实际上,支持多个地址族的协议族从来都没有实现过,而且头文件<sys/socket.h>中为一给定协议定义的PF_值总是与该协议的AF_值相等,因此就默认采用AF_值了。

connect

TCP客户端使用connect函数建立与TCP服务器的连接。

#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
// 返回值:若连接建立成功则为0,若出错则为-1并设置errno

参数sockfd是由socket函数返回的套接字描述符。servaddraddrlen分别是指向套接字地址结构的指针和该结构的大小。套接字地址结构必须包含服务器的IP地址和端口号。

客户端在调用connect函数之前无需调用bind函数,因为内核会确定源IP地址,并选择一个临时端口作为源端口。

如果是TCP套接字,调用connect函数会触发TCP的三路握手过程,而且仅在连接建立成功或出错时返回

connect函数调用后会发出SYN报文,服务器或网络对SYN报文的响应有如下结果:

场景客户端收到对SYN报文的响应客户端connect()返回值常见errno含义说明
正常连接收到SYN+ACK,完成三次握手0连接成功
无响应没有任何响应-1ETIMEDOUNTTCP客户端未收到服务器对SYN报文的响应,默认Linux等待63s
端口未监听RST-1ECONNREFUSED服务器主机存在但是该端口上没有进程监听,无法建立连接
网络不可达中间路由返回的ICMP网络不可达报文-1ENETUNREACH网络不可达
主机不可达中间路由返回的ICMP主机不可达报文-1EHOSTUNREACH主机不可达

在Linux的默认配置(cat /proc/sys/net/ipv4/tcp_syn_retries为6)下,如果connect()函数发出的SYN报文一直没有得到响应,它会按照指数退避的方式重传6次,总计大约63s(1+2+4+8+16+32=63)后才会返回错误。

从TCP状态图来看,connect函数会导致当前套接字从CLOSED状态转移到SYN_SENT状态,若成功在转移到ESTABLISHED状态。

如果connect函数失败,套接字必须关闭,不能用来再次调用connect函数。这是因为内核会标记该套接字为错误状态。

bind

bind函数的作用是将一个本地协议地址赋予一个套接字。对于网际协议,协议地址是32位的IPv4地址或128位的IPv6地址16位的TCP或UDP端口号的组合。

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 返回值:若成功则为0,失败则为-1并设置errno指示错误

addraddrlen参数分别是指向特定协议的套接字地址结构的指针和地址结构的长度。

调用bind函数可以同时指定端口号+IP地址,也可以都不指定,也可以指定其中任意一个。

对于端口号,服务器进程在启动时通常都会绑定它们公开的端口号,客户端进程一般不绑定端口号,在连接时有内核随即选择端口号。

对于IP地址,服务器或客户端进程都可以把一个特定的IP地址绑定到它的套接字上,前提是这个IP地址必须属于其所在主机的网络接口上。对于TCP客户端来说,这为在该套接字上发送的IP数据报指定了源IP地址。对于TCP服务器,这限定了该套接字仅接受目的地址为这个IP地址的客户端连接。

TCP客户端通常不需要绑定IP地址到它的套接字上,当连接套接字时,内核会根据所用外出网络接口来选择源IP地址,而所用外出接口则取决于到达服务器所需的路径

TCP服务器通常会绑定统配地址和指定端口到它的套接字上,表示接收本机所有网卡上到达该端口的报文

如果TCP服务器没有绑定指定IP地址到其套接字上,那么内核就把客户端发送的SYN的目的IP地址当作服务器的源IP地址(在回包时)

下表给出设置sin_addrsin_portsin6_addrsin6_port的值产生的预期结果:

进程指定IP地址进程指定端口号结果
通配地址0内核选择IP地址和端口
通配地址非0内核选择IP地址,进程指定端口
本地IP地址0进程指定IP地址,内核选择端口
本地IP地址非0进程指定IP地址和端口

如果指定端口号为0,那么内核在bind函数调用时就会选择一个临时端口。如果指定IP地址为通配地址,那么内核要等到套接字已连接(TCP)或在该套接字上发出数据报(UDP)时才选择一个本地IP地址。

对于IPv4来说,通配地址通常为INADDR_ANY,用法如:servaddr.sin_addr.s_addr=htonl(INADDR_ANY)

对于IPv6,系统会在头文件<netinet/in.h>中预先分配in6addr_any变量并将其初始化为常值IN6ADDR_ANY_INIT,用法为:servaddr6.sin6_addr=in6addr_any

// <netinet/in.h>中的定义如下
extern const struct in6_addr in6addr_any;        /* :: */
extern const struct in6_addr in6addr_loopback;   /* ::1 */
#define IN6ADDR_ANY_INIT { { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } } }
#define IN6ADDR_LOOPBACK_INIT { { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1 } } }

调用bind函数还有一点需要注意,如果让内核选择端口号,那么该函数是不会返回选择的端口号的。

listen

当使用socket函数创建一个套接字时,默认创建的套接字是主动套接字,即调用connect发起连接的套接字。

listen函数把一个未连接的主动套接字转换为一个被动套接字,指示内核应该接受指向该套接字的连接请求,因此该函数仅由TCP服务器调用。调用listen函数会导致套接字从CLOSED状态切换为LISTEN状态。

从状态转换图可以看出,TCP连接从LISTEN状态切换到ESTABLISHED状态分为两个过程,分别是:

  1. TCP服务器接收到客户端发送的SYN并响应SYN+ACK,此时称为SYN_RCVD状态。
  2. TCP服务器接收到客户端的ACK,此时称为ESTABLISTED状态。
  3. 这两种状态之间间隔时间为RTT。

内核为一个给定的监听套接字的TCP连接各维护一个队列:

  1. 为处于SYN_RCVD状态的TCP连接维护的队列称为半连接队列,其中存储的是轻量级的request_sock,对应SYN_RCVD状态。
  2. 为处于ESTABLISHED状态的TCP连接维护的队列称为全连接队列,其中存储的是完整的连接套接字(struct sock),对应ESTABLISHED状态。

半连接队列中存储的只是处于握手中间态的请求对象。

TCP三路握手和监听套接字的两个队列

当来自TCP客户端的SYN到达时,内核在半连接队列中创建一个新项,然后响应三路握手的第2个分节:SYN+ACK。该项一直保留在半连接队列中,直到三路握手的第3个分节(客户端对服务器SYN的ACK)到达或该项超时为止。如果三路握手正常完成,该项就从半连接队列转移到全连接队列的队尾。

当进程调用accept函数时,全连接队列的对头项将返回给调用进程,或者如果该队列为空,那么进程将进入睡眠,直到TCP在该队列中放入一项才唤醒。

介绍完了上面的基础知识之后,下面给出listen函数的原型:

#include <sys/socket.h>
int listen(int sockfd, int backlog);
// 返回值:成功为0,失败为-1并设置对应的errno

参数说明:sockfd参数表示要转为监听的套接字。backlog参数表示全连接队列的长度。

为什么backlog表示全连接队列的长度而不是半连接队列的长度呢?内核对全连接队列的长度做出限制的原因在于,在监听某个给定套接字的应用进程停止接受连接的时候,防止内核在该套接字上继续接受新的连接。而把半连接交由内核处理,用来解决SYN泛洪攻击。

在Linux中,backlog参数用来限制全连接队列的长度(即等待accept()的ESTABLISHED的套接字数)。半连接队列的大小由内核参数(如tcp_max_syn_backlog)控制,不受backlog参数的影响。

  1. 如果未启用SYN cookiesnet.ipv4.tcp_syncookies=0),半连接队列已满时SYN会被丢弃,客户端只能重试。
  2. 如果启用了SYN cookiesnet.ipv4.tcp_syncookies=1),半连接队列已满时,服务器不在分配半连接队列条目,但仍然会回复SYN+ACK,其中的初始序列号ISN会被特殊编码,当客户端返回ACK时会检查ACK中的确认号是否匹配之前发出的SYN cookie,如果匹配则直接在全连接队列中创建条目,等待accept()

SYN cookies的本质是使用序列号代替半连接存储,防御半连接队列溢出攻击,可以用来解决SYN泛洪攻击。

默认情况下Linux的半连接队列大小如下:

$ cat /proc/sys/net/ipv4/tcp_max_syn_backlog
512
$ cat /proc/sys/net/ipv4/tcp_syncookies
1

accept

accept函数用于从全连接队列中队头返回下一个已完成队列,因此只能被TCP服务器调用。如果全连接队列为空,那么调用进程默认阻塞

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//返回值:成功为已连接套接字,出错为-1

参数说明:sockfd监听套接字描述符addraddrlen参数用来返回已连接的对端进程的协议地址。addrlen表示返回时内核在addr套接字地址结构中存储的具体字节数。

返回值说明:如果accept函数调用成功,其返回值是由内核自动生成的一个全新描述符,代表与所返回客户端的TCP连接,被称为已连接套接字描述符

对于监听套接字描述符而言,服务器进程通常仅仅创建一个,存在于服务器的整个生命周期。

对于已连接套接字描述符,内核为每个由服务器接受的客户端连接创建一个已连接套接字(全连接队列中存储就是已连接套接字)。当服务器完成对某个给定客户端的服务时,相应的已连接套接字就被关闭。

fork和exec

#include <unistd.h>
pid_t fork(void);

fork函数是Unix中派生新进程的唯一方法。fork函数返回两次,在调用进程中返回一次,返回值是新派生的进程的进程ID;在子进程中返回一次,返回值为0。

fork函数在子进程中返回0而不是父进程的进程ID的原因在于:任何子进程仅有一个父进程,而且子进程可以通过调用getppid()函数获取父进程的进程ID。父进程有许多子进程,而且无法获取各个子进程的进程ID。如果父进程想要跟踪所有子进程的进程ID,那么它必须记录每次调用fork的返回值。

父进程中调用fork之前打开的所有文件描述符在fork返回之后由子进程共享。

fork函数的两个典型用法如下:

  1. 一个进程创建自身的副本。这样每个副本都可以在其他副本执行其他任务处理各自的操作。网络服务器的典型用法。
  2. 一个进程想要执行另一个程序。既然创建新进程的唯一办法是fork,那么该进程首先调用fork创建自身的副本,然后其中一个副本调用exec把自身替换为新的程序。这是shell之类程序的典型用法。

exec函数共有六个,作用是把当前进程映像替换为新的程序,而且该新程序从main函数开始执行,进程ID不变

#include <unistd.h>
extern char **environ;

int	      execl(const char *pathname, const char *arg, ... /*, (char *) NULL */);
int	      execlp(const char *file, const char *arg, ... /*, (char *) NULL */);
int	      execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
int	      execv(const char *pathname, char *const argv[]);
int	      execvp(const char *file, char *const argv[]);
int	      execvpe(const char *file, char *const argv[], char *const envp[]);
int	      execve(const char *pathname, char *const _Nullable argv[], char *const _Nullable envp[]);

这些函数中只有execve函数是内核的系统调用,其他函数都是execve函数的库函数。

6个exec函数之间的区别在于:

  1. 待执行的程序文件是由文件名还是路径名指定。由p来决定,如果函数后缀中携带p,则使用当前的PATH环境变量把该文件名参数转换为一个路径名(前提是文件名参数中没有/)。
  2. 新程序的参数是一一列出还是由一个指针数组来引用。由l和v来决定,l表示参数一一列出,v表示使用argv[]
  3. 把调用进程的环境传递给新程序还是给新程序指定新的环境。由e来决定,表示通过envp[]参数传入给新进程的环境变量。

这些函数只有在出错时才会返回给调用者。进程在调用exec之前打开的文件描述符通常跨exec继续保持打开,这个默认行为可以通过fcntl设置FD_CLOEXEC描述符标志禁止。

close

close函数用来关闭套接字,并终止TCP连接。

#include <unistd.h>
int close(int fd);
// 返回值:成功返回0,出错返回-1

close关闭套接字的默认行为是将该套接字标记为已关闭,然后立即返回调用进程。内核TCP协议栈将尝试发送已排队等待发送到对端的任何数据,发送完毕之后是正常的TCP连接终止序列。

如果套接字的引用计数值大于0,那么close调用就不会引发TCP的四分组连接终止序列。如果想要在某个TCP连接上发送一个FIN,那么可以采用shutdown函数代替close函数。

getsockname和getpeername函数

这两个函数用于获取与某个套接字关联的本地协议地址(getsockname)或对端协议地址(getpeername),函数原型如下:

#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict addrlen);
int getpeername(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict addrlen);
// 返回值:成功为0,失败为-1并设置对应的errno

getsockname函数的两个用途:

  1. 在一个没有调用bind函数的TCP客户端上,connect函数成功返回后,getsockname函数用于返回由内核赋予该连接的本地IP地址和本地端口号。
  2. 在以端口号0调用bind函数(让内核自由选择本地端口号)后,getsockname函数用于返回内核赋予的本地端口号。

当一个服务器是由调用过accept函数的某个进程通过调用exec执行程序产生的,它能够获取客户端身份的唯一途径就是调用getpeername。例如inetd进程forkexec某个TCP服务器程序。

示例

下面的例子给出了一个没有调用bind函数的TCP客户端,并在connect函数返回后,通过getsockname获取内核分配的IP地址和端口号。

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    int		       connfd;
    socklen_t	       len;
    struct sockaddr_in servaddr, cliaddr;
    time_t	       ticks;

    connfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (connfd == -1)
    {
	perror("socket error");
	exit(-1);
    }

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port	= htons(13);  // host to network short
    inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr.s_addr);

    if (connect(connfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == 0)
    {
	len = sizeof(cliaddr);
	getsockname(connfd, (struct sockaddr *)&cliaddr, &len);
	printf("%s %d", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
    }
    close(connfd);
}

// 输出结果:127.0.0.1 56994

下面的示例用来获取套接字的协议族:

#include <netinet/in.h>
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
int sockfd_to_family(int sockfd)
{
    struct sockaddr_storage ss;
    socklen_t		    len;

    len = sizeof(ss);
    if (getsockname(sockfd, (struct sockaddr*)&ss, &len) < 0)
    {
	return -1;
    }
    return ss.ss_family;
}

int main(int argc, char* argv[])
{
    int sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    printf("AF_INET=%d %d\n", AF_INET, sockfd_to_family(sockfd));
    close(sockfd);
    sockfd = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
    printf("AF_INET6=%d %d\n", AF_INET6, sockfd_to_family(sockfd));
    close(sockfd);
}

// 输出:
// AF_INET=2 2
// AF_INET6=10 10

示例

下面是一个典型的并发服务器的示例:

#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAXLINE 1024
#define LISTENQ 5
int main(int argc, char* argv[])
{
    pid_t	       pid;
    int		       listenfd, connfd;
    socklen_t	       len;
    struct sockaddr_in servaddr, cliaddr;

    // 1. 创建套接字
    listenfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (listenfd == -1)
    {
	perror("socket error");
	exit(-1);
    }

    // 2. 绑定监听套接字到服务器地址
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family	     = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port	     = htons(13);

    if (bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
    {
	perror("bind error");
	exit(-1);
    }

    // 3. 将套接字转换为被动监听套接字,指示内核接收该套接字上的连接
    if (listen(listenfd, LISTENQ) != 0)
    {
	perror("listen error");
	exit(-1);
    }
    for (;;)
    {
	// 4. 接收连接,并使用cliaddr接收对端地址
	connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &len);
	if (connfd == -1)
	{
	    perror("accept error");
	    continue;
	}
	// 5.在子进程中接收处理业务
	if ((pid = fork()) == 0)
	{
	    close(listenfd);
	    /* 处理业务 */

	    /* 关闭已连接套接字,并非必须,因为exit之后所有套接字都会被关闭 */
	    close(connfd);
	    exit(0);
	}

	close(connfd);
    }
}

在上面的示例中父进程close已连接套接字并不会终止与客户端的连接,这是因为每个文件或套接字都有一个引用计数,维护在文件表项中。

socket函数返回后,与listenfd关联的文件表项的引用计数值为1,accept函数返回后与connfd关联的文件表项的引用计数值为1。然而fork返回后,这两个描述符在父子进程间共享,因此与这两个套接字关联的文件表项的引用计数值为2。父进程关闭connfd,只是把相应的引用计数值从2减到1。

该套接字真正的清理和资源释放要等到其引用计数达到0时才发生,这会在稍后子进程也关闭connfd时发生。

如果父进程对每个调用accept返回的已连接套接字都不调用close函数,那么会造成什么结果?

  1. 父进程会耗尽可用描述符。
  2. 没有一个客户端的连接会被终止。