本文是学习极客时间课程《网络编程实战》的笔记。
基础篇
C-S编程模型
网络编程中,会区分客户端和服务端,二者的编程模型是不同的,通常来说客户端会主动发起请求,服务端则响应请求。
- 服务器需要一开始就监听在一个众所周知的端口上,等待客户端发送请求,一旦有客户端连接建立,服务端就会消耗一定的计算机资源为它服务。
- 客户端向服务器的监听端口发起连接请求,连接建立之后,通过连接通路和服务器端进行通信。
- 无论是客户端还是服务端,它们运行的单位都是进程(process),而不是机器。
在TCP/IP
协议中,IP
地址用来标识网络中的一台主机,端口号则用来指定运行监听在该端口的进程,一个TCP
连接是由客户端-服务器端的IP
地址和端口号构成的四元组唯一确定的:
(clientaddr:clientport, serveraddr: serverport)
有两种截然不同的传输层协议:
- 面向连接的
TCP
:TCP
是一个面向连接的流式协议,通过诸如连接管理、拥塞控制、数据流和窗口、超时和重传等设计,提供了高质量的端到端通信。 - 无连接的
UDP
:UDP
是无连接的数据报协议,不保证可靠传输。
参考资料:
极客时间—网络编程实战02
socket
客户端和服务器的通信流程如下:
其中连接建立、读写等都要通过socket
来完成,socket
是TCP/IP网络编程的接口。
- 客户端发起连接请求前,服务器需要创建
socket
,使用bind
函数绑定在一个众所周知的IP地址和端口上,紧接着使用listen
监听在该端口上,最后阻塞在accept
处等待客户端发起连接。 - 客户端初始化
socket
后通过connect
向服务端的地址和端口发起连接请求,这个过程包括TCP三次握手
- 连接建立成功后,数据的传输是双向的,客户端可以给服务器发送信息,服务器也可以给客户端发信息。
- 需要断开连接时,通过
close
单向断开,每次单向断开都会执行两次挥手的动作,两端都执行close
后,双向断开就完成了。
socket
的通用地址格式如下:
/* POSIX.1g 规范规定了地址族为2字节的值. */
typedef unsigned short int sa_family_t;
/* 描述通用套接字地址 */
struct sockaddr{
sa_family_t sa_family; /* 地址族. 16-bit*/
char sa_data[14]; /* 具体的地址值 112-bit */
};
sa_family
:AF_LOCAL
:本地套接字AF_INET
:使用IPv4
格式的ip
地址AF_INET6
:使用IPv6
格式的ip
地址
IPv4
的地址结构如下:
struct in_addr
{
in_addr_t s_addr;
};
// sizeof(struct sockaddr) == sizeof(struct sockaddr_in)
struct sockaddr_in
{
sa_family_t sin_family; /* 地址族协议: AF_INET */
in_port_t sin_port; /* 端口, 2字节-> 大端 */
struct in_addr sin_addr; /* IP地址, 4字节 -> 大端 */
/* 填充 8字节 */
unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -
sizeof (in_port_t) - sizeof (struct in_addr)];
};
- 本地
socket
的地址结构如下,因为本地socket
本质上是访问本地的文件系统,所以不需要端口号:
struct sockaddr_un {
unsigned short sun_family; /* 固定为 AF_LOCAL */
char sun_path[108]; /* 路径名 */
};
总结一下,几种套接字地址格式的比较如下:
可以看到几种地址结构体的长度不同,可使用时却都是传入struct sockaddr*
,因为长度不同,实际上使用时还要传入结构体的大小。
参考资料:
极客时间—网络编程实战03
套接字-Socket
三次握手、四次挥手
使用socket
建立连接
服务器和客户端建立连接的流程如下:
- 服务器端通信流程:
- 创建用于监听的套接字,这个套接字是一个文件描述符:
int lfd = socket();
- 将监听文件描述符和本地IP地址和端口绑定:
bind();
- 设置监听连接事件:
listen();
- 阻塞等待接受客户端的连接请求, 建立新的连接, 得到一个新的通信文件描述符(通信的):
int cfd = accept();
- 通信,读写操作默认阻塞:
// 接收数据 read(); / recv(); // 发送数据 write(); / send();
- 断开连接, 关闭套接字:
close();
- 客户端通信流程:
- 创建一个通信的套接字:
int cfd = socket();
- 连接服务器,需要知道服务器的IP地址和监听端口号:
connect();
- 通信:
// 接收数据 read(); / recv(); // 发送数据 write(); / send();
- 断开连接:
close();
上面看到客户端没有用bind
绑定某个端口号,这是因为系统会自动选择空闲端口绑定,当然也可以手动bind
绑定,但是这样比较容易造成端口冲突,最好还是让系统自动绑定。
客户端和服务器建立连接的过程中,相应api对应握手过程如下:
服务器端监听socket
维护半连接队列和全连接队列。服务器端连接处于SYN_RCVD
状态时,连接在半连接队列中;服务器端连接处于ESTABLISHED
状态时,连接被添加到全连接队列,此时accept
从全连接队列取出连接,解除阻塞并返回。
参考资料:
极客时间—网络编程实战04
套接字-Socket
最基本的Socket模型
TCP Socket
发送数据
ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
write
用于普通的写入操作send
带有一个flag
参数,想指定选项,发送带外数据时,需要使用第二个带flag
的函数sendmsg
用于指定多重缓冲区的发送
上述写入函数用于写入socket
时与写文件有所不同:
- 对于普通文件描述符而言,通过
write
函数写入文件时,写入的字节流大小通常和输入参数size
的值相同,否则表示出错。 - 对于套接字文件描述符,在套接字文件描述符上调用
write
写入的字节数有可能比请求的数量少。
接收数据
ssize_t read (int socketfd, void *buffer, size_t size)
read
函数要求从套接字描述字 socketfd
读取最多多少个字节(size),并将结果存储到 buffer
中。返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为 0
,表示 EOF(end-of-file)
,这在网络中表示对端发送了 FIN
包,要处理断连的情况;如果返回值为 -1
,表示出错。
要注意,上面write
写入成功后不代表对方已经接收到了数据,每一个socket
连接都有一个写缓冲区和读缓冲区,write
成功只是说明数据已经写到了socket
的缓冲区中。
服务器端Demo代码如下(为了代码简洁没有处理错误情况,实际上要处理):
// server.c
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
int main() {
// 1. 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 将socket()返回值和本地的IP端口绑定到一起
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
int ret = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
// 3. 设置监听
ret = listen(lfd, 128);
// 4. 阻塞等待并接受客户端连接
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &clilen);
// 5. 和客户端通信
while (1) {
// 接收数据
char buf[1024];
memset(buf, 0, sizeof(buf));
int len = read(cfd, buf, sizeof(buf));
if (len > 0) {
printf("客户端say: %s\n", buf);
write(cfd, buf, len);
} else if (len == 0) {
printf("客户端断开了连接...\n");
break;
} else {
perror("read");
break;
}
}
close(cfd);
close(lfd);
return 0;
}
客户端demo代码如下:
// client.c
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main() {
// 1. 创建通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
if (ret == -1) {
perror("connect");
exit(0);
}
// 3. 和服务器端通信
int number = 0;
while (1) {
// 发送数据
char buf[1024];
sprintf(buf, "你好, 服务器...%d\n", number++);
write(fd, buf, strlen(buf) + 1);
// 接收数据
memset(buf, 0, sizeof(buf));
int len = read(fd, buf, sizeof(buf));
if (len > 0) {
printf("服务器say: %s\n", buf);
} else if (len == 0) {
printf("服务器断开了连接...\n");
break;
} else {
perror("read");
break;
}
sleep(1); // 每隔1s发送一条数据
}
close(fd);
return 0;
}
参考资料:
极客时间—网络编程实战05
套接字-Socket
UDP Socket
UDP
是一个无连接、不可靠的数据报协议。
UPD
通信不需要建立连接,因此不需要connect
UDP
通信过程中,每次都需要指定数据接收端的IP和端口,也就是说UDP报文每次都会获取对端的信息,报文与报文之间没有上下文UDP
没有重传和确认,没有有序控制,也没有拥塞控制。可以简单的理解为,在IP
报文的基础上,UDP
增加的能力有限。
UPD
的通信流程如下:
UDP使用recvfrom
和sendto
来接收和发送报文:
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags,
struct sockaddr *from, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags,
const struct sockaddr *to, socklen_t addrlen);
可以看到recvfrom
和sendto
都需要指定上下文。
UDP
服务器端demo代码如下:
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
int main() {
// 1. 创建通信的套接字
int fd = socket(AF_INET, SOCK_DGRAM, 0);
// 2. 通信的套接字和本地的IP与端口绑定
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(9999); // 大端
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
int ret = bind(fd, (struct sockaddr *)&addr, sizeof(addr));
char buf[1024];
char ipbuf[64];
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);
// 3. 通信
while (1) {
// 接收数据
memset(buf, 0, sizeof(buf));
int rlen =
recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&cliaddr, &len);
printf("客户端的IP地址: %s, 端口: %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ipbuf, sizeof(ipbuf)),
ntohs(cliaddr.sin_port));
printf("客户端say: %s\n", buf);
// 回复数据
// 数据回复给了发送数据的客户端
sendto(fd, buf, rlen, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr));
}
close(fd);
return 0;
}
客户端demo代码如下:
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main() {
// 1. 创建通信的套接字
int fd = socket(AF_INET, SOCK_DGRAM, 0);
// 初始化服务器地址信息
struct sockaddr_in seraddr;
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(9999); // 大端
inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);
char buf[1024];
char ipbuf[64];
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
int num = 0;
// 2. 通信
while (1) {
sprintf(buf, "hello, udp %d....\n", num++);
// 发送数据, 数据发送给了服务器
sendto(fd, buf, strlen(buf) + 1, 0, (struct sockaddr *)&seraddr,
sizeof(seraddr));
// 接收数据
memset(buf, 0, sizeof(buf));
recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);
printf("服务器say: %s\n", buf);
sleep(1);
}
close(fd);
return 0;
}
参考资料:
极客时间—网络编程实战06
基于UDP的套接字通信
本地Socket
本地Socket
是本地进程间通信的一种方法。与TCP/UDP Socket
即使在本机地址通信,也要走协议栈。而本地套接字用一个sock文件作为本机地址,收发数据时只是将应用层数据从一个进程拷贝到另一个进程。
本地socket
也区分UDP
和TCP
协议,这里使用TCP
写demo。
服务端的demo如下:
#include <ctype.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#define MAXLINE 80
char *socket_path = "server.socket";
int main(void) {
struct sockaddr_un serun, cliun;
socklen_t cliun_len;
int listenfd, connfd, size;
char buf[MAXLINE];
int i, n;
listenfd = socket(AF_UNIX, SOCK_STREAM, 0);
memset(&serun, 0, sizeof(serun));
serun.sun_family = AF_UNIX;
strcpy(serun.sun_path, socket_path);
size = offsetof(struct sockaddr_un, sun_path) + strlen(serun.sun_path);
unlink(socket_path);
int ret = bind(listenfd, (struct sockaddr *)&serun, size);
printf("UNIX domain socket bound\n");
ret = listen(listenfd, 20);
printf("Accepting connections ...\n");
while (1) {
cliun_len = sizeof(cliun);
connfd = accept(listenfd, (struct sockaddr *)&cliun, &cliun_len);
while (1) {
n = read(connfd, buf, sizeof(buf));
if (n < 0) {
perror("read error");
break;
} else if (n == 0) {
printf("EOF\n");
break;
}
printf("received: %s", buf);
for (i = 0; i < n; i++) {
buf[i] = toupper(buf[i]);
}
write(connfd, buf, n);
}
close(connfd);
}
close(listenfd);
return 0;
}
注意bind
绑定本地地址前要先unlink
一下,删除掉旧的sock文件
客户端demo如下:
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#define MAXLINE 80
char *client_path = "client.socket";
char *server_path = "server.socket";
int main() {
struct sockaddr_un cliun, serun;
int len;
char buf[100];
int sockfd, n;
sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
// 一般显式调用bind函数,以便服务器区分不同客户端
memset(&cliun, 0, sizeof(cliun));
cliun.sun_family = AF_UNIX;
strcpy(cliun.sun_path, client_path);
len = offsetof(struct sockaddr_un, sun_path) + strlen(cliun.sun_path);
unlink(cliun.sun_path);
int ret = bind(sockfd, (struct sockaddr *)&cliun, len);
memset(&serun, 0, sizeof(serun));
serun.sun_family = AF_UNIX;
strcpy(serun.sun_path, server_path);
len = offsetof(struct sockaddr_un, sun_path) + strlen(serun.sun_path);
ret = connect(sockfd, (struct sockaddr *)&serun, len);
while (fgets(buf, MAXLINE, stdin) != NULL) {
write(sockfd, buf, strlen(buf));
n = read(sockfd, buf, MAXLINE);
if (n < 0) {
printf("the other side has been closed.\n");
} else {
write(STDOUT_FILENO, buf, n);
}
}
close(sockfd);
return 0;
}
参考资料:
极客时间—网络编程实战07
Unix domain socket 简介
网络相关工具
ping
:基于ICMP协议实现,用于测试网络的连通性。ifconfig
:显示当前系统中所有网络设备的信息。netstat/ss
:了解当前的网络连接状况。lsof
:找出在指定IP地址或者端口上打开套接字的进程。tcpdump
:抓包工具。
参考资料:
极客时间—网络编程实战08
提高篇
TIME_WAIT
TCP
连接关闭时,主动关闭的一方会进入TIME_WAIT
状态:
Linux系统在TIME_WAIT
状态下停留的时间为固定的60秒
之所以需要TIME_WAIT
状态是因为:
- 防止本次连接中的数据,被之后有相同四元组标记的连接错误接收到。
- 保证被动关闭连接方能够正确接收到最后的
ACK
,从而正确关闭。
TIME_WAIT
过多的危害:
- 占用系统资源,比如文件描述符、内存资源、
CPU
资源和线程资源等等。 - 占用端口资源,端口号是有限的。
优化TIME_WAIT
的方法:
- 打开
net.ipv4.tcp_tw_reuse
和net.ipv4.tcp_timestamps
选项:开启这两个参数后则可以复用处于TIME_WAIT状态的socket为新连接所用。tcp_tw_reuse
功能只适用于客户端(连接发起方),开启了该功能后,在调用connect()
函数时,内核会随机找一个TIME_WAIT
状态超过1
秒的连接给新的连接复用。 - 设置
net.ipv4.tcp_max_tw_buckets
:这个值默认为18000
,当系统中处于TIME_WAIT
的连接一旦超过这个值时,系统就会将所有的TIME_WAIT
连接状态重置,并且只打印出警告信息。这个方法比较暴力,不推荐使用。 - 为
socket
设置SO_LINGER
选项:linger
的英文意思为停留,设置套接字的该选项,来控制close
或shutdown
关闭连接时的行为。int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); struct linger { int l_onoff; /* 0=off, nonzero=on */ int l_linger; /* linger time, POSIX specifies units as seconds */ }
l_onoff
为0
,那么关闭该选项。l_linger
的值被忽略,这对应了默认行为,close
或shutdown
立即返回。如果当前套接字发送缓冲区中有数据残留,系统会尝试将数据发送出去。l_onoff
为0
且l_linger
为0
,那么调用close
后,会立该发送一个RST
标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT
状态,直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下,排队数据不会被发送,被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在recv()
调用上时,接受到RST
时,会立刻得到一个“connet reset by peer”
的异常。l_onoff
为非 0, 且l_linger
的值也非0
,那么调用close
后,调用close
的线程就将阻塞,直到数据被发送出去,或者设置的l_linger
计时时间到。
参考资料:
极客时间—网络编程实战10
如何优化TIME_WAIT
优雅地关闭连接
关闭连接可以使用close
和shutdown
。
close
关闭连接时:
- 在输入方向,系统内核将套接字设置为不可读,任何读操作都会返回异常。
- 在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个
FIN
报文,接下来如果再对该套接字进行写操作都会返回异常。
向一个已经关闭的连接继续发送报文会接收到一个RST
报文。
shutdown
关闭连接时,可以指定连接关闭的方式:
SHUT_RD(0)
:关闭连接的“读”这个方向,对该套接字进行读操作直接返回EOF
。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行ACK
,然后悄悄地丢弃。也就是说,对端还是会接收到ACK
,在这种情况下根本不知道数据已经被丢弃了。SHUT_WR(1)
:关闭连接的“写”这个方向,这就是常被称为“半关闭”的连接。此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去,并发送一个FIN
报文给对端。应用程序如果对该套接字进行写操作会报错。SHUT_RDWR(2)
:相当于SHUT_RD
和SHUT_WR
操作各一次,关闭套接字的读和写两个方向。
close
和shutdown
的区别:
close
会关闭连接并释放资源,而shutdown
并不会释放掉套接字和所有的资源。close
存在引用计数的概念,并不一定导致该套接字不可用;shutdown
则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。close
因为引用计数的存在导致不一定会发出FIN
结束报文,而shutdown
则总会发出FIN
结束报文。
参考资料:
极客时间—网络编程实战11
无效连接的检测
在没有数据读写的TCP
连接上,无法发现TCP
连接是有效的还是无效的。
TCP
有一个保持活跃的机制叫Keep-Alive
,这个机制的原理是:定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP
保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP
连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,默认值如下:
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
tcp_keepalive_time=7200
:表示保活时间是7200
秒(2
小时),也就2
小时内如果没有任何连接相关的活动,则会启动保活机制tcp_keepalive_time=7200
:表示保活时间是7200
秒(2
小时),也就2
小时内如果没有任何连接相关的活动,则会启动保活机制tcp_keepalive_probes=9
:表示检测9
次无响应,认为对方是不可达的,从而中断本次的连接。
也就是说Linux下开启TCP
保活机制后需要2
小时11
分15
秒才能发现一个无效的连接。
使用
TCP
保活机制需要通过socket
接口设置SO_KEEPALIVE
选项
如果开启了 TCP
保活,需要考虑以下几种情况:
- 对端程序是正常工作的。当
TCP
保活的探测报文发送给对端, 对端会正常响应,这样TCP
保活时间会被重置,等待下一个TCP
保活时间的到来。 - 对端程序崩溃并重启。当
TCP
保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个RST
报文,这样很快就会发现TCP
连接已经被重置。 - 对端程序崩溃,或对端由于其他原因导致报文不可达。当
TCP
保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP
会报告该TCP
连接已经死亡。
TCP
保活机制默认是关闭的,当我们选择打开时,可以分别在连接的两个方向上开启,也可以单独在一个方向上开启:
- 如果开启服务器端到客户端的检测,就可以在客户端非正常断连的情况下清除在服务器端保留的“脏数据”;
- 而开启客户端到服务器端的检测,就可以在服务器无响应的情况下,重新发起连接。
TCP
默认的保活机制后需要2
小时11
分15
秒才可以发现一个无效连接,对很多时延敏感的系统中,这个时间间隔是不可接受的。
可以在应用程序中模拟实现TCP Keep-Alive
机制,来完成在应用层的连接探活。
参考资料:
极客时间—网络编程实战12
客户端出现了故障怎么办
小数据包传输
有几个发送小数据包的场景:
- 接收方告知发送方可以发送一个小窗口,发送方就发送了一个小数据包。
- 交互式场景中,每次发送给服务器的命令字符都很少,且很频繁。
- 接收端对收到的每个
TCP
分组进行确认,也就是发送ACK
报文,但是ACK
报文本身不带数据,每次发送数据有固定的TCP
首部和IP
首部的开效。
优化的方法是:
- 第一个场景叫做糊涂窗口综合征,这个场景需要在接收端进行优化。也就是说,接收端不能在接收缓冲区空出一个很小的部分之后,就急吼吼地向发送端发送窗口更新通知,而是需要在自己的缓冲区大到一个合理的值之后,再向发送端发送窗口更新通知。这个合理的值,由对应的 RFC 规范定义。
- 第二个场景需要在发送端进行优化。这个优化的算法叫做
Nagle
算法,Nagle
算法的本质其实就是限制大批量的小数据包同时发送,为此,它提出,在任何一个时刻,未被确认的小数据包不能超过一个。这里的小数据包,指的是长度小于最大报文段长度MSS
的TCP
分组。这样,发送端就可以把接下来连续的几个小数据包存储起来,等待接收到前一个小数据包的ACK
分组之后,再将数据一次性发送出去。 - 第三个场景,也是需要在接收端进行优化,这个优化的算法叫做延时 ACK。延时 ACK 在收到数据后并不马上回复,而是累计需要发送的 ACK 报文,等到有数据需要发送给对端时,将累计的 ACK捎带一并发送出去。当然,延时 ACK 机制,不能无限地延时下去,否则发送端误认为数据包没有发送成功,引起重传,反而会占用额外的网络带宽。
Nagle
算法和延时ACK
的优化是互相冲突的,不能一起使用
除非我们对此有十足的把握,否则不要轻易改变默认的 TCP Nagle 算法。因为在现代操作系统中,针对 Nagle 算法和延时 ACK 的优化已经非常成熟了,有可能在禁用 Nagle 算法之后,性能问题反而更加严重。
总结:
- 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
- 小数据包加剧了网络带宽的浪费,为了解决这个问题,引入了如
Nagle
算法、延时ACK
等机制。 - 在程序设计层面,不要多次频繁地发送小报文,如果有,可以使用
writev
批量发送。
参考资料:
极客时间—网络编程实战13
已连接UDP
无连接的UDP
中,在服务端不开启的情况下,客户端程序是不会报错的,程序只会阻塞在recvfrom
上,等待返回(或者超时)。
已连接UDP
的作用:
- 如果对
UDP socket
进行了connect
操作,帮助操作系统内核建立了(UDP
套接字——目的地址 + 端口)之间的映射关系,当收到一个ICMP
不可达报文时,操作系统内核可以从映射表中找出是哪个UDP
套接字拥有该目的地址和端口,套接字在操作系统内部是全局唯一的,当我们在该套接字上再次调用recvfrom
或recv
方法时,就可以收到操作系统内核返回的“Connection Refused”
的信息。 - 一般来说,客户端通过
connect
绑定服务端的地址和端口,对UDP
而言,可以有一定程度的性能提升。这是为什么呢?因为如果不使用connect
方式,每次发送报文都会需要这样的过程:连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………而如果使用connect
方式,就会变成下面这样:连接套接字→发送报文→发送报文→……→最后断开套接字。所以,UDP
客户端程序通过connect
可以获得一定的性能提升。
参考资料:
极客时间—网络编程实战14
地址复用
在网络通信中,一个端口只能被一个进程使用,不能多个进程共用同一个端口。我们在进行套接字通信的时候,如果按顺序执行如下操作:先启动服务器程序,再启动客户端程序,然后关闭服务器进程,再退出客户端进程,最后再启动服务器进程,就会出如下的错误提示信息:bind error: Address already in use
,这是因为服务器主动关闭后处于TIME_WAIT
状态,IP+Port
仍被占用。
之前提到过TIME_WAIT
状态是为了防止旧的连接的信息被新的连接接收到,一个TCP
连接是由一个四元组(源地址、源端口、目的地址、目的端口)标志的,这种情况下只要客户端使用的本地端口不同,就不会和旧的四元组冲突,也就不会由TIME_WAIT
的旧连接的信息被新连接错误接收的情况发生。
即使很小概率情况下新旧连接四元组相同,在现代Linux操作系统下,也不会有大的问题,原因是现代 Linux 操作系统对此进行了一些优化:
- 第一种优化是新连接
SYN
告知的初始序列号,一定比TIME_WAIT
老连接的末序列号大,这样通过序列号就可以区别出新老连接。 - 第二种优化是开启了
tcp_timestamps
,使得新连接的时间戳比老连接的时间戳大,这样通过时间戳也可以区别出新老连接。
在这样的优化之下,一个TIME_WAIT
的TCP
连接可以忽略掉旧连接,重新被新的连接所使用。
通过给socket
设置SO_REUSEADDR
,这样的TCP
连接就可以完全复用TIME_WAIT
状态的连接
SO_REUSEADDR
需要在bind
之前设置SO_REUSEADDR
套接字选项还有一个作用,那就是本机服务器如果有多个地址,可以在不同地址上使用相同的端口提供服务。
这里很容易跟之前的tcp_tw_reuse
混淆,其实这两个东西一点关系也没有:
tcp_tw_reuse
是内核选项,主要用在连接的发起方。TIME_WAIT
状态的连接创建时间超过1
秒后,新的连接才可以被复用,注意,这里是连接的发起方;SO_REUSEADDR
是用户态的选项,SO_REUSEADDR
选项用来告诉操作系统内核,如果端口已被占用,但是TCP
连接状态位于TIME_WAIT
,可以重用端口。如果端口忙,而TCP
处于其他状态,重用端口时依旧得到“Address already in use”
的错误信息。注意,这里一般都是连接的服务方。
最佳实践: 服务器端程序,都应该设置
SO_REUSEADDR
套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。
参考资料:
极客时间—网络编程实战15
粘包问题
TCP是一个无边界的流式传输协议,发送方一次发送了一个完整的包,接收方有可能分两次才接收到,编程时需要自己确定数据包的边界。
有几种可用的分包的方式:
1.添加包头:数组包的头部添加长度和其它需要的字段:
2. 使用特殊的字符作为边界,比如HTTP
协议使用回车换行符作为边界:
参考资料:
极客时间—网络编程实战16
TCP数据粘包的处理
TCP异常处理
TCP
协议实现并没有提供给上层应用程序过多的异常处理细节,或者说,TCP
协议反映链路异常的能力偏弱。连接建立之后,能感知 TCP
链路的方式是有限的,一种是以 read
为核心的读操作,另一种是以 write
为核心的写操作。
实际情境中,我们会碰到各种异常的情况,可以归为两大类:
参考资料:
极客时间—网络编程实战17
性能篇
I/O多路复用
可以把标准输入、套接字等都看做 I/O
的一路,多路复用的意思,就是在任何一路I/O
有“事件”发生的情况下,通知应用程序去处理相应的 I/O
事件。
比如使用 I/O
复用以后,如果标准输入有数据,立即从标准输入读入数据,通过套接字发送出去;如果套接字有数据可以读,立即可以读出数据。
通过I/O
多路复用通知内核挂起进程,当一个或多个 I/O
事件发生后,控制权返还给应用程序,由应用程序进行 I/O
事件的处理。
这些I/O
事件的类型非常多,比如:
- 标准输入文件描述符准备好可以读
- 监听套接字准备好,新的连接已经建立成功。
- 已连接套接字准备好可以写。
- 如果一个
I/O
事件等待超过了10
秒,发生了超时事件。
可以实现I/O
多路复用的函数有select
、poll
、epoll
,使用方式可以参见:
IO多路转接(复用)之select
IO多路转接(复用)之poll
IO多路转接(复用)之epoll
它们的对比如下:
参考资料:
极客时间—网络编程实战20、21、23
IO多路转接(复用)之select
IO多路转接(复用)之poll
IO多路转接(复用)之epoll
大并发服务器开发
非阻塞I/O
阻塞I/O
执行操作时,应用程序会被挂起,直到I/O
操作完成才返回。
非阻塞I/O
执行操作时,则不论I/O
事件是否完成立即返回,这样程序不会被挂起,返回后可以继续执行其他操作。
对于read
和write
在阻塞模式和非阻塞模式下的行为特性总结如下:
虽然说非阻塞I/O
可以不让出CPU
使用权继续执行,然后如果真的没有数据可读写的话,一直在应用程序轮询也是浪费CPU
,所以非阻塞I/O
一般配合I/O
多路复用使用,委托内核轮询,等到有读写事件发生再继续执行I/O
操作。
在非阻塞 TCP
套接字上调用 connect
函数,会立即返回一个 EINPROGRESS
错误。TCP
三次握手会正常进行,应用程序可以继续做其他初始化的事情。当该连接建立成功或者失败时,通过 I/O
多路复用 select
、poll
等可以进行连接的状态检测。
对于监听套接字,一般也要设置为非阻塞。如果有新连接到来但是没有马上accept
,有可能客户端发送了RST
f分节将连接重置,此时如果用的是非阻塞的监听套接字且没有新的连接建立的话,程序会一直阻塞在accept
处。
参考资料:
极客时间—网络编程实战22
C10K问题
C10K
问题是这样的:如何在一台物理机上同时服务 10000
个用户?
服务器通常会在本地监听某个端口,这样理论上服务器能够连接
2
48
2^{48}
248个连接(由四元组的客户端IP+Port
得到)。
但实际上服务器支持的连接受系统资源的限制:
- 文件描述符的限制:在Linux下,单个进程打开的文件句柄数是有限制的。
- 内存限制:每个连接都要占用一定的内存资源。
- 网络带宽限制:假设
1
万个连接,每个连接每秒传输大约1KB
的数据,那么带宽需要10000 x 1KB/s x8 = 80Mbps
。
在系统资源层面,C10K
问题可以解决。
但是实际上还要考虑频繁的用户态-内核态数据拷贝的开销,以及如何保证在高并发下还能良好工作。
要想解决 C10K
问题,就需要从两个层面上来统筹考虑:
- 第一个层面,应用程序如何和操作系统配合,感知 I/O 事件发生,并调度处理在上万个套接字上的
I/O
操作? - 第二个层面,应用程序如何分配进程、线程资源来服务上万个连接?
这两个层面的组合就形成了解决 C10K
问题的几种解法方案,也就是之后提到的网络编程模型。
参考资料:
极客时间—网络编程实战24
网络编程模型
任何一个网络程序,所做的事情可以总结成下面几种:
read
:从套接字收取数据;decode
:对收到的数据进行解析;compute
:根据解析之后的内容,进行计算和处理;encode
:将处理之后的结果,按照约定的格式进行编码;send
:最后,通过套接字把结果发送出去。
针对这些操作有如下网络编程模型可以处理:
- 阻塞
I/O
+多进程:主进程负责监听连接事件,有新连接到来时fork
出一个进程来为该连接服务。这种方式实现简单,但为每一个连接创建一个进程的开销过大。
- 阻塞
I/O
+多线程:主线程负责监听连接事件,有新连接到来时创建出一个线程来为该连接服务。这种方式相比阻塞I/O
+多进程的方式来说,创建线程的开效更小,但是频繁创建和销毁线程的开销仍然不小,且随着连接数越来越多,线程上下文切换的开销也很大。
关于多进程和多线程模型的实现可参考服务器并发
- 阻塞
I/O
+ 线程池:预先创建好一部分线程放入线程池,这部分线程不重复创建销毁。主线程负责接收连接,将连接放入队列中,线程池中的线程从队列中取出文件描述符处理。这样相比阻塞I/O
+多线程的方式少了重复创建销毁线程和创建过多线程带来的上下文切换开销。
- 单
Reactor
模型:配合I/O
多路复用,采用事件驱动的方式处理I/O
事件,一个reactor线程同时负责处理连接事件和I/O
事件。但是如果有某个I/O
事件处理时间长的话,其他I/O
事件的处理时间就会受到影响,所以这种模型不适合处理CPU
密集型的业务。
- 单
Reactor+ThreadPool
:reactor
线程只负责处理连接事件,相应的I/O事件交给子线程处理。这种方式相比单Reactor
模型充分利用了多核CPU
的性能,但是因为I/O
事件只由Reactor
线程处理,如果短时间内I/O
事件过多的话,reactor
线程可能分发不过来。
- 主从
Reactor
模型:主线程和子线程都是reactor
线程,不同的是主线程只负责监听连接事件,连接建立后,将连接分配给子线程,由子线程负责监听新连接的I/O
事件。这样相比于单Reactor
的方式,主Reactor
不必再监听所有连接。
参考资料:
极客时间—网络编程实战25、26、27、28
服务器并发
muduo源码学习