socket api是网络编程的基础,是对两台主机间利用各个通信协议进行通信行为的封装,操作系统以系统调用的形式将这个统一的接口提供给应用程序。各个编程语言的网络编程接口的底层都是基于操作系统的socket api,因此,了解它们的行为非常重要。仅仅了解每个系统调用是干什么的是不够的,本文将以TCP协议为例,从内核和tcp协议栈的角度去介绍这些系统调用干了什么、怎么做的以及一些实现机制。
全双工:TCP连接的两端都可以发送数据和接受数据,因此两端都得有自己的send buffer和receive buffer,都得维护自己的TCP连接状态机
多说一点:TCP协议规定的行为,比如对buffer中数据的发送、收到报文后处理都是操作系统内核协议栈自动进行的,不需要应用程序干涉,当然操作系统提供给应用程序一些接口,可以控制TCP协议栈的行为。
三次握手: 上图中已经包括了TCP连接建立过程中客户端和服务器端的状态机状态转换过程,完整的状态转换图见下:
socket
:
1
2
3
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
Returns: file (socket) descriptor if OK, −1 on error
指定domain(记住AF_INET), type(记住SOCK_STREAM和SOCK_DGRAM), protocol(记住IPPROTO_TCP和IPPROTO_UDP), 打开一个socket。
返回值是int类型,是一个socket资源描述符。socket在内核中是一系列数据结构的集合,是一种资源。一般来说,一个普通的socket关联一个send buffer、一个receive buffer、一个state machine以及一个可选的TCP连接(四元组)
listening socket关联的则是不同于上面普通数据通信socket的数据结构,当调用 listen
时, socket就成为listening socket,后面会提到
getaddrinfo
:
1
2
3
4
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *restrict host, const char *restrict service, const struct addrinfo *restrict hint, struct addrinfo **restrict res);
//Returns: 0 if OK, nonzero error code on error
根据host和服务名称查找addr,返回的addrinfo结构里有sockaddr成员。客户端在建立socket连接之处如果不知道服务器端某个服务的ip和port,则可使用这个函数来查找。
bind
:
1
2
3
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
//Returns: 0 if OK, −1 on error
将一个socket和一个sockaddr关联,TCP协议中需要指定IP地址和端口。对于客户端而言,这个关联无关紧要,可以让系统自动去选择一个默认的地址就ok。然而对于服务器端,则需要将一个大家都知道的地址关联到客户端要连的那个socket上, 这样客户端就可以通过这个公开的地址和服务器的socket进行通信了
如果客户端或者服务器端未指定端口,那么内核会在 connect
或者 listen
被调用时默认选择一个未被占用的短生端口。(一般来说,服务器端都需要指定一个well-known的端口)
如果客户端没有指定IP地址,那么在调用 connect
时内核会根据实际的网络接口选择IP。如果服务器端没有指定IP地址(或者指定了为IPADDR_ANY),内核将使用客户端SYN请求中的destination IP地址作为该连接的服务器端IP地址。
connect
: (客户端调用)
1
2
3
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t len);
//Returns: 0 if OK, −1 on error
现在两边的socket也打开了,地址也绑定了,客户端要和服务器端通信的话,就调connect
连吧。对于客户端而言,如果之前没有bind一个地址,connect
会自动分配一个地址给这个socket。
对于TCP而言,connect
其实就是发起一个三次握手的过程,向服务器发SYN后阻塞,至客户端收到服务器的SYN时, connect
返回,并state machine进入ESTABLISHED状态。
connect
失败时返回-1,并置error标示原因,一般来说error取值有以下几种:
如果connect
失败了怎么办?对于一个重负载的系统往往采用指数退避算法去重试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "apue.h"
#include <sys/socket.h>
#define MAXSLEEP 128
int connect_retry(int sockfd, const struct sockaddr *addr, socklen_t alen){
int numsec;
for (numsec = 1; numsec <= MAXSLEEP; numsec <<= 1) {
if (connect(sockfd, addr, alen) == 0) {
return(0);
}
if (numsec <= MAXSLEEP/2)
sleep(numsec);
}
return(-1);
}
上面的代码在BSD-based系统上(比如mac)是不能正确工作的,原因在于:如果对于某个socket的第一个connect
调用fail了,后续的connect
也会fail。因此,改进的方法就是再for循环里每次新建一个socket,此外,成功时就不能再返回0了,而是要把socket的fd返回
注意:connect
并不仅仅用于面向连接型socket间通信,同样也适用于面向非连接的,这看上去矛盾,其实不然。对于SOCK_DGRAM
类型的socket,connect
调用使得我们不必再每次发消息时都加上addr信息。
socket与阻塞和非阻塞模式之分,因此connect
调用也会根据socket类型有相应的行为。
listen
:(服务器端调用)
1
2
3
#include <sys/socket.h>
int listen(int sockfd, int backlog);
//Returns: 0 if OK, −1 on error
服务器通过调用listen
表示自己可以接受客户端的连接请求,backlog参数是对系统the num of outstanding connect requests的建议.
listening socket在内核中关联着两个socket队列,未完成的连接队列 和 已完成的连接队列。
当一个客户端的SYN请求到达listening socket时,TCP协议栈就在未完成的连接队列中创建一个entry,同时响应客户端以SYN和ACK。这个entry有着和listening socket相同的参数,且会一直存在于未完成的连接队列中,直到客户端最后一次握手到达或者超时。如果最后一次握手到达,entry将变成一个完整的socket(已经有了send buffer和receive buffer),并置入已完成的连接队列中。当进程调用accept时,取到的就是已完成的连接队列中第一个socket,如果队列为空,accept将阻塞。如果accept前已经有客户端数据到达,客户端能发送的数据量取决于服务器端socket的receive buffer大小
backlog参数一直没有一个正式的定义,各个操作系统的实现有着不同的解释,一般来说,它决定着两个队列大小之和,然而,也有系统将其只与已完成的连接队列大小关联。这样做有两个好处:
accept
:(服务器端调用)
1
2
3
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len);
Returns: file (socket) descriptor if OK, −1 on error
服务器通过accept
取得一个socket fd,这个socket与一个client相连着。 注意到accept
调用的第一个参数也是一个socket(listening socket),这个socket与返回的socket有着同样的type和address family,此外,这个socket与建立好的client-server连接没有任何关系,他还可以用来获取其他连接请求。
与connect
一样,accept
会根据socket类型分为阻塞和非阻塞两种行为,当server要处理多个连接请求时,可基于poll、select调用来wait io可用。
注意accept调用还是可能会出错的,(虽然看上去不像,因为不涉及主机间交互,只是本地操作)。设想这样一种情况:客户端发起了一个建立连接请求,三次握手后服务器端的listening socket的已完成的连接队列中已经有了一个建立好的socket,此时服务端尚未调用accept去获取这个socket,客户端发来一个RST请求,如下图所示:
服务器端收到客户端的RST请求后,再accept就会返回错误。然而各个操作系统的实现对此情况的处理是不一样的,posix规定error应该置为ECONNABORTED,这样应用程序知道后重试就行了。有些操作系统在内核里直接就给处理了(把相应的socket从队列中移除,释放掉socket资源),应用程序甚至都不知道。
四次挥手:
上图中,客户端发起关闭连接请求(第一个FIN),称之为active close,相应的,服务器端被称为passive close。
先看服务器端,接收到客户端的FIN后,响应一个ACK同时进入CLOSE_WAIT状态。此时,之前如果有read调用在阻塞,那么将返回0,表示end-of-file。 在随后的一段时间内,服务器端通过调用close向客户端发一个FIN 请求,同时进入LAST_ACK状态。在接受到客户端的ACK后,服务器端socket进入CLOSED状态。
再看客户端,通过close调用发完FIN后,进入FIN_WAIT_1状态,等到收到服务器端的ACK后,进入FIN_WAIT_2状态,在受到服务器的FIN后进入TIME_WAIT状态,并发最后的ACK。在等两个MSL后,才进入CLOSED状态。
这个TIME_WAIT状态是干嘛用的?为什么不让客户端在收到服务器端的FIN后直接进入CLOSED状态?MSL又是啥?为啥等两个MSL?
MSL(Maximum Segmant Lifetime) : 报文的最长生存时间,我们知道IP协议有TTL一说,就是一个报文最大的路由跳数,当报文的转发次数超过TTL时会被丢弃,也就是说报文在网络中有生存时间限制。
close
1
2
3
#include <unistd.h>
int close(int sockfd);
return: 0 if OK, -1 on error
close 默认的行为是将sockfd的reference count减一,如果为0则将socket标记为关闭,然后立即返回。一旦调用close,针对这个socket的read和write都将失效。TCP协议栈会自己将send buffer中还没发送的数据发送至另一端,然后自动进行四次挥手。
注意,服务器端父进程fork出子进程后要及时去close掉accept返回的的socket fd
shutdown
1
2
3
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
returns: 0 if ok, -1 on error
shutdown没有像close调用那样的reference count限制,一旦调用,关闭行为立即生效。
howto可为:SHUT_WR、SHUT_RD、SHUT_RDWR,分别表示关闭写、关闭读和关闭读写。对于SHUT_WR,shutdown会先把send buffer中还没发送的数据发送,然后,执行四次挥手。对于SHUT_RD,shutdown使得socket不能在进行读取,同时丢弃receive buffer中尚未读取的数据。
read
and write
:
与正常file一样,socket可使用 read
和 write
来进行数据传输,这点非常重要,使得我们可以将一个socket描述符传给子进程(比如通过管道),子进程甚至都不需要知道它是个socket。
此外,UNIX还提供了一些有这额外功能的API来传输数据。
send
: (for TCP)
1
2
3
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
Returns: number of bytes sent if OK, −1 on error
可以看出,比 write
多了flags参数,这些flags参见single UNIX标准,这里就不具体说了。 值得一提的是 send
的行为:与 write
一致,也分为阻塞和非阻塞,取决于socket的模式。 另外,当 send
正确返回了,并不一定意味着另一端正确收到数据了, send
只能保证用户空间的数据发往socket的send buffer中,至于什么时候发往网络,什么时候对方接收等,socket api层不做保证,由操作系统的协议栈自动完成。
连接建立过程中边界条件已经基本说到了,下面结合场景说下数据传输过程中的边界条件(出错情况)。
假如当服务器进程echo回一次内容后,就被kill掉了。我们知道进程被终止前会自动关闭它所打开的资源描述符的(也就是会对所有socket进行close),意味会向客户端发一个FIN,然后进程终止了、listening socket不在了(listening socket不需要四次挥手,没挥的对象!),但是数据通信socket还在、TCP连接还在(还在等四次挥手呢)。客户端收到FIN后自动ACK了,进入CLOSE_WAIT状态,服务器收到客户端的ACK后进入FIN_WAIT_2状态。假如此时用户又从终端向客户端输入一个内容,客户端进程则又会向socket write一次,会成功么? 没问题,TCP 协议允许(见上一节)。但这种情况下服务器收到后会怎样?实际上TCP协议规定这种情况下会响应一个RST过去。
在上面的场景中,客户端发起一个write成功后,就立即去read准备读服务器端响应了,一看这个socket收到了来自服务器端的FIN报文,就又立即从read返回0表示end-of-file了(这也解释了为什么,close和shutdown只是标示下要关闭这个socket,实际FIN请求的发出得等到把send buffer中未发完的数据确认发完后才进行)。假如此时不是去read,而是发起了另一个write,也就是对一个已经收到RST(由第一个write触发)的socket去write,POSIX规定此时write调用将触发SIGPIPE信号,应用程序捕获它,然后从信号处理程序中返回后,write将返回且置error为EPIPE(与pipe类似),也可以忽略它,SIGPIPE默认的行为是终止进程(意味着会去向服务器发一个FIN,完成四次挥手)。
说了这么多,总结一下:虽然write/send只涉及本机操作,但是仍然可能会产生TCP协议的相关错误(EPIPE)。
再多说一点:如果上面的例子中服务器直接down掉了(不是进程被kill,而是机器挂掉了),那么FIN压根就不会发过来,客户端发起write的时候,会成功返回,然后会数据会一直存在send buffer中,因为不会被ACK。当客户端去对socket发起read准备读服务器端响应时,会一直阻塞着,直到TCP协议栈重传了多次数据仍然失败导致的超时错误(可能已经等了好几分钟了!),read会从阻塞中返回,同时error被置为ETIMEDOUT。如果路径上的某个路由器感知到主机的不可达,则error可能被置为EHOSTUNREACH/ENETUNREACH。(注意:这个错误不是由read直接导致的,正常情况read是会block forever的,如果想尽快从read中返回怎么办:自己设置一个超时时间,比如几秒的一个alarm信号,让read从alarm信号中断中返回。)
在上面的例子中,ETIMEDOUT的产生是由客户端主动去write才会有的,也就是说如果我们不去write的话,比如直接read阻塞,如果服务器down掉了,客户端是感知不到的,真的会block forever的!那么怎么办?只能通过一个socket option——SO_KEEPALIVE来解决。
什么?没看懂!正常,这文章也不是写给您看的,是总结给我自己的,知识还是要看一手的,呐,UNP、 APUE欢迎您