好奇的探索者,理性的思考者,踏实的行动者。
Table of Contents:
网络编程是一个高度重视实战的内容模块
很多人在理论部分折了戟,干脆跑向了另一个极端,转而去学习框架,快速上手。事实上,理论是基石,框架则是站在一个更为抽象的角度来看待网络编程问题。框架的产生或是为了实现跨平台支持,例如 JDK,或是为了屏蔽网络编程的细节,让开发更为方便,例如libevent。
没有理论为底,框架也只是空中楼阁。直接学习框架反而会更加摸不着头脑,对网络编程也很难有实打实的收获。
我认为学习高性能网络编程,掌握两个核心要点就可以了:
第一就是理解网络协议,并在这个基础上和操作系统内核配合,感知各种网络 I/O 事件;
第二就是学会使用线程处理并发。
要学好网络编程,需要达到以下三个层次:
第一个层次,充分理解 TCP/IP 网络协议和相应的系统接口。
第二个层次,结合对协议的理解,增强对各种异常情况的优雅处理能力。
第三个层次,写出可以支持大规模高并发的网络处理程序。
本节主要讲了TCP/IP、unix、linux、gnu的一些历史。
我们正处于一个属于我们的时代里,我们也正在第一线享受着这个时代的红利。
具体到互联网技术里,有两件事最为重要,
一个是 TCP/IP 协议,它是万物互联的事实标准;
另一个是 Linux 操作系统,它是推动互联网技术走向繁荣的基石。
OSI 的七层模型定得过于复杂,并且没有参考实现,在一定程度上阻碍了普及。
不过,OSI 教科书般的层次模型,对后世的影响很深远,一般我们说的 4 层、7 层,也是遵从了 OSI 模型的定义,分别指代传输层和应用层。
TCP/IP 的成功也不是偶然的,而是综合了几个因素后的结果:
1. TCP/IP 是免费或者是少量收费的,这样就扩大了使用人群;
2. TCP/IP 搭上了 UNIX 这辆时代快车,很快推出了基于套接字(socket)的实际编程接口;
3. 这是最重要的一点,TCP/IP 来源于实际需求,大家都在翘首盼望出一个统一标准,可是在此之前实际的问题总要解决啊,TCP/IP 解决了实际问题,并且在实际中不断完善。
区别出客户端和服务器,本质上是因为二者编程模型是不同的,所做的事儿也不同。
有一点需要强调的是,无论是客户端,还是服务器端,它们运行的单位都是进程(process),而不是机器。
服务器端需要在一开始就监听在一个众所周知的端口上,等待客户端发送请求,一旦有客户端连接建立,服务器端就会消耗一定的计算机资源为它服务,服务器端是需要同时为成千上万的客户端服务的。如何保证服务器端在数据量巨大的客户端访问时依然能维持效率和稳定,这也是我们讲述高性能网络编程的目的。
客户端相对来说更为简单,它向服务器端的监听端口发起连接请求,连接建立之后,通过连接通路和服务器端进行通信。
一个连接可以通过套接字对,四元组表示:(clientaddr:clientport, serveraddr: serverport)
在网络 IP 划分的时候,我们需要区分两个概念。第一是网络(network)的概念,第二是主机(host)的概念
子网掩码的格式永远都是二进制格式:前面是一连串的 1,后面跟着一连串的 0
不过一大串的数字会有点不好用,比如像 255.192.0.0 这样的子网掩码,人们无法直观地知道有多少个 1,多少个 0,后来人们发明了新的办法,你只需要将一个斜线放在 IP 地址后面,接着用一个十进制的数字用以表示网络的位数,类似这样:192.0.2.12/30, 这样就很容易知道有 30 个 1, 2 个 0,所以主机个数为 4。
国际标准组织在 IPv4 地址空间里面,专门划出了一些网段,这些网段不会用做公网上的 IP,而是仅仅保留做内部使用,我们把这些地址称作保留网段。
下表是三个保留网段,其可以容纳的计算机主机个数分别是 16777216 个、1048576 个和65536 个
10.0.0.0--10.255.255.255
172.16.0.0--172.31.255.255
192.168.0.0--192.168.255.255
为什么存在通用套接字地址, IPv4、IPv6、本地套接字格式?
这样设计的目的是为了给用户提供一个统一的接口, 不用每个地址族成员都增加个函数原型; 只用通过sockaddr.sa_family来确定具体是什么类型的地址,通用套接字就是所有函数的入口参数,用通用套接字就不需要为Tcp udp等各定义一组socket函数了
/* POSIX.1g 规范规定了地址族为 2 字节的值. */
typedef unsigned short int sa_family_t;
/* 描述通用套接字地址 */
struct sockaddr{
/* 地址族. 16-bit*/
sa_family_t sa_family; char sa_data[14]; /* 具体的地址值 112-bit */
};
在这个结构体里,第一个字段是地址族,它表示使用什么样的方式对地址进行解释和保存
glibc 里的定义非常多,常用的有以下几种:
* AF_LOCAL:表示的是本地地址,对应的是 Unix 套接字,这种情况一般用于本地 socket 通信,很多情况下也可以写成 AF_UNIX、AF_FILE;
* AF_INET:因特网使用的 IPv4 地址;
* AF_INET6:因特网使用的 IPv6 地址。
这里的 AF_ 表示的含义是 Address Family,但是很多情况下,我们也会看到以 PF_ 表示的宏,比如 PF_INET、PF_INET6 等,实际上 PF_ 的意思是 Protocol Family,也就是协议族的意思。
我们用 AF_xxx 这样的值来初始化 socket 地址,用 PF_xxx 这样的值来初始化 socket。
我们在 <sys/socket.h>
头文件中可以清晰地看到,这两个值本身就是一一对应的。
#define AF_UNSPEC PF_UNSPEC
#define AF_LOCAL PF_LOCAL
#define AF_UNIX PF_UNIX
#define AF_FILE PF_FILE
#define AF_INET PF_INET
/* IPV4 套接字地址,32bit 值. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};/* 描述 IPV4 的套接字地址格式 */
struct sockaddr_in
{/* 16-bit */
sa_family_t sin_family; /* 端口口 16-bit*/
in_port_t sin_port; struct in_addr sin_addr; /* Internet address. 32-bit */
/* 这里仅仅用作占位符,不做实际用处 */
unsigned char sin_zero[8];
};
可以发现和 sockaddr 一样,都有一个 16-bit 的 sin_family 字段,对于 IPv4 来说这个值就是 AF_INET
sockaddr
是通用的套接字地址结构,在不同的地址族中都可以使用,但需要根据具体情况来解释其中的实际数据。
sockaddr_in
是专门用于IPv4网络的套接字地址结构,在IPv4网络编程中经常使用,包含了端口号和IPv4地址等信息。
struct sockaddr_in6
{/* 16-bit */
sa_family_t sin6_family; /* 传输端口号 # 16-bit */
in_port_t sin6_port; uint32_t sin6_flowinfo; /* IPv6 流控信息 32-bit*/
struct in6_addr sin6_addr; /* IPv6 地址 128-bit */
uint32_t sin6_scope_id; /* IPv6 域 ID 32-bit */
}
struct sockaddr_un {
unsigned short sun_family; /* 固定为 AF_LOCAL */
char sun_path[108]; /* 路径名 */
};
int fd, sockaddr * addr, socklen_t len) bind(
BSD 设计套接字的时候大约是 1982 年,那个时候的 C 语言还没有void *
的支持,为了解决这个问题,BSD 的设计者们创造性地设计了通用地址格式来作为支持 bind 和 accept 等这些函数的参数。
对于使用者来说,每次需要将 IPv4、IPv6 或者本地套接字格式转化为通用套接字格式
对于实现者来说,可根据该地址结构的前两个字节判断出是哪种地址。为了处理长度可变的结构,需要读取函数里的第三个参数,也就是 len 字段,这样就可以对地址进行解析和判断了。
int listen (int socketfd, int backlog)
第二个参数 backlog,官方的解释为未完成连接队列的大小,这个参数的大小决定了可以接收的并发数目。
这个参数越大,并发数目理论上也会越大。但是参数过大也会占用过多的系统资源,一些系统,比如Linux 并不允许对这个参数进行改变。
可以把地址设置成本机的 IP 地址,这相当告诉操作系统内核,仅仅对目标 IP 是本机 IP 地址的 IP 包进行处理。但是这样写的程序在部署时有一个问题,我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候,可以利用通配地址的能力帮助我们解决这个问题。通配地址相当于告诉操作系统内核:“Hi,我可不挑活,只要目标地址是咱们的都可以。”比如一台机器有两块网卡,IP 地址分别是 202.61.22.55 和 192.168.1.11,那么向这两个 IP 请求的请求包都会被我们编写的应用程序处理。
struct sockaddr_in name;
/* IPV4 通配地址,在linux下其定义是0 */ name.sin_addr.s_addr = htonl(INADDR_ANY);
除了地址,还有端口。如果把端口设置成 0,就相当于把端口的选择权交给操作系统内核来处理,操作系统内核会根据一定的算法选择一个空闲的端口,完成套接字的绑定。这在服务器端不常使用。
两次不够,四次又多了。三次通信是理论上的最小值. 所以三次握手不是TCP本身的要求, 而是为了满足"在不可靠信道上可靠地传输信息"这一需求所导致的。
三次握手的目的:使双方都能确定和对方的网络通路是通的。前两次使客户端确定服务器端是通的,后一次让服务器确定客户端是通的。
发送数据时常用的有三个函数,分别是 write、send 和 sendmsg。
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)
第一个函数:常见的文件写函数,如果把 socketfd 换成文件描述符,就是普通的文件写入。
第二个函数:如果想指定选项,发送band of data,或peek数据,就需要使用第二个带 flag 的函数。
第三个函数:如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体 msghdr 的方式发送数据。
对于普通文件描述符而言,一个文件描述符代表了打开的一个文件句柄,通过调用 write 函数,操作系统内核帮我们不断地往文件系统中写入字节流。注意,写入的字节流大小通常和输入参数 size 的值是相同的,否则表示出错。
对于套接字描述符而言,它代表了一个双向连接,在套接字描述符上调用 write 写入的字节数有可能比请求的数量少。
三次握手成功,TCP 连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区和状态记录。
当我们的应用程序调用 write 函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。
发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。之后的缓冲区的数据就不归程序管了,而是操作系统了(组装数据,数据打包)。
对于 send 来说,返回成功仅仅表示数据写到发送缓冲区成功,并不表示对端已经成功收到。
已经发送到网络的数据依然需要暂存在send buffer中,只有收到对方的ack后,kernel才从buffer中清除这一部分数据,为后续发送数据腾出空间
ssize_t read (int socketfd, void *buffer, size_t size)
read 函数要求操作系统内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。
返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。当然,如果是非阻塞 I/O,情况会略有不同
read 阻塞情况下:
如果在网络缓冲中没有发现数据会一直等待
如果这个时候读到的数据量比较少,比参数中指定的长度要小,read 并不会一直等待下去,而是立刻返回,需要循环读取数据,并且需要考虑 EOF 等异常条件。
read 非阻塞情况下:
如果发现没有数据就直接返回,
如果发现有数据那么也是采用有多少读多少的进行处理
write阻塞情况下:
阻塞情况下,write会将数据发送完。(不过可能被中断),在阻塞的情况下,是会一直等待,直到write 完,全部的数据再返回.这点行为上与读操作有所不同。
原因:
读,究其原因主要是读数据的时候我们并不知道对端到底有没有数据,数据是在什么时候结束发送的,如果一直等待就可能会造成死循环,所以并没有去进行这方面的处理;
写,而对于write, 由于需要写的长度是已知的,所以可以一直再写,直到写完.不过问题是write 是可能被打断吗,造成write 一次只write 一部分数据, 所以write 的过程还是需要考虑循环write, 只不过多数情况下次write 调用就可能成功.
write非阻塞情况下:
能写多少写多少,写完了或不能写了就返回
UDP 比较简单,适合的场景还是比较多的,我们常见的 DNS 服务,SNMP(Simple Network Management Protocol)服务都是基于 UDP 协议的,这些场景对时延、丢包都不是特别敏感。另外多人通信的场景,如聊天室、多人游戏等,也都会使用到 UDP 协议。
UDP socket 设置为的非阻塞模式
1.recvform中设置
sizeof(szRecvBuf), MSG_DONTWAIT, (struct sockaddr *)&SockAddr,&ScokAddrLen); Len = recvfrom(SocketFD, szRecvBuf,
2.通过fcntl函数将套接字设置为非阻塞模式。
问:recvfrom 一直处于阻塞状态中,这是非常不合理的,你觉得这种情形应该怎么处理呢?
可以添加超时时间做处理
问:既然 UDP 是请求 - 应答模式的,那么请求中的 UDP 报文最大可以是多大呢?
用UDP协议发送时,用sendto函数最大能发送数据的长度为:65535- IP头(20) - UDP头(8)=65507字节
本地套接字一般也叫做 UNIX 域套接字,最新的规范已经改叫本地套接字。
本地套接字是一种特殊类型的套接字,和 TCP/UDP 套接字不同。TCP/UDP 即使在本地地址通信,也要走系统网络协议栈,而本地套接字,严格意义上说提供了一种单主机跨进程间调用的手段,效率比 TCP/UDP 套接字都要高许多。
套接字文件xxx.sock,是在 服务器端bind的时候自动创建出来的,客户端连接时候的地址通过这个套接字文件的路径来连接。
它的主要作用是起到定位的作用
关于本地文件路径,1.它必须是“绝对路径”,这样的话,编写好的程序可以在任何目录里被启动和管理。2.还有一点需要注意的是套接字文件的权限问题。
ping 是基于一种叫做 ICMP 的协议开发的,ICMP 又是一种基于 IP 协议的控制协议,翻译为网际控制协议
另外一种对路由的检测命令 traceroute 也是通过 ICMP 协议来完成的
ICMP 在 IP 报文后加入了新的内容,这些内容包括:
类型:即 ICMP 的类型, 其中 ping 的请求类型为 0,应答为 8。
代码:进一步划分 ICMP 的类型, 用来查找产生错误的原因。
校验和:用于检查错误的数据。
标识符:通过标识符来确认是谁发送的控制协议,可以是进程 ID。
序列号:唯一确定的一个报文,前面 ping 名字执行后显示的 icmp_seq 就是这个值。
netstat -apn 查看所有的连接详
lsof -i :80 查看80端口被什么占用了
lsof /var/run/docker.sock 查看本地socket是哪个进程占用
一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
为什么需要四次挥手呢?
因为在 TCP 连接的生命周期中,双方都有数据要传输。因此,在断开连接时,需要确保两个方向的数据都能正常传输完毕。每个方向都需要一个 FIN 和一个 ACK,所以总共需要四个步骤来完成这个过程。
TCP 的 "四次挥手" 是为了确保数据的可靠传输和确认,从而避免数据的丢失或混乱。这种设计保证了双方都有机会完成数据传输,然后安全地关闭连接。
也就是断开前我得确认在断开之前我发的数据都发送到对端,并得到对端的确认。
当套接字被关闭时,TCP 为其所在端发送一个 FIN 包。在大多数情况下,这是由应用进程调用 close 而发生的,值得注意的是,一个进程无论是正常退出(exit 或者 main 函数返回),还是非正常退出(比如,收到 SIGKILL 信号关闭,就是我们常常干的 kill -9),所有该进程打开的描述符都会被系统关闭,这也导致 TCP 描述符对应的连接上发出一个 FIN包。
无论是客户端还是服务器,任何一端都可以发起主动关闭。大多数真实情况是客户端执行主动关闭
若作为客户端的进程频繁的去连接不同的服务器,然后再close掉,当次数非常多的时候就会出现好多time_wait的状态,然后会导致端口耗尽的情况。
确保可靠关闭:,这样做是为了确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭
如果图中主机 1 的 ACK 报文没有传输成功,那么主机 2 就会重新发送 FIN 报文。如果主机 1 没有维护 TIME_WAIT 状态,而直接进入 CLOSED 状态,它就失去了当前状态的上下文,只能回复一个 RST 操作,从而导致被动关闭方出现错误。
处理延迟报文: 在TCP的通信过程中,由于网络拥塞、路由问题或其他因素,可能导致一些延迟的报文仍然在网络中传输。TIME_WAIT状态允许这些延迟的报文被检测和处理。如果在TIME_WAIT期间收到一个重复的报文,它会被丢弃,以确保不会导致错误。
第一是内存资源占用,这个目前看来不是太严重,基本可以忽略。
第二是对端口资源的占用,如果 TIME_WAIT 状态过多,会导致无法创建新连接。服务器端问题不大,主要是客户端。
解决:net.ipv4.tcp_tw_reuse 选项
tcp_tw_reuse仅在TCP套接字作为客户端,调用connect时起作用。绝大部分的TCP服务器,应该不会有大量主动连接的动作(或许会连接DB等,但一般也是长连接)。因此这个选项对于TCP服务来说,基本上是无用的,完全是没必要打开,甚至可能还会给一些初级的运维工程师带来迷惑和干扰。
1 //configure kernel parameters at runtime sysctl -w net.ipv4.tcp_tw_reuse=
最后的连接关闭阶段,我们需要重点关注的是“半连接”状态。
因为 TCP 是双向的,这里说的方向,指的是数据流的写入 和 读出的方向。
在绝大数情况下,TCP 连接都是先关闭一个方向(写入),此时另外一个方向还是可以正常进行数据传输(读取)。
这个函数会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并且会关闭TCP 两个方向的数据流。
close 函数具体是如何关闭两个方向的数据流呢?
在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个 FIN 报文,接下来如果再对该套接字进行写操作会返回异常。
如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个RST报文,告诉对端:“Hi, 我已经关闭了,别再给我发数据了。”
我们会发现,close 函数并不能帮助我们关闭连接的一个方向,那么如何在需要的时候关闭一个方向呢?幸运的是,设计 TCP 协议的人帮我们想好了解决方案,这就是 shutdown 函数。
int shutdown(int sockfd, int howto)
howto 是这个函数的设置选项,它的设置有三个主要选项:
SHUT_RD(0):关闭连接的“读”这个方向,对该套接字进行读操作直接返回 EOF。从数
据角度来看,套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对
数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根
本不知道数据已经被丢弃了。
SHUT_WR(1):关闭连接的“写”这个方向,这就是常被称为”半关闭“的连接。此
时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区
已有的数据将被立即发送出去,并发送一个 FIN 报文给对端。应用程序如果对该套接字进行写操作会报错。
SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。
close和SHUT_RDWR的区别:
第一个差别:close 会关闭连接,并释放所有连接对应的资源,而 shutdown 并不会释放掉套接字和所有的资源,最后还是需要调用close()。
第二个差别:close 存在引用计数的概念,并不一定导致该套接字不可用;shutdown 则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。
我之前做过一个基于 NATS 消息系统的项目,多个消息的提供者 (pub)和订阅者(sub)都连到 NATS 消息系统,通过这个系统来完成消息的投递和订阅处理。突然有一天,线上报了一个故障,一个流程不能正常处理。经排查,发现消息正确地投递到了 NATS 服务端,但是消息订阅者没有收到该消息,也没能做出处理,导致流程没能进行下去。通过观察消息订阅者后发现,消息订阅者到 NATS 服务端的连接虽然显示是“正常”的,但实际上,这个连接已经是无效的了。为什么呢?这是因为 NATS 服务器崩溃过,NATS服务器和消息订阅者之间的连接中断 FIN 包,由于异常情况,没能够正常到达消息订阅者,这样造成的结果就是消息订阅者一直维护着一个“过时的”连接,不会收到 NATS 服务器发送来的消息。
这个故障的根本原因在于,作为 NATS 服务器的客户端,消息订阅者没有及时对连接的有效性进行检测,这样就造成了问题。
很多刚接触 TCP 编程的人会惊讶地发现,在没有数据读写的“静默”的连接上,是没有办法发现 TCP 连接是有效还是无效的。
比如客户端突然崩溃,服务器端可能在几天内都维护着一个无用的 TCP 连接。
那么有没有办法开启类似的“轮询”机制,让 TCP 告诉我们,连接是不是“活着”的呢?
这就是 TCP 保持活跃机制所要解决的问题。实际上,TCP 有一个保持活跃的机制叫做 Keep-Alive。
这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
上述的可定义变量,分别被称为保活时间、保活时间间隔和保活探测次数。
在 Linux 系统中,这些变量分别对应 sysctl 变量:
// 7200 秒(2 小时)
net.ipv4.tcp_keepalive_time // 75 秒
net.ipv4.tcp_keepalive_intvl // 9 次探测, 多次探活是为了防止误伤,避免ping包在网络中丢失掉了,而误认为对端死亡 net.ipv4.tcp_keepalve_probes
TCP 保活机制默认是关闭的,当我们选择打开时,可以分别在连接的两个方向上开启,也可以单独在一个方向上开启。
如果开启服务器端到客户端的检测,就可以在客户端非正常断连的情况清理在服务器端保留的“脏数据”;
而开启客户端到服务器端的检测,就可以在服务器无响应的情况下,重新发起连接。
为什么 TCP 不提供一个频率很好的保活机制呢?
我的理解是早期的网络带宽非常有限,如果提供一个频率很高的保活机制,对有限的带宽是一个比较严重的浪费。
我们可以通过在应用程序中模拟 TCP Keep-Alive 机制,来完成在应用层的连接探活。
我们可以设计一个 PING-PONG 的机制,需要保活的一方,比如客户端,在保活时间达到后,发起对连接的 PING 操作,如果服务器端对 PING 操作有回应,则重新设置保活时间,否则对探测次数进行计数,如果最终探测次数达到了保活探测次数预先设置的值之后,则认为连接已经无效。
实现要点:
1.是需要使用定时器,这可以通过使用 I/O 复用自身的机制来实现;
2.是需要设计一个 PING-PONG 的协议。
场景:游戏中,所以服务器为了判定他是否真的存活还是需要一个心跳包 隔了一段时间过后把朋友角色踢下线
编写心跳包活逻辑出现的问题
测试点对点发100万条数据的时候,程序会崩,经过打印发现。是节点因为没有心跳被删除了,缓存区自然也被删掉了。
解决方法为在每次收到网络包的时候重置一下其心跳。
调用send write这些接口并不意味着数据被真正发送到网络上,其实,这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中,或者说是发送缓冲区中,等待协议栈的处理。至于这些数据是什么时候被发送出去的,对应用程序来说,是无法预知的。对这件事情真正负责的,是运行于操作系统内核的 TCP 协议栈实现模块。
发送窗口和接收窗口的本质其实是“TCP 的生产者 - 消费者”模型。
作为 TCP 发送端,也就是生产者,不能忽略 TCP 的接收端,也就是消费者的实际状况,不管不顾地把数据包都传送过来。
如果都传送过来,消费者来不及消费,必然会丢弃;而丢弃反过来使得生产者又重传,发送更多的数据包,最后导致网络崩溃。
我想,理解了“TCP 的生产者 - 消费者”模型,再反过来看发送窗口和接收窗口的设计目的和方式,我们就会恍然大悟了。
TCP 的生产者 - 消费者模型,只是在考虑单个连接的数据传递,但是, TCP 数据包是需要经过网卡、交换机、核心路由器等一系列的网络设备的,网络设备本身的能力也是有限的,当多个连接的数据包同时在网络上传送时,势必会发生带宽争抢、数据丢失等,这样,TCP 就必须考虑多个连接共享在有限的带宽上,兼顾效率和公平性的控制,这就是拥塞控制的本质。
在 TCP 协议中,拥塞控制是通过拥塞窗口来完成的,拥塞窗口的大小会随着网络状况实时调整。
拥塞控制常用的算法有
1. 慢启动算法,它通过一定的规则,慢慢地将网络发送数据的速率增加到一个阈值。超过这个阈值之后,慢启动就结束了,
2. 拥塞避免算法。在这个阶段,TCP 会不断地探测网络状况,并随之不断调整拥塞窗口的大小。
现在你可以发现,在任何一个时刻,TCP 发送缓冲区的数据是否能真正发送出去,至少取决于两个因素,一个是当前的发送窗口大小,另一个是拥塞窗口大小,而 TCP 协议中总是取两者中最小值作为判断依据。
比如当前发送的字节为 100,发送窗口的大小是 200,拥塞窗口的大小是 80,那么取 200 和 80 中的最小值,就是 80,当前发送的字节数显然是大于拥塞窗口的,结论就是不能发送出去。
发送窗口反应了作为单 TCP 连接、点对点之间的流量控制模型,它是需要和接收端一起共同协调来调整大小的;
拥塞窗口则是反应了作为多个 TCP 连接共享带宽的拥塞控制模型,它是发送端独立地根据网络状况来动态调整的。
延时 ACK 在收到数据后并不马上回复,而是累计需要发送的 ACK 报文,等到有数据需要发送给对端时,
将累计的 ACK捎带一并发送出去。当然,延时 ACK 机制,不能无限地延时下去,否则发送端误认为数据包没有发送成功,引起重传,反而会占用额外的网络带宽。
Nagle 算法的本质其实就是限制大批量的小数据包同时发送,发送端就可以把接下来连续的几个小数据包存储起来,等待接收到前一个小数据
包的 ACK 分组之后,再将数据一次性发送出去。
int on = 1;
void *)&on, sizeof(on)); setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (
小数据包加剧了网络带宽的浪费,为了解决这个问题,引入了如 Nagle 算法、延时 ACK等机制。
在程序设计层面,不要多次频繁地发送小报文,如果有,可以使用 writev
批量发送。
在UDP(User Datagram Protocol)中,虽然UDP本身是无连接的,但是在编程时可以使用 connect
函数来建立一个“伪连接”(pseudo-connection),这实际上是在操作系统中保存了一些状态信息,以便在之后的通信中提供方便。这个使用 connect
函数的过程有以下几个方面的优势:
1. 默认目标地址: 使用 connect
函数后,你可以将套接字与特定的目标地址和端口号绑定。这意味着在之后的发送操作中,你可以省略目标地址和端口号,因为操作系统已经知道你要发送给哪个地址。这有助于简化代码并减少冗余。
2. 错误检测: 在使用 connect
函数后,如果你尝试发送到错误的地址,操作系统会通知你,而不是让数据在网络上迷失。这有助于提前检测错误,以便及早处理。
3. 更简单的发送调用: 使用 connect
函数后,在发送数据时只需提供数据本身,而不需要重复指定目标地址和端口号。这在编程中可以降低出错的可能性。
4. UDP的面向连接特性: 虽然UDP本身是无连接的,但通过使用 connect
函数,你可以在应用层模拟一些面向连接的特性。例如,你可以在客户端和服务器之间建立一个“连接”,并在这个“连接”上进行数据交换。这有助于在应用程序中构建一些逻辑层面的连接概念,方便管理数据传输。
处在TIME_WAIT状态下重启服务器会报Address already in use的错误,开启SO_REUSEADDR 这个选项可以避免。
SO_REUSEADDR
的作用有以下几个方面:
SO_REUSEADDR
选项后,新的套接字可以立即绑定到之前关闭的地址上,这有助于服务器快速重启。SO_REUSEADDR
选项可以让新的连接在短时间内复用相同的端口。最佳实践
服务器端程序,都应该设置 SO_REUSEADDR 套接字选项,以便服务端程序可以在极短时间内复用同一个端口启动。
有些人可能觉得这不是安全的。其实,单独重用一个套接字不会有任何问题。我在前面已经讲过,TCP 连接是通过四元组唯一区分的,只要客户端不使用相同的源端口,连接服务器是没有问题的,即使使用了相同的端口,根据序列号或者时间戳,也是可以区分出新旧连接的。
报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法,
1. 发送端把要发送的报文长度预先通过报文告知给接收端;
2. 是通过一些特殊的字符来进行边界的划分。
TCP 是一种可靠的协议,这种可靠体现在端到端的通信上。
TCP 连接建立之后,能感知 TCP异常情况方式是有限的,一种是以 read 为核心的读操作,另一种是以 write 为核心的写操作。
对端无 FIN 包
当系统突然崩溃,如断电时,不会发送 FIN 包
此时,如果是阻塞套接字,会一直阻塞在 read 等调用上,没有办法感知套接字的异常。
若服务器完全崩溃,或者网络中断的情况下,此时,如果是阻塞套接字,会一直阻塞在 read 等调用上,没有办法感知套接字的异常。
* 方法一:设置超时
const char *) &tv, sizeof tv); setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, (
关键之处在读操作返回异常,根据出错信息是EAGAIN或者EWOULDBLOCK,判断出超时
* 方法二:心跳检测
* 方法三:select设置超时,通过返回值判断
1.read或revc时程序设置的缓存区溢出。
2.在使用显式编码报文长度的时候,需要对对方传过来的报文长度保持警惕,对方可能传了个很大的长度值,但并么有传那么多的数据,此时会造成之后的数据读不了,或者缓冲区溢出。
i/o多路复用:使用 select 函数,通知内核挂起进程,当一个或多个 I/O 事件发生后,控制权返还给应用程序,由应用程序进行 I/O 事件的处理。
select检测读
- 套接字接收缓冲区有数据可以读,如果我们使用 read 函数去执行读操作,肯定不会被阻塞,而是会直接读到这部分数据。
- 对方发送了 FIN,使用 read 函数执行读操作,不会被阻塞,直接返回 0。
- 针对一个监听套接字而言的,有已经完成的连接建立,此时使用 accept 函数去执行不会阻塞,直接返回已经完成的连接。
- 套接字有错误待处理,使用 read 函数去执行读操作,不阻塞,且返回 -1。
总结成一句话就是,内核通知我们套接字有数据可以读了,使用 read 函数不会阻塞。
select 检测套接字可写
- 套接字发送缓冲区足够大,如果我们使用非阻塞套接字进行 write 操作,将不会被阻塞,直接返回。
- 连接的写半边已经关闭,如果继续进行写操作将会产生 SIGPIPE 信号。
- 套接字上有错误待处理,使用 write 函数去执行读操作,不阻塞,且返回 -1。
总结成一句话就是,内核通知我们套接字可以往里写了,使用 write 函数就不会阻塞。
注意点:1.监控的最大数量为1024 2.设置timeout参数时间的单位
select 有一个缺点,那就是所支持的文件描述符的个数是有限的。在 Linux 系统中,select 的默认最大值为 1024。
poll 是另一种普遍使用的 I/O 多路复用技术,和 select 相比,它和内核交互的数据结构有所变化,另外,也突破了文件描述符的个数限制。
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
0,若出错则为 -1 返回值:若有就绪描述符则为其数目,若超时则为
非阻塞 I/O 配合 I/O 多路复用,是高性能网络编程中的常见技术。
阻塞时内核所做的事情是将 CPU 时间切换给其他有需要的进程。
当 accept 和 I/O 多路复用 select、poll 等一起配合使用时,如果在监听套接字上触发事件,说明有连接建立完成,此时调用 accept 肯定可以返回已连接套接字。
但这仅限于正常情况下。一定要将监听套接字设置为非阻塞的,在极端情况下回产生阻塞。
在使用非阻塞accept时要注意处理返回值,需要正确地处理各种看似异常的错误,例如忽略 EWOULDBLOCK、EAGAIN 等
在非阻塞 TCP 套接字上调用 connect 函数,会立即返回一个 EINPROGRESS 错误。
TCP三次握手会正常进行,应用程序可以继续做其他初始化的事情。当该连接建立成功或者失败时,通过 I/O 多路复用 select、poll 等可以进行连接的状态检测。
connect的超时时间在75秒到几分钟之间。有时程序希望在等待一定时间内结束,使用非阻塞connect可以防止阻塞75秒,在多线程网络编程中,尤其必要。
例如有一个通过建立线程与其他主机进行socket通信的应用程序,如果建立的线程使用阻塞connect与远程通信,当有几百个线程并发的时候,由于网络延迟而全部阻塞,阻塞的线程不会释放系统的资源,同一时刻阻塞线程超过一定数量时候,系统就不再允许建立新的线程,如果使用非阻塞的connect,连接失败使用select等待很短时间,如果还没有连接成功,线程立刻结束释放资源,防止大量线程阻塞而使程序崩溃。
int net_com::connect_init(u32 u32_ip, u16 u16_port)
{int confd = 0;
struct sockaddr_in servaddr = { 0 };
struct sockaddr_in my_addr = { 0 };
int ret = 0;
0);
confd = Socket(AF_INET, SOCK_STREAM, int flags = 1;
sizeof(int));
Setsockopt(confd, SOL_SOCKET, SO_REUSEADDR, &flags, 1;
flags = sizeof(int));
Setsockopt(confd, SOL_SOCKET, SO_REUSEPORT, &flags,
// 绑定端口
0, sizeof(my_addr));
memset(&my_addr,
my_addr.sin_family = AF_INET;
my_addr.sin_port = htons(SERVERMAINPORT);
struct sockaddr*) & my_addr, sizeof(struct sockaddr));
ret = bind(confd, (if (ret < 0)
"bind hold port");
perror(
//连接对方
0, sizeof(servaddr));
memset(&servaddr,
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(u16_port);struct in_addr addr = {0};
sizeof(u32_ip));
memcpy(&addr, &u32_ip,
inet_pton(AF_INET, inet_ntoa(addr), &servaddr.sin_addr);
/*阻塞情况下linux系统默认超时时间为75s*/
if (set_fd_noblocking(confd) < 0)
{"setnonblock error");
debug(return -1;
}
struct sockaddr*) & servaddr, sizeof(servaddr));
ret = Connect(confd, (
//fd_set fdr, fdw;
fd_set fdw;struct timeval timeout = {0};
int err = 0;
sizeof(err);
socklen_t errlen =
if (ret != 0) {
if (errno == EINPROGRESS) {
"Doing connection.");
debug(/*正在处理连接*/
//FD_ZERO(&fdr);
FD_ZERO(&fdw);//FD_SET(confd, &fdr);
FD_SET(confd, &fdw);10;
timeout.tv_sec = 0;
timeout.tv_usec = int ret;
do
{//et = select(confd + 1, &fdr, &fdw, NULL, &timeout);
1, NULL, &fdw, NULL, &timeout);
ret = select(confd + while (ret < 0 && errno == EINTR);
} "select 监测结束" << std::endl;
std::cout << /*select调用失败*/
if (ret < 0) {
"connect error(%s)", strerror(errno));
debug(
close(confd);return -1;
}
/*连接超时*/
if (ret == 0) {
"Connect timeout.");
debug(
close(confd);return -1;
}/*[1] 当连接成功建立时,描述符变成可写,rc=1*/
if (ret == 1) {
/* ret返回为1(表示套接字可写),可能有两种情况,一种是连接建立成功,一种是套接字产生错误,*/
/* 此时错误信息不会保存至errno变量中,因此,需要调用getsockopt来获取。 */
int err;
sizeof(err);
socklen_t sockLen = int sockoptret = getsockopt(confd, SOL_SOCKET, SO_ERROR, &err, &sockLen);
if (sockoptret == -1)
{return -1;
}if (err == 0)
{return confd; // 成功建立连接
}else
{// 连接失败
return -1;
}"Connect success confd(%d)", confd);
debug(return confd;
}/*[2] 当连接建立遇到错误时,描述符变为即可读,也可写,rc=2 遇到这种情况,可调用getsockopt函数*/
if (ret == 2) {
if (getsockopt(confd, SOL_SOCKET, SO_ERROR, &err, &errlen) == -1) {
"getsockopt(SO_ERROR): %s", strerror(errno));
debug(
close(confd);return -1;
}if (err) {
"connect error:%s\n" RESET, strerror(errno));
debug(RED
close(confd);return -1;
}
}
}"connect failed, error(%s)." RESET, strerror(errno));
debug(RED return -1;
}
"connect_init num confd(%d)", confd);
debug(
return confd;
}
不同于 poll 的是,epoll 不仅提供了默认的 level-triggered(条件触发)机制,还提供了性能更为强劲的 edge-triggered(边缘触发)机制。
条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
一般我们认为,边缘触发的效率比条件触发的效率要高。
epoll 通过改进的接口设计,避免了用户态 - 内核态频繁的数据拷贝,大大提高了系统性能。
支持单机 1 万并发的问题被称为 C10K (client)问题,为了解决 C10K 问题,需要重点考虑两个方面的问题:
如何和操作系统配合,感知 I/O 事件的发生?
如何分配和使用进程、线程资源来服务上万个连接?
在 Linux 下,单个进程打开的文件句柄数是有限制的,没有经过修改的值一般都是 1024。
$ulimit -n
1024
我们可以对这个值进行修改,比如用 root 权限修改 /etc/sysctl.conf 文件,使得系统可用支持 10000 个描述符上限。
10000
fs.file-max = 10000
net.ipv4.ip_conntrack_max = 10000 net.ipv4.netfilter.ip_conntrack_max =
Linux 4.4.0 下发送缓冲区和接收缓冲区的值。
$cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
cat /proc/sys/net/ipv4/tcp_rmem
$ 4096 87380 6291456
估算内存的时候还需要加上自己程序本身的缓存区。
通过前面我们对操作系统层面的资源分析,可以得出一个结论,在系统资源层面,C10K 问题是可以解决的。
do{
accept connectionsfor conneced connection fd
fork
process_run(fd)while(true) }
do{
accept connectionsfor conneced connection fd
pthread_create
thread_run(fd)while(true) }
线程池
create thread pooldo{
accept connections
get connection fd
push_queue(fd)while(true) }
轮询
问题是如果这个 fdset 有一万个之多,每次循环判断都会消耗大量的CPU 时间,而且极有可能在一个循环之内,没有任何一个套接字准备好可读,或者可写。
for fd in fdset{
if(is_readable(fd) == true){
handle_read(fd)else if(is_writeable(fd)==true){
}
handle_write(fd)
} }
poll
这样的方法需要每次 dispatch 之后,对所有注册的套接字进行逐个排查,效率并不是最高的。如果 dispatch 调用返回之后只提供有 I/O 事件或者 I/O 变化的套接字
do {
poller.dispatch()for fd in registered_fdset{
if(is_readable(fd) == true){
handle_read(fd)else if(is_writeable(fd)==true){
}
handle_write(fd)
}while(ture) }
epoll
do {
poller.dispatch()for fd_event in active_event_set{
if(is_readable_event(fd_event) == true){
handle_read(fd_event)else if(is_writeable_event(fd_event)==true){
}
handle_write(fd_event)
}while(ture) }
这种方式可能很难足高性能程序的需求,但好处在于实现简单。
在没有线程池的情况下,如果并发连接过多,就会引起线程的频繁创建和销毁。虽然线程切换的上下文开销不大,但是线程创建和销毁的开
销却是不小的。
我们可以使用预创建线程池的方式来进行优化。在服务器端启动时,可以先按照固定大小预创建出多个线程,当有新连接建立时,往连接字队列里放置这个新连接描述字,线程池里的线程负责从连接字队列里取出连接描述字进行处理。
这个程序的关键是连接字队列的设计,因为这里既有往这个队列里放置描述符的操作,也有从这个队列里取出描述符的操作。
对此,需要引入两个重要的概念,一个是锁 mutex,一个是条件变量 condition。锁很好理解,加锁的意思就是其他线程不能进入;条件变量则是在多个线程需要交互的情况下,用来线程间同步的原语
事件驱动的流程:
一个无限循环的事件分发线程在后台运行,一旦用户在界面上产生了某种操作,例如点击了某个 Button,或者点击了某个文本框,一个事件会被产生并放置到事件队列中,这个事件会有一个类似前面的 onButtonClick 回调函数。事件分发线程的任务,就是为每个发生的事件找到对应的事件回调函数并执行它。这样,一个基于事件驱动的GUI 程序就可以完美地工作了。
事件驱动模型,也被叫做反应堆模型(reactor),或者是 Event loop 模型。这个模型的核心有两点。
第一,它存在一个无限循环的事件分发线程,或者叫做 reactor 线程、Event loop 线程。这个事件分发线程的背后,就是 poll、epoll 等 I/O 分发技术的使用。
第二,所有的 I/O 操作都可以抽象成事件,每个事件必须有回调函数来处理。acceptor 上有连接建立成功、发送缓冲区空出可以写、通信管道 pipe 上有数据可以读,这些都是一个个事件,通过事件分发,这些事件都可以一一被检测,并调用对应的回调函数加以处理。
任何一个网络程序,所做的事情可以总结成下面几种:
* read:从套接字收取数据;
* decode:对收到的数据进行解析;
* compute:根据解析之后的内容,进行计算和处理;
* encode:将处理之后的结果,按照约定的格式进行编码;
* send:最后,通过套接字把结果发送出去。
代码是用纯c语言写的。
性能网络框架需要满足的需求有以下三点。
第一,采用 reactor 模型,可以灵活使用 poll/epoll 作为事件分发实现。
第二,必须支持多线程,从而可以支持单线程单 reactor 模式,也可以支持多线程主 - 从reactor 模式。可以将套接字上的 I/O 事件分离到多个线程上。
第三,封装读写操作到 Buffer 对象中。
event_loop channel acceptor event_dispatcher channel_map
thread_pool event_loop_thread
buffer tcp_connection
在这一讲里,我们重点讲解了框架中涉及多线程的两个重要问题,
第一是主线程如何等待多个子线程完成初始化,
第二是如何通知处于事件分发中的子线程有新的事件加入、删除、修改。
第一个问题通过使用锁和信号量加以解决;第二个问题通过使用 socketpair,并将sockerpair 作为 channel 注册到 event loop 中来解决。
【线程】
acceptor线程
reactor线程
thread_pool 记录了主线程和所有子线程
thread_pool 维护了一个 sub-reactor 的线程列表,它可以提供给主 reactor 线程使用,
每次当有新的连接建立时,可以从 thread_pool 里获取一个线程,以便用它来完成对新连
接套接字的 read/write 事件注册,将 I/O 线程和主 reactor 线程分离。
event_loop_thread 具体的子线程
pthread_create的时候,运行event_loop_run线程
epoll_dispatch
channel_event_activate
在eventLoop->channelMap中通过fd来查找channel
channel中有对应的eventReadCallback、eventWriteCallback
tcp_connection
每个 tcp_connection 对象一定包含了一个 channel 对象,而
channel 对象未必是一个 tcp_connection 对象。
加channel
channel_new
event_loop_add_channel_event
先加入pending链表
event_loop_handle_pending_channel用来修改当前监听的事件列表,把pending链表的add、del到channelMap中
把channel挂到epoll上
主线程如何判断子线程已经完成初始化并启动,继续执行下去呢?
通知这个线程有新的事件加入。而这个线程很可能是处于事件分发的阻塞调用之中,
如何协调主线程数据写入给子线程?
【框架接口】
tcp_server_init
设置各种回调函数
回调的意思体现在“框架会在适合的时机调用预定好的接口实现”
tcp_server_start
开启主线程和子线程,添加监听连接的channel