网络编程的学习主要是通过《UNIX网络编程》来学习的,也看了一下《Effective TCP/IP》和一些项目。网络编程在一个项目中占到的比重一般比较小,更重要的是基于网络的功能的实现,但几乎每一个项目都离不开网络。网络编程细节上的东西也比较多,准备在这里记录一下,主要是TCP,有小部分UDP和UNIX域套接字。IP方面主要是IPv4,但也追求协议无关的编程。
根据体系结构的不同,计算机会以不同的方式存储和解释数据:一种是将低位字节存储在起始地址/将低地址的数据解释成低位字节,叫做小端法(little-endian);另一种将高位字节存储在起始地址/将低地址的数据解释成高位字节,叫做大端法(big-endian)。应用程序发送数据总是从低地址开始一字节一字节的发送数据,所以数据在内存中的存储方式会影响在网络中传输的顺序,而应用接受数据也按照顺序从低地址开始存储,也就是说数据在发送端和接收端的内存存储方式是相同的,但是解释方式不一定相同。网络协议规定了以大端字节序作为网络字节序,提供了一系列的字节序转换函数:
#include
/* s为16字节,l为32字节,即使在64位处理器中,long int为64字节 */
uint16_t htons(uint16_t hostshort);
uint32_t htonl(uint32_t hostlong);
uint16_t ntohs(uint16_t netshort);
uint32_t ntohl(uint32_t netlong);
h | n | s | l |
---|---|---|---|
host | network | short | long |
发送端可以将二进制数据转换为网络字节序发送,然后接收端接收数据后转换为主机字节序,就消除了不同存储方式的影响,但是这不代表没有问题了,因为不同的主机上数据类型的大小也会不同,当考虑结构体时,就更复杂了,在结构的内部还会根据不同的字段对齐规则填充字节。所以相比于传递二进制结构,更好的办法是将所有传输的数据编码成文本形式,因为字符只占1个字节,所以不会有大小端的影响,也没有不同大小的影响。
字节序转换函数的最大用途是在地址结构体赋值中使用:htons(port)
转换端口号;htonl(addr)
转换地址。
由于有多种协议,也就有了多种地址结构。
对于最常见的IPv4套接字来说:
#include
struct sockaddr_in {
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 16-bit port number */
struct in_addr sin_addr; /* 32-bit IPv4 address */
/* padding... */
unsigned char __pad[X];
};
struct in_addr {
in_addr_t s_addr; /* 32-bit IPv4 address */
};
通常只需要用到sin_family、sin_port和sin_addr,但一般具体实现中还有别的元素,所以需要在使用前置零,使用memset(&addr, 0, sizeof(addr));
或者bzero(&addr, sizeof(addr));
在初始化时,sin_port和sin_addr需要转换为网络字节序,因为这些字段会用在不同主机之间通信,比如附加在TCP和IP头部。要注意地址的访问方法,sin_addr
是一个结构,而sin_addr.s_addr
是一个32位无符号整数,有些函数对这方面有要求。
IPv6地址是128位的,所以会被存储在一个sockaddr_in6
结构中,端口号和地址族和IPv4相同,还有一些其他的元素。
UNIX域套接字地址以路径名来表示,不需要用到端口号:
#include
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* Null-terminated socket pathname */
};
当套接字地址结构作为参数传递给函数时,总是以指针传递。为了处理不同类型的指针,最简单的方法是使用void \*
指针,但是套接字函数比ANSI C早,所以定义了一个通用的套接字地址结构:
#include
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
};
它的唯一用途就是用在函数参数传递时进行强制类型转换来消除编译器的警告: bind(fd, (struct sockaddr) &addr, sizeof(addr));
不能用它来存储不同类型的地址结构,因为它的大小不足以存储所有的数据。
IPv6套接字API新定义了一个通用套接字地址结构,它的大小可以容纳所有的套接字地址结构,即可以将任意类型的socket地址结构强制转换并存储在这个结构中:
#include
struct sockaddr_storage {
sa_family_t ss_family;
__ss_aligntype __ss_align; /* Force alignment */
char __ss_padding[SS_PADSIZE]; /* Pad to 128 bytes */
};
需要用到的只有ss_family
,用来确定套接字地址结构的类型,然后强制类型转换到相应的类型再使用。
主机地址和端口的表示有下面几种方法:
主机地址表示为一个二进制值或一个符号主机名(域名)或展现格式(IPv4为点分十进制,IPv6为16进制字符串)。
端口号表示为一个二进制值或一个符号服务名。
二进制格式的地址不易于记忆和使用,人们更倾向于使用点分十进制来表示IPv4地址,也提供了几个二进制形式和点分十进制形式的转换函数:
#include
int inet_aton(const char *str, struct in_addr *addr);
in_addr_t inet_addr(const char *str);
char *inet_ntoa(struct in_addr inaddr);
上面的函数只提供IPv4地址的转换,这些函数已经被废弃了,需要使用下面的函数,提供IPv4和IPv6地址的转换:
#include
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, size_t size);
#include
#define INET_ADDRSTRLEN 16 /* Maximum IPv4 dotted-decimal string */
#define INET6_ADDRSTRLEN 46 /* Maximum IPv6 hexadecimal string */
p代表presentation,n代表network:
af用来指定IP地址版本:AF_INET
或 AF_INET6
。
void *
指针是二进制格式地址参数,需要指向in_addr
或in6_addr
结构而不是结构体内部的整型(IPv4)或整型数组(IPv6)。
char *
指向字符串格式地址,其中size为目标缓冲区大小。定义了两个常量,标志了地址的最大长度(包含结尾NULL)。
还有一些功能更强大的函数,提供了主机和服务名与二进制形式之间的转换:
#include
struct hostent *gethostbyname(const char *hostname);
struct hostent *gethostbyaddr(const void *addr, socklen_t len, int family);
struct servent *getservbyname(const char *servname, const char *proto);
struct servent *getservbyport(int port, const char *proto);
这些函数也已经过时了,需要使用下列函数,来提供协议无关的服务:
#include
#include
int getaddrinfo(const char *host, const char *serv,
const struct addrinfo *hints, struct addrinfo **res);
void freeaddrinfo(struct addrinfo *res);
int getnameinfo(const struct sockaddr *addr, socklen_t addrlen,
char *host, size_t hostlen,
char *serv, size_t servlen, int flags);
struct addrinfo {
int ai_flags; /* Input flags (AI_*) */
int ai_family; /* Address family */
int ai_socktype; /* Type: SOCK_STREAM, SOCK_DGRAM */
int ai_protocol; /* Socket protocol */
size_t ai_addrlen; /* Size of structure pointed to by ai_addr */
char *ai_canonname; /* Canonical name of host */
struct sockaddr *ai_addr; /* Pointer to socket address structure */
struct addrinfo *ai_next; /* Next structure in linked list */
};
getaddrinfo
函数同时提供了主机和服务的转换服务,函数以host
、serv
和hints
参数作为输入:
host
可以是一个主机名或一个点分十进制的IPv4地址或十六进制字符串的IPv6地址。注意host也可以是NULL
,一般只用来创建监听套接字。
serv
可以是一个服务名或一个十进制端口号字符串。
hints
参数用来决定res
返回的结果,只能设置下面几个元素:
ai_family
可以是AF_INET
或 AF_INET6
,当指定为AF_UNSPEC
时,将返回所有种类的地址结构。
ai_socktype
指定套接字的类型。指定为SOCK_STREAM
,将返回适用于TCP流式套接字使用的地址结构;指定为SOCK_DGRAM
,将返回适用于UDP数据报套接字使用的地址结构;指定为0
,将返回任意类型。
ai_protocal
指定协议,一般被设置为0
。
ai_flags
是一个位掩码,用于指定getaddrinfo()
的行为,可以为0或者多个选项相或,最常用的选项有:
AI_PASSIVE
用于返回一个适合创建监听套接字的地址结构。当指定了这个选项,host
应设置为NULL
,通过res
返回的IP地址部分将会使用通配地址(INADDR_ANY 或 IN6ADDR_ANY_INIT);如果没有设置这个选项,那么返回的地址将适用于创建主动套接字,如果host为NULL,IP地址将会被设置为回环地址(loopback)。
AI_NUMERICHOST
强制将host
解释为一个数值地址字符串,避免进行名字解析耗费时间(通常会使用DNS服务)。
AI_NUMERICPORT
对端口号进行上面解释。
res
是一个二级指针,需要将一个指针引用传递。它指向一个动态分配的结果链表,因为一个主机可能会有多个地址结构。返回的地址结构可以直接用来调用套接字相关函数。getaddrinfo()
是可重入的,需要在调用结束后使用freeaddrinfo()
释放掉结构链表。
通常利用getaddrinfo
来构造协议无关
的程序,一般用法大致为:
struct addrinfo hints, *res, *resp;
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM; /* SOCK_DGRAM */
hints.ai_flags = AI_PASSIVE; /* 服务器端设置,客户端去掉这句 */
/* 客户端指定的地址为需要连接的地址,服务器指定的地址为自身的地址 */
/* 省略部分错误处理 */
getaddrinfo(host, serv, &hints, &res);
for (resp = res; resp != NULL; resp = resp->ai_next) {
fd = socket(resp->ai_family, resp->ai_socktype, resp->ai_protocol);
if (fd == -1)
continue;
/* 服务器端 */
/* 省略设置选项,比如SO_REUSEADDR */
if (bind(fd, resp->ai_addr, resp->ai_addrlen) == 0)
break; /* 成功 */
close(fd);
/* 客户端 */
if (connect(fd, resp->ai_addr, resp->ai_addrlen) == 0)
break;
close(fd);
}
if (resp == NULL) {
/* 错误处理 */
}
/* 服务器端 */
listen(fd, BACKLOG);
/* 释放结构 */
freeaddrinfo(res);
getnameinfo()
通过给定一个socket地址结构,返回包含对应的主机和服务名的字符串或者在无法解析名字时返回一个等价的数值。当不需要某一项时,可以指定为NULL,并设置长度为0。
TCP编程的最基本的流程就如上图所示。
socket(2)
服务器端和客户端都需要创建流式套接字,通过socket()
系统调用返回一个套接字描述符来完成。
#include
int socket(int domain, int type, int protocol);
/* 创建TCP套接字 */
fd = socket(AF_INET, SOCK_STREAM, 0);
AF代表的是address family(地址族),也有相对应的PF,也就是protocol family(协议族),通常都会有#define AF_INET PF_INET
,所以这两个是等价的。TCP是字节流协议,只支持SOCK_STREAM
。最后一个参数通常设置为0,会根据给定的前两个参数组合选择适当的系统默认值。在较新版本的Linux中,提供了SOCK_NONBLOCK
和 SOCK_CLOEXEC
标记,可以用来创建非阻塞套接字和启用close-on-exec标志。
bind(2)
#include
int bind(int sockfd, const struct sockaddr *addr, sockelen_t addrlen);
bind()
调用用来给套接字绑定地址结构,对于TCP来说,是将一个IP地址和端口号赋予给套接字。也可以不绑定地址,这种情况和绑定端口号0及IP地址0等价,会由内核选择IP地址和临时端口。
对于服务器端来说,一般需要绑定一个众所周知的端口,也可以选择绑定一个特定的IP地址,不过一般会绑定通配地址INADDR_ANY
也就是0,内核等到套接字已连接(TCP)或已在套接字上发出数据报(UDP)时才选择一个本地IP地址。客户端一般不需要调用bind
,不过更好的办法是使用上面的getaddrinfo()
创建协议无关的程序:
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(fd, (struct sockaddr) &addr, sizeof(addr));
bind()
调用最常见的错误是EADDRINUSE
(Address already in use),这是因为绑定了重复的端口号导致的。在之前的网络协议里解释过TIME_WAIT
状态和判定连接的五元组,
处于TIME_WAIT状态时或者已经有相同端口号的连接时(例如,服务器接收连接,创建一个子进程处理连接,后来,服务器终止,子进程仍在运行),
不能绑定相同的端口号,设置SO_REUSEADDR
选项,可以绑定相同的端口号,只要使用不同的IP地址即可。
在Linux上设置该选项,绑定了通配地址的端口只能有一个实例。服务器通常都会在调用bind前设置该选项:
#include
const int on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
connect(2)
通过socket()
调用创建的是主动套接字,这时可以调用connect()
来连接到一个被动套接字(监听套接字)。
#include
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect()调用成功,套接字变为ESTABLISHED
状态,但不代表连接已经被接受,也就是说即使服务器不调用accept(2)
,connect()也会成功返回,这时客户端仍可以发送数据,数据由TCP排队存储在套接字的接受缓冲区中。
一般情况下,connect会阻塞到建立成功或出错,出错主要有下面几种:
ETIMEOUT
,客户端没有收到ACK响应,超时重传到一定次数后返回该错误,有可能是网络紊乱导致或者服务器监听队列已满丢弃SYN或者没有网络中没有对应主机导致ARP超时。在Linux上测试,当队列已满还是会完成前两次握手,同时可以向套接字写,具体见下面listen
调用。
ECONNREFUSED
,服务器主机在指定的端口上没有进程在等待连接,服务器主机收到连接请求会响应RST
。
EHOSTUNREACH
和 ENETUNREACH
,当指定的主机或网络不可达时,就会在路由器上产生ICMP错误,这是发送方超时并重传,直到放弃返回ICMP指定的错误。
listen(2)
listen()
将主动套接字转换为被动套接字,也就是监听套接字,可以接收连接,状态从CLOSED状态转换为LISTEN状态。
#include
int listen(int fd, int backlog);
内核为监听套接字维护了两个队列:
未完成连接队列,每个SYN段对应其中一项,服务器正在完成相应的TCP三路握手,套接字处于SYN_RCVD状态。
已完成连接队列,每个已完成TCP三次握手的套接字对应其中一项,处于ESTABLISHED状态。
backlog
用于指定队列的大小,不同的实现有不同的解释方法,一般会是设置的值乘上一个系数,比如1.5。backlog设置为0时,在Linux上仍可以接收1个连接,当backlog过大时,会被截取到限制的最大值,通常会是128。
当连接太频繁时,队列可能会被充满。当两个队列都满时,TCP会忽略后面的SYN,客户端可以重发SYN请求连接。当已完成连接队列已满时而未完成连接未满时,仍有客户连接进来,就会被存放在未完成连接队列,并向客户端发送SYN + ACK段,使客户端的connect()
返回成功。当客户向服务器发送三次握手的最后一个ACK时,服务器会丢弃该ACK,因为已完成连接队列没有空间存放新的套接字。由于没有收到ACK,服务器会重传SYN + ACK,持续5次,之后等待已完成连接队列有空间或超时,若超时则将该套接字丢弃。客户端在connect()
成功返回后,可以向套接字写不产生错误,这时由于连接没有建立,服务器不会发送ACK,会导致写的数据重传,当连接成功建立就会存储在接受缓冲区,如果超时,就会收到RST并返回错误。但如果没有发送数据,就不会产生RST,即使调用read()
也不会产生错误,会一直阻塞下去。
accept(2)
accept()
用于从上面的已完成连接队列对头返回下一个已完成连接,如果已完成连接队列为空,会阻塞(默认为阻塞套接字)。
#include
int accept(int fd, struct sockaddr *addr, socklen_t *addrlen);
addr
用于返回连接到的对端地址。addrlen
是值-结果参数,需要初始化为addr的大小,返回时,值为对端地址结构的大小,可以都设置为NULL
。accept()
调用成功会返回一个已连接套接字,通过该套接字和对端进行通信。accept()
返回的套接字会继承监听套接字的一些选项,主要有TCP_KEEPALIVE
、TCP_NODELAY
,不继承O_NONBLOCK
。
在调用accept()
时可能会出现下面几种状况:
在调用accept()
前,服务器接收到FIN
。当服务器接收该连接时,会成功返回一个套接字并立刻发送FIN和ACK,完成四次挥手,之后对该套接字进行操作会出错,与操作已关闭套接字效果一样,见close(2)
。
在调用accept()
前,服务器接收到RST
,有两种情况:
在未完成队列里时收到RST
,也就是发送了三次握手的第一个TCP段后,紧跟着发送一个RST
。调用accept()
返回ENOTSOCK
错误。
在已完成队列里时收到RST
,也就是完成三次握手,再发送RST
,这时调用accept()
会成功,但是之后对已连接套接字进行I/O操作,会返回ECONNRESET
错误
这都是在(Ubuntu 14.04)测试的,有些实现可能会返回ECONNABORTED
或忽略这种情况。
getsockname(2)/getpeername(2)
#include
int getsockname(int fd, struct sockaddr *addr, socklen_t *addrlen);
int getpeername(int fd, struct sockaddr *addr, socklen_t *addrlen);
这两个函数用来返回已连接套接的地址。getsockname()
返回本端地址,可以用来确定内核分配的地址;getpeername()
返回对端地址。
close(2)
close()
调用用来关闭套接字,释放连接,并回收描述符资源。
#include
int close(int fd);
close()
默认行为是将套接字标记为已关闭,该套接字描述符不能再由调用进程使用。需要注意的有以下几点:
描述符是引用计数的,close()
调用成功将使计数减一,计数为0时,才会向对端发送FIN,关闭连接。先发送FIN
的一端执行的是主动关闭,如果四次挥手完成,会进入到TIME_WAIT
状态,服务器一般会设置SO_REUSEADDR
选项,见bind(2)
。
如果套接字发送缓冲区中有数据没有发送,会将数据发送到对端,但不等待对端确认就返回,即不能够确定数据是否成功发送到对端。如果套接字引用计数为0,后面跟着正常的TCP终止序列。
如果套接字接受缓冲区中有数据没有读,关闭套接字会发送RST
替代正常的FIN
序列,这会导致略过TIME_WAIT
状态,直接进入CLOSED
状态。
当对端连接已关闭后,如果继续向对端发送数据,第一次写会调用成功,对端响应RST
,第二次写就会收到SIGPIPE
并返回EPIPE
错误,SIGPIPE
默认是关闭进程,所以经常会设置signal(SIGPIPE, SIG_IGN)
。
如果对端连接关闭,读套接字会返回0。有些实现会在收到RST
后,返回ECONNRESET
错误,例如先向对端已关闭的套接字发送数据,之后再读。在我的电脑上测试不管收不收到RST,都会返回0。
设置SO_LINGER
选项会影响close()
调用的效果:
#include
struct linger {
int l_onoff; /* Nonzero to linger on close */
int l_linger; /* Time to linger */
};
struct linger linger;
linger.l_onoff = 1;
linger.l_linger = x;
setsockopt(fd, SOL_SOCKET, SO_LINGER, &linger, sizeof(linger));
默认情况是关闭的,也就是上述的情况,close()
调用立即返回。
当l_onoff
为非零时,则开启该选项。l_linger
有两种情况:
l_linger = 0
时,close()
立即返回,套接字缓冲区的数据都被丢弃,发送缓冲区的数据不会发送。如果计数引用为0,则向对端发送RST
,直接进入CLOSED
状态。
l_linger != 0
时,close()
不立即返回,会“逗留”一端时间,用来发送发送缓冲区中的数据,直到发送缓冲区的数据全部被确认或超时。若超时,close()
返回EWOULDBLOCK
错误,残留的数据被丢弃。之后跟以正常的TCP终止序列。
设置该选项后,close()
成功返回告诉我们数据已全部成功发送到对端,但不能知道应用进程是否读取数据。不设置则不能保证数据是否被确认。
shutdown(2)
TCP是全双工的通信,可以通过shutdown()
系统调用来关闭一个方向的通信:
#include
int shutdown(int fd, int how);
调用该函数可以不管引用计数就发送TCP正常的连接终止序列,也就是说该函数影响的其实是打开的文件描述。根据how
参数,可以进行下面三种操作:
SHUT_RD
关闭当前连接的读部分。不要使用这个参数,它没有实际意义,在很多实现中没有提供期望的行为。在Linux上,即使关闭读部分,仍能读到数据,仍能读到对端在关闭后写入的数据。
SHUT_WR
关闭当前连接的写部分。当前发送缓冲区的数据会被发送掉,然后发送正常的TCP终止序列。对端程序读取完所有的数据后,会读到0。这是最常用的操作。
SHUT_RDWR
全双工的关闭操作。
shutdown()
调用不会释放套接字描述符和它的资源,仍需要调用close()
。
主要有下面几种:
阻塞I/O:默认设置,会阻塞在等待数据和数据在内核和用户空间的复制。
非阻塞I/O:不等待数据,但会阻塞在数据的复制环节。
I/O多路复用:select()
、poll()
和Linux特有的epoll
。阻塞在这三个系统调用上,返回描述符的可读可写条件。数据输入输出还是需要调用真正的I/O操作。
信号驱动I/O:开启套接字的该功能,当数据准备好时会发送SIGIO
信号通知进程,同样需要调用真正的I/O操作。
异步I/O:告知内核启动某个操作,让内核完成整个操作后通知进程,包括数据在内核和用户空间的传输,通常也是发送信号进行通知,该类型不会造成进程阻塞。前面几种都是同步I/O,都会造成进程阻塞,因为都会阻塞在数据的复制环节。
内核为每一个TCP套接字都维护了一个发送缓冲区和一个接收缓冲区:
写操作是从应用程序缓冲区拷贝数据到发送缓冲区。如果当前发送缓冲区空间不够,就会阻塞直到所有数据复制完毕(默认行为)。数据的传输由内核和TCP/IP协议栈完成,缓冲区中的数据会被分段传输,最大为MSS大小,之后以MTU大小的IP数据报在网络中传输。发送缓冲区中的数据直到收到对端的ACK才会被丢弃。
读操作是从接收缓冲区拷贝数据到应用程序缓冲区。如果当前接收缓冲区中没有数据,会阻塞到有数据可读。读操作一般不会返回指定大小的数据,如果数据比较少,会发生部分读。
在TCP三次握手时,会通告对端窗口大小,也就是接受缓冲区的大小,可以通过设置SO_RCVBUF
和SO_SNDBUF
选项改变默认缓冲区大小。
#include
int buf_size = x;
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf_size, sizeof(buf_size));
要注意设置的顺序问题,因为窗口大小是在建立连接时通告,所以服务器端需要在listen()
之前设置;客户端要在connect()
之前设置。
影响输出的因素还有Nagle算法
,详见上面协议部分。设置TCP_NODELAY
选项可以关闭该算法:
#include
#include /* for TCP_* */
int on = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
read(2)/write(2)
最基本的描述符输入输出系统调用。
#include
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, void *buf, size_t count);
readv(2)/writev(2)
有时需要将多个缓冲区的数据同时发送到网络上(原子操作),比如消除Nagle算法的影响。
#include
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
struct iovec {
void *iov_base; /* staring address */
size_t iov_len; /* size of buffer */
};
参数iov
指向iovec
结构体数组,每个数组元素包含一个缓冲区和缓冲区大小。
iovcnt
用来指明iov
中数组元素个数。
sendfile(2)
很多程序需要传递整个文件内容,比如Web服务器和文件服务器。
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
常见的传递文件操作为调用循环,类似:
while ((n = read(fd, buf, BUF_SIZE)) > 0)
write(sockfd, buf, n);
但这会带来很大的开销,数据在用户态和内核态来回传输,调用系统调用的开销也比较大。sendfile()
采用了零拷贝传输,在内核空间内就完成文件内容到套接字的传输。offset
为文件起始处偏移量,一般设置为NULL。
sendfile()
长和TCP_CORK
选项(头文件为linux/tcp.h
)一起使用,尤其在Web服务器当中,需要发送HTTP首部和页面数据。设置该选项候,所有的输出会缓冲在一个TCP段中,直到:
recv(2)/send(2)
#include
ssize_t recv(int fd, void *buf, size_t len, int flags);
ssize_t send(int fd, void *buf, size_t len, int flags);
这两个系统调用提供了专属于套接字的功能,前三个参数和read()/write()相同,flags最常见的有下面几个(详见 man 2 recv
):
MSG_DONTWAIT
使该次调用非阻塞,如果不能立即完成返回EAGAIN
错误。
MSG_WAITALL
阻塞到读取完指定大小的数据,但是仍很大可能返回不足值,比如出错、中断等等,用途不大。
MSG_NOSIGNAL
防止写出错产生SIGPIPE
信号终止进程。
MSG_OOB
发送带外数据。
MSG_MORE
效果同TCP_CROK
,只适用于该次调用。
MSG_PEEK
预览数据,之后可以再次读取。
recvfrom(2)/sendto(2)
#include
ssize_t recvfrom(int fd, void *buf, size_t len, int flags,
struct sockaddr *addr, socklen_t *addrlen);
ssize_t sendto(int fd, const void *buf, size_t len, int flags,
struct sockaddr *addr, socklen_t *addrlen);
recvfrom()
最后两个参数用于返回对端的地址,sendto()
最后两个参数用于指定目的地址。这两个函数主要用于UDP
,因为UDP是无连接的,一般也不用connect()
,所以需要指定地址。
recvmsg(2)/sendmsg(2)
这是最通用的I/O函数,基本上所有的调用都可以使用这两个函数。使用方法比较复杂,最大的用途是用来发送辅助数据。
输入输出的注意事项
部分读:接受缓冲区中的数据比请求的少时,会返回可读的数据。当读取被信号中断时,不会读取数据,直接返回错误。
部分写:发送缓冲区空间不足以传输所有的字节,在传输了部分字节到缓冲区后,调用出错,例如被信号中断、非阻塞写、异步错误。只要成功发送数据到缓冲区就会返回成功的字节数。
对端出错。当仅收到RST
时,读写都返回ECONNREST
错误;当收到FIN
时,读永远返回0(FIN
比RST
优先级大),写第一次产生RST
,第二次产生SIGPIPE
信号,返回EPIPE
错误;超时,返回ETIMEOUT
。举例:
FIN
给对端。RST
。TCP是字节流协议,没有消息边界的概念。你不知道对端会发送多少数据给你,也不能依赖于有消息边界的通信,除非每一条消息都有固定的长度。一般需要自己设定协议,常见的有:
用分隔符标志记录结束。例如HTTP协议,用\r\n
分隔。不过要注意的是如果在数据中需要包含分隔符,需要使用转义字符或者特殊编码。
在每一条消息前面附加一个消息头,用来指定后方数据的长度,消息头长度固定,所以读取数据可以先读取固定长度的消息头,然后根据消息头中的数据读取指定的数据量。
最好不要传递二进制结构,尤其是结构体,因为有填充的空间。使用字符串编码。
使用输入输出系统调用,最好不要使用标准I/O函数。因为标准I/O为了减少系统调用提高效率,在函数内部维护了一个不可见的缓冲区,对于套接字是完全缓冲,如果一定要用也要设置为无缓冲,不过也失去了效率优势。混用也会出现发麻,所以不要使用标准I/O。
如果需要确定数据被对端应用程序收到,需要发送一个用户级别的确认。
keepalive
当连接没有数据交互时,没有办法发现对端出错或连接是否终止,直到调用输入输出函数才会将错误返回。设置SO_KEEPALIVE
选项可以开启TCP提供的检测机制,一段时间内没有数据交互,就会发送一个TCP探测段,错误处理与发送数据相同。默认的超时时间大约是2小时,比较长,可以设置TCP_KEEPIDLE
来更改定时时间。
#include
#include
int on = 1;
int time = x;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &time, sizeof(time));
设置的超时时间单位为秒,在Linux上,该时间不是内核级别,是基于每个套接字设置。当使用该选项检测到错误也需要数据交互才能发现,最常用的是使用I/O多路复用返回可读或可写条件来返回错误。也可以自己实现应用级别的心搏机制(heartbeat)。
非阻塞套接字
因为网络服务器一般需要服务多个客户,不可以在一个套接字上阻塞太长时间。可以设置定时机制,使用alarm(2)
或者select(2)
或设置SO_RCVTIMEO
和SO_SNDTIMEO
选项或其他定时机制来实现超时机制,不过最常见的还是设置非阻塞套接字。
有下面几种方法设置非阻塞套接字(Linux上):
socket(2)
调用可以设置SOCK_NONBLOCK
选项创建非阻塞套接字。
accept4(2)
设置SOCK_NONBLOCK
选项返回非阻塞套接字,使用该函数需要设置特性测试宏#define _GNU_SOURCE
。
使用fcntl
,最通用的方法:
#include
#include
int flags;
flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
当使用非阻塞I/O时,如果没有数据可以读入或输出,就返回EAGAIN
错误,EWOULDBLOCK
和EAGAIN
相同。如果可以读入一部分或输出一部分,就返回成功的字节数。
使用非阻塞I/O对缓冲区的操作会比较复杂,如果需要指定数据量,可以编写类似readn()
或writen()
的函数,用循环来多次调用I/O。
非阻塞accept()
非阻塞的accept()
在没有可用连接时返回EAGAIN
,使用该版本可以预防阻塞和已连接套接字错误,比如被其他线程先接收连接,这时再调用会阻塞。当accept之前的连接被终止时导致没有可用连接,有的实现会忽略错误导致阻塞在accept,而Linux会将错误码返回。
非阻塞connect()
默认的行为是等到接收到对端的SYN + ACK
再返回。如果连接不能立即建立,会阻塞一段时间,直到出错或成功建立连接。使用非阻塞的版本会在不能立即建立连接的情况下,立即返回EINPROGRESS
错误,当连接的目标在同一主机上时,会成功返回。最常见的用法是为了同时建立多个连接,例如Web浏览器。
确定连接是否成功建立需要使用select()
或poll()
或epoll()
,监控套接字返回可写条件,不过因为连接出错会同时返回可读和可写,所以在可写时,要检查SO_ERROR
:getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &len)
,当建立成功时,error
为0;建立失败,error
为相应的错误,需要关闭套接字,重新创建再连接,不能再次调用connect()
会返回EADDRINUSE
。当阻塞的connect()
被中断时,也需要使用上面的方法。
信号驱动I/O
使用信号驱动I/O需要设置O_ASYNC
标志和该套接字的属主,同时还需要建立SIGIO
的信号处理器函数。当有输入输出时,就会发送SIGIO
信号给进程,信号的频繁出现会使程序变得非常复杂,所以现在用的很少。
I/O多路复用
多路复用是为了实现同时检查多个文件描述符,看它们是否准备好了执行I/O操作。使用非阻塞I/O的轮询也可以实现,但是太耗费CPU。使用多线程或多进程也可以实现,但耗费过多资源,数据通信也会造成麻烦。最好的方法是使用select()
或poll()
或Linux特有的epoll()
,其中epoll()
效率最高最常用。
多路复用有两种触发机制,这两种机制很像电平,水平触发类似高低电平,边缘触发类似上升沿或下降沿。只是触发(通知)的方式不同,两者返回的结果是相同的,只要监控的事件可用就会返回
:
水平触发(LT): 当文件描述符上可以非阻塞地执行I/O系统调用,就通知就绪。也就是在接收缓冲区中有数据就通知可读条件;在输出缓冲区中有空间就通知可写条件。
边缘触发(ET):当文件描述符自上次状态检查以来有了新的I/O活动,就通知就绪。当有新的数据到达时,就通知可读条件;当输出缓冲区有数据被发送,也就是空闲空间增大时,就通知可写条件。
边缘触发有可能会出现描述符饥饿现象。当一个描述符上出现大量的输入存在时(一般是个不间断的输入流),就会使进程停留在该描述符的读时间过长,导致其他的描述符处于饥饿状态。水平触发没有这种风险,因为水平触发只需要执行一部分I/O操作。
多路复用常与非阻塞I/O一起使用,原因有下面几个:
当需要将缓冲区的所有数据读完时,如果循环使用阻塞I/O,因为不知道数据量,最后会进入阻塞。
当使用边缘触发时,必须使用非阻塞I/O,需要尽可能多的执行I/O,直到返回EAGAIN
。因为如果后续该套接字没有I/O事件,就会造成数据的丢失或程序阻塞。
如果多个进程或线程在同一个打开的文件描述符上进行I/O操作,文件描述符的状态可能会在通知就绪和执行I/O之间发生改变,造成之后的阻塞。
当通知可写时,需要写大块的数据,但发送缓冲区没有那么多空间,就会造成阻塞。
有时select()
和poll()
会返回虚假的通知,例如当收到的TCP段的checksum出错时,会通知就绪状态,但是数据会被丢弃,导致阻塞。
select(2)
#include /* For portability */
#include
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
void FD_ZERO(fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_CLR(int fd, fd_set *fdset);
void FD_ISSET(int fd, fd_set *fdset);
struct timeval {
time_t tv_sec;
suseconds_t tv_usec;
};
select()
出现的时间比较早,有许多问题,效率也不是很高。fd_set
为文件描述符集合,通常被实现为位掩码,容量由常量FD_SETSIZE
决定,在Linux上是1024,select()
只能处理大小小于等于1024的描述符。
提供了4个宏操作fd_set
,隐藏了内部的实现细节:
FD_ZERO()
初始化集合为空。
FD_SET()
将文件描述符添加到指定集合。
FD_CLR()
将文件描述符从指定集合移除。
FD_ISSET()
检查文件描述符是不是指定结合的成员。
select()
检查三个文件描述符集合:
readfds
用来检测输入就绪。writefds
用来检测输出就绪。exceptfds
用来检测异常情况。异常情况通常只有在流式套接字上接受到带外数据。首先需要使用FD_ZERO()
和FD_SET()
初始化集合。当调用返回时,这三个集合被设置为已就绪的文件描述符集合,需要使用FD_ISSET()
检查每一个感兴趣的文件描述符。当需要再次调用select()
时,需要重新初始化集合。不感兴趣的集合可以设置为NULL
。
nfds
至少需要设置为比3个文件描述符集合中所包含的最大的文件描述符大1。内核不会去检查大于这个值的文件描述符。
timeout
控制select()
的阻塞行为:
NULL
阻塞。
结构体内部值不都为0,计时指定时间。
结构体内部值为0,立即返回。
当select()
返回时,不管是成功返回还是被信号中断,如果时间没有超时,timeout
会被更新为剩余的超时时间。select()
返回-1,为出错;返回0,为超时;返回正整数为3个集合中已就绪的文件描述符总数。
poll(2)
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd;
short events; /* requested events */
short revents; /* returned events */
};
fds
为需要检查的文件描述符集合。结构体中fd
为需要检查的文件描述符,设置为负值将忽略该元素。events
设定为需要检查的事件,设置为0,将关闭对该文件描述符的检查。revents
为返回时发生的事件。主要有下面几个位掩码:
POLLIN
有数据可读。POLLPRI
TCP接收到带外数据。POLLOUT
可写。POLLRDHUP
对端关闭写部分。POLLNVAL
监视的描述符被关闭。(不需要设置)timeout
是以毫秒为单位的时间。设置为-1时,将阻塞;为0,检查一次立即返回;大于0,阻塞指定时间。
返回值和select()
类似,-1为出错或中断;0为超时;大于0为fds
中revents
不为0的数目。
poll()
和select()
主要有下面几点不同:
当监视的文件描述符被关闭时,select()
返回-1和EBADF
,poll()
返回POLLNVAL
。
select()
对文件描述符有限制,上限大小为1024,poll()
没有。
select()
需要每次重新初始化fd_set
,poll()
不需要。
select()
需要检查从0到nfds-1之间的所有文件描述符,即使有些你没有设置。poll()
只检查你指定的文件描述符。在集合比较稀疏但大小相差比较大的情况下,select()
会比poll()
慢许多。
poll()
和select()
比较相似的也有相同的问题:
只支持水平触发。
内核需要轮询检查所有被指定的文件描述符,效率会随着文件描述符数量的上升而线性下降。
每次调用,都需要传递文件描述符集合到内核,内核检查完毕再修改该集合返回给程序。从用户空间到内核空间来回拷贝数据将占用大量CPU时间。
需要检查返回的集合中的每个元素。select()
需要为每个描述符使用FD_ISSET()
,poll()
需要检查每一个元素的revents
。
这些问题主要是由于内核不会在每次调用中记录下需要检查的文件描述符集合,因为通常情况下需要检查的文件描述符集合都是相同的。
epoll(2)
epoll()
作为Linux特有的API,与前两个相比有很大提高。
#include
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
struct epoll_event {
uint32_t events;
epoll_data_t data;
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll在内核中维护了一个数据结构,该数据结构有两个目的:
记录了在进程中声明过的感兴趣的文件描述符列表————interest list(兴趣列表)。
维护了处于I/O就绪态的文件描述符列表————ready list(就序列表)。
首先需要调用epoll_create()
创建一个epoll实例,返回一个文件描述符,是该内核数据结构的句柄。size
参数用来告诉内核如何为内部数据结构分配初始大小,现在已经不用了,只需要设为一个正数。
epoll_ctl()
修改epfd
所代表的epoll实例的兴趣列表。fd
为要修改的兴趣列表中的文件描述符,不可以是普通文件描述符(EPERM),甚至可以是另一个epoll实例描述符,可以监视修改该实例的兴趣列表。op
有下面几种操作:
EPOLL_CTL_ADD
将描述符fd添加到epoll实例的兴趣列表中。添加一个已存在的文件描述符会出现EEXIST
错误。
EPOLL_CTL_MOD
修改描述符fd设定的事件。如果该描述符不在兴趣列表中,会返回ENOENT
错误。
EPOLL_CTL_DEL
将文件描述符fd从兴趣列表删除。如果该描述符不在兴趣列表中,返回ENOENT
。当文件描述符被关闭时引用计数为0,会自动从兴趣列表中删除,不会出错。
ev
参数用来指定需要监视的事件,并提供了一个可以传回给调用进程的信息。events
是一个位掩码,可以指定这几种事件:
EPOLLIN
有数据可读。 EPOLLOUT
可写。 EPOLLPRI
有带外数据到达。EPOLLRDHUP
对端套接字关闭或关闭写端。EPOLLET
采用边缘触发,默认为水平触发。EPOLLONESHOT
在完成一次事件通知后禁用检查。重新激活需要使用EPOLL_CTL_MOD
不能使用EPOLL_CTL_ADD
,因为该文件描述符还在epoll实例中。唯一可获知发生事件的文件描述符的途径就是通过data
字段。可以通过将data.fd
设置为该文件描述符,最好在之前初始化一下:data.u64 = 0
,因为fd只占32字节。或者将data.ptr
指向包含文件描述符的结构体。
epoll_wait()
用来等待事件发生。参数evlist
返回已就绪的文件描述符的信息,空间需要自己分配。maxevents
用来指定evlist
的元素个数。timeout
和poll()
一样。调用成功后,返回数组evlist
的元素个数;超时返回0;出错为-1。
当创建一个epoll实例时,内核在内存中创建了一个新的i-node并打开文件描述,内核中维护的数据结构(兴趣列表等)是与打开的文件描述相关的:
当使用epoll_ctl()
添加了一个元素到兴趣列表中,这个元素同时记录了该文件描述符和监控的事件结构还有该描述符对应的打开的文件描述。当所有指向该打开的文件描述的文件描述符都被关闭后,这个打开的文件描述才会从epoll的兴趣列表中删除,如果有dup
的还存在,epoll_wait
将仍会返回之前的fd
。可以添加复制的文件描述符到同一epfd
。
如果epfd
指向同一个打开的文件描述(例如通过fork()
、dup()
),则兴趣列表也相同。通过其中一个epfd
修改兴趣列表会影响所有的实例。
当有一个线程阻塞在epoll_wait()
时,另一个线程添加兴趣列表会立即生效,并且是线程安全的,不过好多程序不这么做,而是使用管道,比如主线程接收连接,然后向与工作线程相连的管道写,通知工作线程添加该连接到兴趣列表。
与select()
和poll()
不同,epoll
不是无差别轮询,当有I/O操作使文件描述符就绪时,会出现中断,然后调用callback
函数。该回调函数会在就绪列表中添加元素。epoll_wait()
就是简单的从就绪列表中取出元素。
需要监视的文件描述符是在内核中维护的,不再需要来回在用户空间和内核空间传递数据(epoll_wait
时)。
套接字就绪条件
读:
套接字接收缓冲区中的数据字节数大于等于低水位标记。设置SO_RCVLOWAT
可以修改低水位标记,默认为1。
对端关闭写部分(接收到FIN),shutdown(fd, SHUT_WR)
或关闭套接字。
监听套接字有连接可以接受。
写:
套接字发送缓冲区的可用空间字节数大于等于低水位标记。设置SO_SNDLOWAT
可以修改低水位标记。
对端套接字关闭。写将产生RST
和SIGPIPE
。
非阻塞connect()
成功建立连接或失败。
当套接字出错时,既可读又可写,会返回-1和错误值。
惊群现象
经常会出现多进程或多线程的服务器。如果有多个accept()
阻塞在接受同一个监听套接字的连接(等待相同的fd进行读写一般没什么意义,所以最常见的在监听套接字,不过为什么我测试读写没发现惊群,难道是我姿势不对),会唤醒所有的线程或进程,导致性能下降,这就是惊群。Linux2.6内核已经修复了这种现象,通过加锁只会唤醒一个调用线程或进程。
更常见的是使用I/O多路复用技术。当有多个epfd
等待相同的文件描述符就绪时也会发生惊群(可以是不同的epfd
,也可以是fork
的相同的epfd
),将惊群从accept
提前到了epoll
,这时只有一个可以成功连接,其余accept
均返回错误。在Linux4.5内核,提供了EPOLLEXCLUSIVE
,可以在EPOLL_CTL_ADD
时与需要监听的事件相或来解决惊群现象,只会唤醒设置了这个选项的并且阻塞在epoll_wait
的线程(?)。
有一种更好的办法用于epoll_wait
,使用SO_REUSEPORT
监听相同地址和端口的套接字,内核会将连接分配给不同的套接字实现简单的负载均衡,也不会出现惊群。
UDP
UDP是无连接的协议也没有流量控制。一个UDP套接字可以和多个UDP套接字通信,只要对端的目的地址指向该套接字。UDP没有真正的发送缓冲区,只有一个UDP数据报的大小上限,当大小合适时会立刻发送出去,不需要保存副本。
UDP获知错误会比较麻烦,因为是无连接的,通常不会返回给本地套接字。虽然是无连接的协议,但是可以使用connect()
将UDP套接字连接起来,不会有数据上的交互,只是绑定了目的地址和端口号到本地套接字上。有下面几种作用:
不需要也不能给输出操作指定目的地址。所有的输出都会发送到已连接的地址。
只能接收到已连接的套接字发送的数据,不会收到其他套接字发送的数据。
当出现异步错误时会立即返回。
提高一定的效率。
UDP的连接是不对称的,只是对使用connect()
的这一端起作用。当再次调用connect()
时,可以修改连接到的地址,当地址族为AF_UNSPEC时,可以解除连接。
UNIX域套接字
UNIX域套接字的地址是使用路径名来表示的,不会由内核分配地址,必须调用bind()
,无法将一个套接字绑定在已存在的路径名上,可以在绑定之前调用unlink()
。绑定成功会在文件系统中创建一个条目,当关闭套接字时也不会删除文件。可以使用Linux抽象socket名空间,只需要指定sun_path
的第一个字节为NULL,不会出现冲突,关闭套接字会自动删除。
使用socketpair()
可以创建一对已连接的UNIX域套接字,相当于创建了一个双向管道,最常用的用法是父子进程各使用一个进行IPC通信。pipe()
管道就是基于socketpair()
实现。
最特殊的功能是使用sendmsg()
和recvmsg()
实现描述符传递,实际上传递的是对同一个打开的文件描述的引用,会导致描述符的引用计数加一。
UNIX域的数据报是在内核中传输,也是可靠的。
网络方面暂时告一段落,感觉写的不是很好,太细节了。