套接字编程简介
套接字API可以在两个方向上进行传递:从内核到进程和从进程到内核。
套接字地址简介
大多数套接字函数都需要一个指向套接字地址结构的指针作为参数。每个协议族都定义它自己的套接字地址结构。这些结构的名字均以sockaddr_开头,并以对应每个协议族的唯一后缀结尾。
IPv4套接字地址结构
IPv4套接字地址结构通常也称为“网际套接字地址结构”。
基本TCP套接字编程
编写一个完整的TCP客户/服务器程序所需要的基本套接字函数。
并发服务器,是在同时有大量的客户连接到同一服务器上时用于提供并发性的一种常用Unix技术。每个客户连接都迫使服务器为它派生(fork)一个新的进程。
流程
- 所有客户和服务器都从调用socket()开始,它返回一个套接字描述符。
- 客户随后调用connect(),服务器则调用bind()、listen()、accept()。
- 套接字通常使用标准的close()关闭,不过shutdown()也是一种方法。
- SO_LINGER套接字选项对于关闭套接字的影响。
socket()
为了执行网络IO,一个进程必须做的第一件事情就是调用socket函数,指定期望的通信协议类型(使用IPv4的TCP、使用IPv6的UDP、Unix域字节流协议等)。
组成
1 | //若成功则为非负描述符,若出错则为-1 |
- family。指明协议族,该参数也被称为协议域。
- AF_INET。IPv4协议。
- AF_INET6。IPv6协议。
- AF_LOCAL。Unix域协议。
- AF_ROUTE。路由套接字。
- AF_KEY。密钥套接字。
- type。指明套接字类型。
- SOCK_STREAM。字节流套接字。
- SOCK_DGRAM。数据报套接字。
- SOCK_SEQPACKET。有序分组套接字。
- SOCK_RAW。原始套接字。
- protocol。设为某个协议类型的常值,或设为0以选择所给定family和type组合的系统默认值。
- IPPROTO_TCP。TCP传输协议。
- IPPROTO_UDP。UDP传输协议。
- IPPROTO_SCTP。SCTP传输协议。
实战
socket()在成功时返回一个小的非负整数值,它与文件描述符类似,称为套接字描述符,简称sockfd。为了得到这个套接字描述符,我们只是指定了协议族和套接字类型,而没有指定本地协议地址或远程协议地址。
connect()
TCP客户用connect()来建立与TCP服务器的连接。
1 | //若成功则为0,若出错则为-1. |
- sockfd。是由socket函数返回的套接字描述符。
- sockaddr。指向套接字地址结构的指针。
- addrlen。套接字地址结构的大小。
实战
客户在调用connect()时不必非得调用bind(),因为如果需要的话,内核会确定源IP地址,并选择一个临时端口作为源端口。
如果是TCP套接字,则调用connect()将激发TCP的三次握手过程,且仅在连接建立成功或出错后才返回。
bind()
bind()将一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32位的IPv4或128位的IPv6地址与16位的TCP或UDP端口号的组合。
1 | //成功则为0,失败则为-1 |
- sockaddr。是一个指向特定于协议的地址结构的指针。
- 若IP地址为通配地址,则内核选择IP地址。若IP地址为本地IP地址,则进程指定IP地址。
- 若端口为0,则进程指定IP地址。若端口为非0,则进程指定端口。
- addrlen。是该地址结构的长度。
对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两 者都指定,也可以都不指定。
- 服务器在启动时捆绑它们众所周知的端口。若一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口。
- 让内核来选择临时端口对于TCP客户来说是正常的,除非应用需要一个预留端口。然而对于TCP服务器却极为罕见,因为服务器是通过它们的总所周知的端口被大家认识的。
- 对于RPC服务器,它们通常就由内核为它们的监听套接字选择一个临时端口,而该端口随后通过RPC端口映射器进行注册。客户在connect这些服务器前,必须与端口映射器联系以获取它们的临时端口。
- 进程可以将一个特定的IP地址捆绑到它的套接字上,不过这个IP地址必须属于其所在主机的为了接口之一。
- 对于TCP客户,就为在该套接字上发送的IP数据报指派了源IP地址。TCP客户通常不把IP地址捆绑到它的套接字上,当连接套接字时,内核将根据所用外出网络接口选择源IP地址,而所用外出接口则取决于到达服务器所需的路径。
- 对于TCP服务器,就限定该套接字只接收那些目的地为这个IP地址的客户连接。如果TCP服务器没有将IP地址捆绑到它的套接字上,内核就把客户发送发送的SYN的目的IP地址作为服务器的源IP地址。
listen()
listen()仅在TCP服务器调用,完成两件事情:
- 当socket()创建一个套接字时,它被假设为一个主动套接字,即它是一个将被调用connect()发起连接的客户套接字。listen()把一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。
- 根据TCP状态转换图,调用listen()导致套接字从CLOSED状态转换到LISTEN状态。
- 规定了内核应该未相应套接字排队的最大连接个数。
1 | //若成功则为0,出错则为-1 |
listen()通常在调用socket()、bind()函数后,并在调用accept()前调用。
- backlog。内核将为任何一个给定的监听套接字维护两个队列。
- 未完成连接队列。每个这样的SYN分节对应其中一项,已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三次握手过程,这些套接字处于SYN_RECVD状态。
- 已完成连接队列。每个已经完成TCP三次握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态。
accept()
accept()由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果已完成连接队列为空,则进程被投入睡眠(假定套接字为默认的阻塞方式)。
1 | //若成功则为非负描述符,若出错则为-1 |
- sockfd。监听套接字描述符,由socket创建,随后用作bind()、listen()的第一个参数的描述符。
- cliaddr。用来返回已连接的对端进程(客户)的协议地址。
- addrlen。是值-结果参数。
调用前,将由*addrlen
所引用的整数值置为由cliaddr所指的套接字地址结构的长度,返回时,该整数值即为由内核存放在该套接字地址结构内的确切字节数。
若accept()成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接。即已连接套接字描述符。它在服务器的生命周期内一直存在,内核为每个由服务器进程接受的客户连接创建一个已连接套接字,当服务器完成对某个给定客户的服务时,相应的已连接套接字被关闭。
fork()
exec()
close()
缓冲区
write
每个TCP套接字有一个发送缓冲区,我们可以用SO_SNDBUF套接字选项来更改该缓冲区的大小。当某个应用进程调用write时,内核从该应用进程的缓冲区复制所有数据到缩写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),该应用进程将被投入睡眠。这里假设该套接字是阻塞的,它通常是默认设置。内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接受到数据。
这一端的TCP提取套接字发送缓冲区中的数据并把它发送给对端的TCP,其过程基于TCP数据传送的所有规则。对端TCP必须确认收到的数据,伴随来自对端的ACK的不断到达,本段TCP至此才能从套接字发送缓冲区中丢弃已确认的数据。TCP必须为已发送的数据保留一个副本,直到它被对端确认为止。本端TCP以MSS大小或是更小的块把数据传递给IP,同时给每个数据块安上一个TCP首部以构成TCP分节,其中MSS或是由对端告知的值,或是536(若未发送一个MSS选项为576-TCP首部-IP首部)。IP给每个TCP分节安上一个IP首部以构成IP数据报,并按照其目的的IP地址查找路由表项以确定外出接口,然后把数据报传递给相应的数据链路。每个数据链路都有一个数据队列,如果该队列已满,那么新到的分组将被丢弃,并沿协议栈向上返回一个错误:从数据链路到IP,在从IP到TCP。TCP将注意到这个错误,并在以后某个时候重传相应的分节。应用程序不知道这种暂时的情况。
任何UDP套接字都有发送缓冲区大小(我们可以用SO_SNDBUF套接字选项更改它),不过它仅仅是可写道套接字UDP数据报大小上限。如果一个应用进程写一个大于套接字发送缓冲区大小的数据报,内核将返回该进程一个EMSGSIZE错误。既然UDP是不可靠的,它不必保存应用进程数据的一个副本,因此无需一个真正的发送缓冲区。(应用进程的数据在沿协议栈向下传递时,通常被复制到某种格式的一个内核缓冲区中,然而当该数据被发送之后,这个副本被数据链路层丢弃了。)
UDP简单地给来自用户的数据报安上8字节首部以构成UDP数据报,然后传递给IP。IPv4或IPv6给UDP数据报安上相应的IP首部以构成IP数据报,执行路由操作确定外出接口,然后或者直接把数据报加入数据链路层输出队列(如果适合于MTU),或者分片后在把每个片段加入数据集链路层的输出队列。如果某个UDP进程发送大数据报,那么它们相比TCP应用数据更有可能被分片,因为TCP会把应用数据划分成MSS大小的块,而UDP却没有对等的手段。
从写一个UDP套接字的write调用成功返回表示所写的数据报或其所有片段已被加入数据链路层的输出队列。如果该队列没有足够的空间存放该数据报或它的某个片段,内核通常会返回一个ENOBUFS错误给它的应用进程。有些UDP实现不返回这种错误,这样甚至数据报未经发送就被丢弃的情况进程也不知道。