合抱之木始于毫末,万丈高楼起于垒土。多发现,多总结,多积累,知识具有连贯性,只有循序渐进地学习,才能不断地深入。
- 本文的笔记来自于b站视频的爱编程的大丙,博客链接:https://subingwen.cn/,有做了相应的补充!
一、套接字Socket
1、概念
(1)局域网和广域网
- 局域网:一定区域内的各种计算机、外部设备和数据库连接起来形成计算机通信的私有网络;
- 广域网:又称外网、公网,是连接不同地区局域网或城域网计算机通信的远程公共网络;
(2)IP
本质上是一个整数,用于表示计算机在网络中的地址。IP协议有两种:IPv4和IPv6 。
- IPv4(Internet Protocol version4)
- 使用一个32位的整数描述一个IP地址,4个字节,int型;
- 也可以使用一个点分十进制字符串描述这个IP地址:192.128.2.12
- 分成4份,每一份1个字节,8bit(char),最大值为255;0.0.0.0 是最小的 IP 地址,255.255.255.255 是最大的 IP 地址;
- 按照IPv4协议计算,可以使用的IP地址共有2的32次方;
目前IPv4已经用完,但可以通过局域网内的多个主机共享一个IPv4的地址,然后通过交换机或路由器连接外网 !
- IPv6(Internet Protocol version6)
- 使用一个128位的整数描述一个IP地址,16个字节;
- 可以通过一个字符串描述这个 IP 地址:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b;
- 分成8份,每份2个字节,每一份以16进制的方式表示;
- 按照 IPv6 协议计算,可以使用的 IP 地址共有 2的128 个;
Linux查看IP地址的命令:
ifconfig
ping www.baidu.com # 测试是否可用连接外网
(3)端口(port)
端口的作用是定位到主机上的某一个进程,通过这个端口进程可以接受到对应的网络数据。
比如:在电脑上运行了微信和 QQ, 小明通过客户端给我的的微信发消息,电脑上的微信就收到了消息,为什么?
- 运行在电脑上的微信和 QQ 都绑定了不同的端口;
- 通过 IP 地址可以定位到某一台主机,通过端口就可以定位到主机上的某一个进程;
- 通过指定的 IP 和端口,发送数据的时候对端就能接受到数据了;
端口也是一个无符号的十六位的整数(unsigned short),有效端口的取值范围是:0~65535(2^16-1)。
计算机中所有的进程都需要关联一个端口?
- 不需要,如果这个进程不需要网络通信,那么这个进程就不需要绑定端口的;
- 一个端口只能给一个进程使用,多个进程不能同时使用同一个端口!
(4)OSI/ISO 网络分层模型
OSI(Open System Interconnect),即开放式系统互联,一般叫做OSI模型,是ISO(国际标准化组织)在1985年研究的网络互联模型。
- 物理层:负责最后将信息编码成电流脉冲或其它信号用于网上传输;
- 数据链路层:
- 数据链路层通过物理网络链路供数据传输;
- 规定了0和1的分包形式,确定了网络数据包的形式;
- 网络层:
- 负责在源和终点之间建立连接;
- 此处需要确定计算机的位置,通过IPv4、IPv6的格式IP的地址来找到对应的主机;
- 传输层
- 向高层提高可靠的端到端的网络数据流服务;
- 每个应用程序都会在网卡注册一个窗口号,该层就是端口与端口的通信;
- 会话层
- 建立、管理和终止表示层与实体之间的通信会话;
- 建立了一个连接(自动的手机信息、自动的网络寻址);
- 表示层:对应用层数据编码和转化,确保以一个系统应用层发送的信息可以被另一个系统应用层识别;
2、网络协议
网络协议指的是计算机网络中互相通信的对等实体之间交换信息时必须遵守的规则的集合,一般系统网络协议包括五个部分:通信环境,传输服务,词汇表,信息的编码格式,时序、规则和过程。先来通过下面几幅图了解一下常用的网络协议的格式:
- TCP 协议 -> 传输层协议
- UDP 协议 -> 传输层协议
- IP 协议 -> 网络层协议
- 以太网帧协议 -> 网络接口层协议
- 数据的封装
在网络通信的时候,程序猿需要负责的应用层数据的处理 (最上层):
- 应用层的数据可以使用某些协议进行封装,也可以不封装;
- 程序猿需要调用发送数据的接口函数,将数据发送出去;
- 程序猿调用的 API 做底层数据处理;
- 传输层使用传输层协议打包数据;
- 网络层使用网络层协议打包数据;
- 网络接口层使用网络接口层协议打包数据;
- 数据被发送到 internet;
- 接收端接收到发送端的数据;
程序猿调用接收数据的函数接收数据;
调用的 API 做相关的底层处理;
网络接口层拆包 ==> 网络层的包;
网络层拆包 ==> 网络层的包;
传输层拆包 ==> 传输层数据;
如果应用层也使用了协议对数据进行了封装,数据的包的解析需要程序猿做;
3、socket 编程
Socket套接字的目的是将TCP/IP协议相关软件移植到UINIX系统中,设计者开发了一个接口,以便应用程序可以简单地调用该接口通信,这个接口不断完善,最终形成了Socket套接字,Linux系统采用了Socket套接字,因此,Socket接口被广泛使用。与socket套接字相关的函数被包含在头文件sys/socket.h中。
通过上面可以看出,套接字对应用程序来说就是一套网络通信的接口,使用这个套接字可以完成网络的通信。网络通信的主体部分分为两部分:客户端和服务端。在客户端和服务器通信的时候需要频繁提到三个概念:IP、端口、通信数据。
(1)字节序
在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编 / 译码从而导致通信失败。
- 字节序,顾名思义就是字节的顺序,就是大于一个字节类型的数据结构在内存中的存储顺序,也就是说对于单字节没有字节序问题的,字符串是单字节的集合,因此字符串也没有字节序的问题。int、long等类型有字节序问题!
目前在各种体系的计算机中通常采用的字节存储机制主要有两种:Big-Endian 和 Little-Endian,下面先从字节序说起。
- Little-Endian->主机字节序(小端)
- 数据的低位字节存储在内存的低地址位,数据的高位字节存储在内存的高位地址位;
- 我们使用的PC机,数据默认的存储方式是小端;
- Big-Endian->网络字节序(大端)
- 数据的低位字节存储在内存的高地址位,数据的高位字节存储在内存的低位地址位;
- 套接字通信过程中操作的数据都是大端存储的,包括:接受、发送的数据,IP地址,端口;
// 有一个16进制的数, 有32位 (int): 0xab5c01ff
// 字节序, 最小的单位: char 字节, int 有4个字节, 需要将其拆分为4份
// 一个字节 unsigned char, 最大值是 255(十进制) ==> ff(16进制)
内存低地址位 内存的高地址位
--------------------------------------------------------------------------->
小端: 0xff 0x01 0x5c 0xab
大端: 0xab 0x5c 0x01 0xff
- BSD Socket 提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:htons、htonl;从网络字节序到主机字节序的转换函数:ntohs、ntohl。
#include <arpa/inet.h>
// u:unsigned
// 16: 16位, 32:32位
// h: host, 主机字节序
// n: net, 网络字节序
// s: short
// l: int
// 这套api主要用于 网络通信过程中 IP 和 端口 的 转换
// 将一个短整形从主机字节序 -> 网络字节序
uint16_t htons(uint16_t hostshort);
// 将一个整形从主机字节序 -> 网络字节序
uint32_t htonl(uint32_t hostlong);
// 将一个短整形从网络字节序 -> 主机字节序
uint16_t ntohs(uint16_t netshort)
// 将一个整形从网络字节序 -> 主机字节序
uint32_t ntohl(uint32_t netlong);
(2)IP 地址转换
IP 地址本质是一个整形数,但是在使用的过程中都是通过一个字符串来描述,下面的函数描述了如何将一个字符串类型的 IP 地址进行大小端转换,需要包含的头文件是:
- 主机->网络(字符串->整形)、小端->大端
#include <arpa/inet.h>,
// 主机字节序的IP地址转换为网络字节序
// 主机字节序的IP地址是字符串, 网络字节序IP地址是整形
int inet_pton(int af, const char *src, void *dst);
参数:
- af:地址族 (IP 地址的家族包括 ipv4 和 ipv6) 协议;AF_INET表示 ipv4 格式的 ip 地址;AF_INET6表示ipv6 格式的 ip 地址
- src: 传入参数,对应要转换的点分十进制的 ,ip 地址:为192.168.1.100
- dst:传出参数,函数调用完成,转换得到的大端整形 IP 被写入到这块内存中
- 返回值:成功返回 1,失败返回 0 或者 - 1
- 网络->主机(整形->字符串)、大端->小端
#include <arpa/inet.h>
// 将大端的整形数, 转换为小端的点分十进制的IP地址
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数:
af:地址族协议,AF_INET代表ipv4 格式的 ip 地址,AF_INET6代表ipv6 格式的 ip 地址
src:传入参数,这个指针指向的内存中存储了大端的整形 IP 地址
dst: 传出参数,存储转换得到的小端的点分十进制的 IP 地址
size: 修饰 dst 参数的,标记 dst 指向的内存中最多可以存储多少个字节
返回值:成功意味着指针指向第三个参数对应的内存地址,通过返回值也可以直接取出转换得到的 IP 字符串;失败返回NULL。
还有一组函数也能进程 IP 地址大小端的转换,但是只能处理 ipv4 的 ip 地址:
// 点分十进制IP -> 大端整形
in_addr_t inet_addr (const char *cp);
// 大端整形 -> 点分十进制IP
char* inet_ntoa(struct in_addr in);
(3)sockaddr 数据结构
// 在写数据的时候不好用
struct sockaddr {
sa_family_t sa_family; // 地址族协议, ipv4
char sa_data[14]; // 端口(2字节) + IP地址(4字节) + 填充(8字节)
}
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
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)];
};
(4) 套接字函数
使用套接字通信函数中需要包含头文件<arpa/inet.h>,这个文件也包含了sys/socket.h了。
// 创建一个套接字
int socket(int domain, int type, int protocol);
参数:
- domain: 使用的地址族协议
AF_INET: 使用 IPv4 格式的 ip 地址
AF_INET6: 使用 IPv4 格式的 ip 地址
- type:
SOCK_STREAM: 使用流式的传输协议
SOCK_DGRAM: 使用报式 (报文) 的传输协议
- protocol: 一般写 0 即可,使用默认的协议
SOCK_STREAM:流式传输默认使用的是 tcp
SOCK_DGRAM:报式传输默认使用的 udp
- 返回值
成功:可用于套接字通信的文件描述符
失败:-1
// 将文件描述符和本地的IP与端口进行绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd:监听的文件描述符,通过socket( )调用得到的返回值;
- addr:传入参数,要绑定的IP和端口号要初始化到这个结构体中,IP和端口要转换为网络字节序;
- addrlen:参数addr指向内存的大小,sizeof (struct sockaddr)
- 返回值:成功返回 0,失败返回 - 1
// 给监听的套接字设置监听
int listen(int sockfd, int backlog);
参数:
- sockfd:文件描述符,可以通过调用socket( )得到的,在监听之前必须要绑定bind
- backlog:同时能处理的最大并发连接请求(内核两个队列,已经建立连接,还没建立连接的),最大为128;
- 返回值:函数调用成功立刻返回 0,调用失败返回 -1,不会阻塞!
// 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
- sockfd:监听的文件描述符
- addr:传入参数,里面存储了建立连接的客户端的地址信息,需要用户存入;
- addrlen:传入传出参数,用于存储 addr 指向的内存大小
- 返回值:调用成功,得到一个文件描述符(非负整数),用于与建立连接的这个客户端进行通信,失败返回-1;
这个函数是阻塞函数,当没有新的客户端连接请求的时候,该函数将会阻塞;当检测到有新的客户端连接请求时,阻塞解除,新的连接就建立了,得到的返回值也是一个文件描述符,基于这个文件描述符就可以和客户端通信了。
//客户端用于连接的请求
int connect(int sockfd,const struct sockaddr* addr,socklen_t addren);
参数:
sockfd:通信的文件描述符,通过调用 socket () 函数就得到了
addr:存储了要连接的服务器端的地址信息: iP 和 端口,这个 IP 和端口也需要转换为大端然后再赋值
addrlen: addr 指针指向的内存的大小 sizeof (struct sockaddr)
返回值:
- 连接成功返回 0;
- 连接失败返回 - 1 ;
// 接收数据
ssize_t read(int sockfd, void *buf, size_t size);
ssize_t recv(int sockfd, void *buf, size_t size, int flags);
参数:
- sockfd:用于通信的文件描述符,accept( )函数的返回值;
- buf:指向一块有效的内存,用于存储接收到的数据;
- size:指向内存的容量;
- flags:特殊的属性,一般不使用,指定为 0
返回值:
- 大于0:实际接收到的字节数;
- 等于0:对方断开了连接;
- -1:接受数据失败;
如果连接没有断开,接收端接收不到数据,接收数据的函数会阻塞等待数据到达,数据到达后函数解除阻塞,开始接收数据,当发送端断开连接,接收端无法接收到任何数据,但是这时候就不会阻塞了,函数返回0;
// 发送数据的函数
ssize_t write(int fd, const void *buf, size_t len);
ssize_t send(int fd, const void *buf, size_t len, int flags);。
参数:
- sockfd:用于通信的文件描述符,accept( )函数的返回值;
- buf:传入参数,要发送的字符串;
- len:要发送字符串的长度;
- flags:特殊的属性,一般不使用,指定为 0
返回值:
- 大于0:实际发送的字节数,和参数len是相等的;
- -1:发送数据失败了;
int close(fd);//关闭套接字
参数:
- fd:创建的socket文件描述符
4、TCP 通信流程
TCP是一个面向连接的,可靠的,流式传输协议,这个协议是一个传输层协议。
- 面向连接:是一个双向连接,通过三次握手完成,断开连接需要通过四次挥手完成;
- 可靠:tcp通信过程中,对发送的每一个数据包都会进行校验,如果发现数据丢失,会自动重传;
- 流式传输:发送端和接收端处理数据的速度,数据的量都可以不一样;
4.1 服务器端通信流程
(1)创建用于监听的套接字,这个套接字是一个文件描述符
int lfd = socket();
- 需要注意: 文件IO的文件描述符,操作的是磁盘文件;这里的文件描述符,操作的是网络接口;
(2)将得到的监听的文件描述符(进程)和本地IP端口进行绑定
bind();
(3)设置监听(成功之后开始监听,监听的是客户端的连接)
listen();
(4)等待并接受客户端的连接请求,建立新的连接,会得到一个新的文件描述符fd(通信的),没有新连接就会阻塞
accept();
- fd允许被重用,Linux底层采用采用(源地址,源端口,目标地址,目标端口,协议)这个五元组作为唯一标识。
(5)通信,读写操作默认是阻塞的
// 接收数据
read(); / recv();
// 发送数据
write(); / send();
(6)断开连接,关闭套接字
close();
- 注意: 在tcp通信中,谁先调用close函数,谁就进行前两次挥手,不分客户端和服务端的,只与谁调用close为主
在tcp的服务端,有两类文件描述符:
- 监听的文件描述符:只需要一个,不负责与客户端进行通信,负责检测客户端的连接请求,检测到之后调用accept就可以建立连接;
- 通信的文件描述符:负责和建立建立连接的客户端通信,如果有 N 个客户端和服务器建立了新的连接,通信的文件描述符就有 N 个,每个客户端和服务器都对应一个通信的文件描述符;
注意:服务器每个客户连接成功后会有对应的缓冲区,在套接字通信时,每个文件描述符在内核的内存中对应两块内存(两块缓冲区),也就是假如有N个客户连接成功,服务端有N+1的缓冲区,一个用于监听的,N个用于通信的 。
文件描述符对应的内存结构:
一个文件文件描述符对应两块内存,一块内存是读缓冲区,一块内存是写缓冲区
- 读数据: 通过文件描述符将内存中的数据读出, 这块内存称之为读缓冲区
- 写数据:通过文件描述符将数据写入到某块内存中, 这块内存称之为写缓冲区
- 监听的文件描述符
- 客户端的连接请求会发送到服务器端监听的文件描述符的读缓冲区中
- 读缓冲区中有数据,说明有新的客户端连接
- 调用 accept () 函数,这个函数会检测监听文件描述符的读缓冲区
- 检测不到数据,该函数阻塞
- 如果检测到数据,解除阻塞,新的连接建立
- 通信的文件描述符
- 客户端和服务器端都有通信的文件描述符
- 发送数据:调用函数 write () /send (),数据进入到内核中
- 数据并没有被发送出去,而是将数据写入到了通信的文件描述符对应的写缓冲区中
- 内核检测到通信的文件描述符写缓冲区中有数据,内核会将数据发送到网络中
- 接收数据:调用的函数 read () /recv (), 从内核读数据
- 数据如何进入到内核程序猿不需要处理,数据进入到通信的文件描述符的读缓冲区中
- 数据进入到内核,必须使用通信的文件描述符,将数据从读缓冲区中读出即可
4.2 客户端的通信流程
在单线程的情况下客户端通信的文件描述符有一个,没有监听的文件描述符。
(1)创建一个通信的套接字
int cfd = socket();
(2)连接服务器,需要知道服务器的IP和端口
connect();
(3)通信
// 接收数据
read(); / recv();
// 发送数据
write(); / send();
(4)断开连接,关键文件描述符(套接字)
close();
二、 TCP 状态转换
在TCP进行三次握手,或者四次挥手的过程中,通信的服务器和客户端内部会发送状态上的变化,发生的状态变化在程序中是看不到的,这个状态的变化也不需要程序猿去维护,但是在某些情况下进行程序的调试会去查看相关的状态信息,先来看三次握手过程中的状态转换。
1、三次握手
- 在第一次握手之前,服务端必须的启动,并且已经开始了监听,服务器先调用listen( )函数开始监听,服务器启动监听前后的状态变化:没有状态->LISTEN;
当服务器监听启动之后,由客户端发起的三次握手过程中状态转移如下:
第一次握手:
- 客户端:调用了connect( )函数,状态变化:没有状态->SYN_SENT;
- 服务器:收到连接请求SYN,状态变化:LISTEN->SYN_REVD;
第二次握手:
- 服务器:给客户端回复ACK,并且请求和客户端建立连接,状态无变化,依然是SYN_REVD;
- 客户端:接收数据,收到了ACK,状态变化:SYN_SENT->ESTABLISHED;
第三次握手:
- 客户端:给服务器回复ACK,同意建立连接,状态没有变化,还是ESTABLISHED;
- 服务器:收到ACK,状态变化:SYN_RCVD->ESTABLISHED;
三次握手完成之后,客户端和服务器都变成了同一种状态,这种状态叫做:ESTABLISHED,表示双向连接已经建立,可以进行通信。在通信过程中,正常的通信状态就是ESTABLISHED。
2、四次挥手
关于四次挥手对于客户端和服务器哪端先断开连接没有要求,根据实际情况处理即可。换言之,哪一端先断开就进入FIN_WAIT1、FIN_WAIT2、TIME_WAIT状态!下面根据上图中的实例描述一下四次挥手过程中 TCP 的状态转换(上图中主动断开连接的一方是客户端):
第一次挥手:
- 客户端:调用close( )函数,将tcp协议中的FIN设置为1,请求和服务器断开连接。状态变化:ESTABLISHED->FIN_WAIT_1;
- 服务器:收到断开连接请求,状态变化:ESTABLISHED->CLOSE_WAIT;
第二次挥手:
- 服务器:回复ACK,同意断开连接的请求,状态没有变化,还是CLOSE_WAIT;
- 客户端:收到ACK,状态变化:FIN_WAIT1->FIN_WAIT2;
第三次挥手:
- 服务器:调用close( )函数,发送FIN给客户端,请求断开连接,状态变化:CLOSE_WAIT->LAST_ACK;
- 客户端:收到FIN,状态变化:FIN_WAIT_2->TIME_WAIT;
第四次挥手:
- 客户端:回复ACK给服务器,状态变化:TIME_WAIT->没有变化;
- 服务器:收到ACK,双向连接断开,状态变化:LAST_ACK->无状态了;
在TCP通信的时候,当主动断开连接的一方接收到被动断开连接的一方发送的FIN和最终的ACK后(第三次挥手完成),连接的主动关闭一方必须处于TIME_WAIT状态并持续2MSL(Maximum Segment Lifetime)时间,这样就能够让TCP连接的主动关闭一方在它发送的ACK丢失的情况下重传最终的ACK。
一倍报文寿命(MSL)大概时长为30s,因此两倍报文寿命一般在1分钟作用。
主动关闭方重传发送的最终ACK,是因为被动关闭方重传了它的FIN。事实上,被动关闭方总是重传FIN直到它收到一个最终的ACK。
3、半关闭
TCP连接只有一方发送了FIN,另外一方没有发出FIN包,仍然可以在一个方向上正常发送数据,这种状态可以称之为半关闭或半连接。当四次挥手完成两次,就相当于实现了半关闭,在程序中只需要在某一端直接调用close( )函数即可。套接字通信默认的双工的,也就是双向的,如果进行了半关闭就变成了单工,数据只能单向流动了。比如下面的这个例子:
服务器端:
- 调用了 close () 函数,因此不能发数据,只能接收数据;
- 关闭了服务器端的写操作,现在只能进行读操作 –> 变成了读端;
客户端:
- 没有调用 close (),客户端和服务器的连接还保持着;
- 客户端可以给服务器发送数据,也可以接收服务器发送的数据 (但是,服务器已经丧失了发送数据的能力),因此客户端也只能发送数据,接收不到数据 –> 变成了写端;
按照上述流程做了半关闭之后,从双工变成了单工,数据单向流动的方向:客户端 —–> 服务器端。
// 专门处理半关闭的函数
#include <sys/socket.h>
// 可以有选择的关闭读/写, close()函数只能关闭写操作
int shutdown(int sockfd, int how);
参数:
- sockfd:要操作的文件描述符
- how:SHUT_RD:关闭文件描述符对应的读操作;SHUT_WR:关闭文件描述符对应的写操作;SHUT_RDWR:关闭文件描述符对应的读写操作
返回值:
- 函数调用成功返回 0,失败返回 - 1
4、端口复用
在网络通信中,一个端口只能被一个进程使用,不能被多个进程共用一个端口。我们按顺序执行如下操作:
先启动服务器程序,再启动客户端程序,然后关闭服务器进程,再退出客户端进程,最后再启动服务器进程,就会出如下的错误提示信息:bind error: Address already in use
# 第二次启动服务器进程
$ ./server
bind error: Address already in use
$ netstat -apn|grep 9999
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 127.0.0.1:9999 127.0.0.1:50178 TIME_WAIT
通过netstat查看TCP状态,发现上一个服务进程其实还没有真正退出(类似ctrl+z操作)。因为服务器进程是主动断开连接的进程,最后状态变成了TIME_WAIT状态,这个进程会等待2MSL(大约1分钟)才会退出,如果该进程不退出,其绑定的端口就不会被释放,再次启动新的进程是还使用这个未被释放的端口,断开被重复使用,就提示:bind error: Address already in use这个错误信息。
如果要解决上述问题,需要设置端口复用:
//套接字选项
int setsockopt(int sockfd,int level,int optname,const void* optval,socklen_t optlen):
参数:
- sockfd:用于监听的文件描述符;
- level:设置断开复用需要使用SOL_SOCKET宏;
- optname:要设置什么属性,SO_REUSEADDR、SO_REUSEPORT;
- optval:设置去除端口复用属性还是设置端口复用属性,实际应该使用int型变量;0表示不设置,1表示设置;
- optlen:optval指针指向的内存大小,sizeof(int);
#include <stdio.h>
#include <ctype.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
// server
int main(int argc, const char* argv[])
{
// 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket error");
exit(1);
}
// 绑定
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(9999);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本地多有的IP
// 127.0.0.1
// inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.s_addr);
// 设置端口复用
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定端口
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if(ret == -1)
{
perror("bind error");
exit(1);
}
// 监听
ret = listen(lfd, 64);
if(ret == -1)
{
perror("listen error");
exit(1);
}
fd_set reads, tmp;
FD_ZERO(&reads);
FD_SET(lfd, &reads);
int maxfd = lfd;
while(1)
{
tmp = reads;
int ret = select(maxfd+1, &tmp, NULL, NULL, NULL);
if(ret == -1)
{
perror("select");
exit(0);
}
if(FD_ISSET(lfd, &tmp))
{
int cfd = accept(lfd, NULL, NULL);
FD_SET(cfd, &reads);
maxfd = cfd > maxfd ? cfd : maxfd;
}
for(int i=lfd+1; i<=maxfd; ++i)
{
if(FD_ISSET(i, &tmp))
{
char buf[1024];
int len = read(i, buf, sizeof(buf));
if(len > 0)
{
printf("client say: %s\n", buf);
write(i, buf, len);
}
else if(len == 0)
{
printf("客户端断开了连接\n");
FD_CLR(i, &reads);
close(i);
}
else
{
perror("read");
exit(0);
}
}
}
}
return 0;
}
三、三次握手、四次挥手
四、服务器并发
服务端与一个客户端通信,
1、单线程/单进程
server端:
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string.h>
int main(int argc,char* argv[]){
//1.创建监听的套接字
int fd=socket(AF_INET,SOCK_STREAM,0);
if(fd==-1)
{
perror("socket");
return -1;
}
//2.绑定本地的IP Port
struct sockaddr_in saddr;//可以用struct sockaddr,不过sockaddr_in容易获取内部数据
saddr.sin_family=AF_INET;
saddr.sin_port=htons(9999);//主机字节序转换为网络字节序(小端->大端)
saddr.sin_addr.s_addr=INADDR_ANY;
int ret=bind(fd,(struct sockaddr*)&saddr,sizeof(saddr));
if(ret==-1)
{
perror("bind");
return -1;
}
//3.设置监听
ret=listen(fd,128);
if(ret==-1)
{
perror("listen");
return -1;
}
//4.阻塞并等待客户端的连接
struct sockaddr_in caddr;
socklen_t addrlen=sizeof(caddr);
int cfd=accept(fd,(struct sockaddr*)&caddr,&addrlen);
if(cfd==-1)
{
perror("accept");
return -1;
}
//连接建立成功,打印客户端的IP、端口信息
char ip[32];//网络字节序->主机字节序
printf("客户端的IP: %s, 端口:
%d\n",inet_ntop(AF_INET,&caddr.sin_addr.s_addr,ip,sizeof(ip)),ntohs(caddr.sin_port));
//5.通信
while(true)
{
//接受数据
char buff[1024];
int len=recv(cfd,buff,sizeof(buff),0);//返回接受到的数据大小
if(len>0)
{
printf("client say: %s\n",buff);
send(cfd,buff,len,0);
}else if(len==0)
{
printf("客户端已经断开了连接...\n");
break;
}else{
perror("recv");
break;
}
}
//6.关闭文件描述符
close(fd);
close(cfd);
return 0;
}
client端:
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>// 已经包括了inet/socket.h了
int main(int argc,char* argv[])
{
//1.创建通信的套接字
int fd=socket(AF_INET,SOCK_STREAM,0);
if(fd==-1)
{
perror("socket");
return -1;
}
//2.连接服务器IP、port
struct sockaddr_in saddr;
saddr.sin_family=AF_INET;
saddr.sin_port=htons(9999);
inet_pton(AF_INET,"192.168.207.129",&saddr.sin_addr.s_addr);
int ret=connect(fd,(struct sockaddr*)&saddr,sizeof(saddr));
if(ret==-1)
{
perror("connect");
return -1;
}
//3.通信
int number=0;
while(true)
{
char buff[1024];
sprintf(buff,"你好, hello world, %d...\n",number++);
send(fd,buff,strlen(buff)+1,0);
//接受数据
memset(buff,0,sizeof(buff));
int len=recv(fd,buff,sizeof(buff),0);
if(len>0)
{
printf("server,say: %s\n",buff);
}else if(len==0)
{
printf("服务器已经断开了连接...\n");
break;
}else{
perror("recv");
break;
}
sleep(1);
}
//4.关闭文件描述符
close(fd);
return 0;
}
在上面的代码中用到了三个会引起程序阻塞的函数,分别是:
- accept():如果服务器端没有新客户端连接,阻塞当前进程 / 线程,如果检测到新连接解除阻塞,建立连接
- read():如果通信的套接字对应的读缓冲区没有数据,阻塞当前进程 / 线程,检测到数据解除阻塞,接收数据
- write():如果通信的套接字写缓冲区被写满了,阻塞当前进程 / 线程(这种情况比较少见),如果需要和发起新的连接请求的客户端建立连接,那么就必须在服务器端通过一个循环调用
accept() 函数,另外已经和服务器建立连接的客户端需要和服务器通信,发送数据时的阻塞可以忽略,当接收不到数据时程序也会被阻塞,这时候就会非常矛盾,被 accept() 阻塞就无法通信,被 read() 阻塞就无法和客户端建立新连接。因此得出一个结论,基于上述处理方式,在单线程 / 单进程场景下,服务器是无法处理多连接的,解决方案也有很多,常用的有三种:
- 使用多线程实现
- 使用多进程实现
- 使用 IO 多路转接(复用)实现
- 使用 IO 多路转接 + 多线程实现
2、多进程并发
如果编写多进程版的并发服务器程序,首先要考虑,创建出的多个进程都是什么角色,这样就可以在程序中对号入座了。在TCP服务器一共有两个角色,分别是:监听和通信,监听时一个持续的动作,如果有新的连接就建立一个连接,如果没有新连接就会阻塞。关于通信是需要和多个客户端同时进行的,因此需要多个进程,这样才能达到互相不影响同时运行。进程需要两大类:父进程和子进程,
(1)父进程
- 负责监听,处理客户端的连接请求,也就是在父进程中循环调用accep( )函数;
- 创建子进程:建立一个新的连接,就创建一个新的子进程,让这个子进程和对应的客户端进行通信;
- 回收子进程资源:子进程退出回收其内核PCB资源,防止出现僵尸进程(子进程退出,父进程没有回收资源);
(2)子进程
- 负责通信,基于父进程建立新连接之后得到的文件描述符,和对应的客户端完成数据的接受和发送;
- 发送数据:send( )/write( );
- 接收数据:recv( )/read( );
在多进程版的服务器端程序中,多个进程是有血缘关系的,
- 子进程是父进程的拷贝,在子进程的内核区PCB中,文件描述符也是可以被拷贝的,因此在父进程可以使用的文件描述符在子进程中也有一份,并且可以使用它们做和父进程一样的事情;
- 父子进程有各自的虚拟地址空间,因此所有的资源都是独占的
- 为了节省资源,对于只有在父进程才能用到的资源,可以在子进程找那个将其释放掉,父进程亦如此;
- 由于需要在父进程中做accept( )操作,并且要释放子进程资源,如果想要高效处理往往需要使用信号的方式。
注意:
- Linux的exit()是进程退出用的,wait( )/waitpid( )则是父进程用来回收已退出的子进程的资源的。
- 若子进程已经调用了exit()退出,而父进程还没有调用wait()回收资源的状态,就是僵尸状态(task zombie)。这时该进程已经不能运行,但还占着待回收资源。
#include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>
#include <sys/types.h>
//信号处理函数
void callback(int num)
{
while(true)
{
//非阻塞等待所有子进程退出,回收已经退出的子进程资源,-1代表所有子进程,
//已经没有子进程返回0,异常返回-1,大于0为结束的子进程
pid_t pid=waitpid(-1,NULL,WNOHANG);
if(pid<=0)
{
printf("子进程正在运行,或者所有的子进程已经被回收完毕\n");
break;
}
printf("child die,pid=%d\n",pid);
}
}
int childWork(int cfd);
int main(int argc,char* argv[]){
//1.创建监听的套接字
int lfd=socket(AF_INET,SOCK_STREAM,0);
if(lfd==-1)
{
perror("socket");
exit(0);
}
//2.将socket返回的文件描述符绑定到指定的IP和Port
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port=htons(9999);//主机字节序->网络字节序(小端->大端)
//INADDR_ANY本机所有的IP,假设有三个网卡就有三个IP地址
//这个宏可以代表任何一个IP地址,一般用于本地的绑定操作
addr.sin_addr.s_addr=INADDR_ANY;//这个宏的值为0=0.0.0.0
int ret=bind(lfd,(struct sockaddr*)&addr,sizeof(addr));
if(ret==-1)
{
perror("bind");
exit(0);
}
//设置监听
ret=listen(lfd,128);
if(ret==-1)
{
perror("listen");
exit(0);
}
//注册信号的捕捉
struct sigaction act;
act.sa_flags=0;
act.sa_handler=callback;
sigemptyset(&act.sa_mask);
sigaction(SIGCHLD,&act,NULL);
while(true)
{
//4.阻塞等待并接收客户端连接
struct sockaddr_in cliaddr;
socklen_t clilen=sizeof(cliaddr);
int cfd=accept(lfd,(struct sockaddr*)&cliaddr,&clilen);
if(cfd==-1)
{
if(errno==EINTR)
{
continue;//accept调用被信号中断了,解除阻塞,返回了-1
}
perror("accept");
exit(0);
}
//打印客户端的地址信息
char ip[24]={0};
printf("客户端的IP地址:%s,端口:%d\n",
inet_ntop(AF_INET,&cliaddr.sin_addr.s_addr,ip,sizeof(ip)),
ntohs(cliaddr.sin_port));
//新的连接已经建立,创建子进程,让子进程和这个客户端进行通信
pid_t pid=fork();//fork执行两次放回,子进程和父进程,子进程有lfd和cfd,父进程也有
if(pid==0)
{
//子进程与客户端进行通信,fork通信的文件描述符cfd被拷贝到子进程中
//子进程不负责监听
close(lfd);
while(true)
{
int ret=childWork(cfd);//已经没有数据了
if(ret<=0) break;
}
//退出子进程
close(cfd);
exit(0);
}else if(pid>0)
{
//父进程不和客户端通信
close(cfd);
}
}
return 0;
}
//5.与客户端通信
int childWork(int cfd)
{
//接收数据
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");
}else
{
perror("read");
}
return len;
}
在上面的代码代码中,父子进程中分别关掉了用不到的文件描述符(父进程不需要通信,子进程也不需要监听)。如果客户端主动断开了连接,那么服务器端负责和客户端通信的子进程也就退出了,子进程退出会给父进程发送一个SIGCHLD的信号,在父进程中通过sigaction( )函数捕捉了该信号,通过回调函数callback( )中的waitpid( )对退出的子进程进行了资源回收!
int cfd=accept(lfd,(struct sockaddr*)&cliaddr,&clilen);
if(cfd==-1)
{
if(errno==EINTR)
{
continue;//accept调用被信号中断了,解除阻塞,返回了-1
}
perror("accept");
exit(0);
}
- 如果父进程调用accept( )函数没有检测到新的客户端连接,父进程就阻塞在这儿了,这时候有子进程退出了,发送信号给父进程,父进程就捕获了这个信号SIGCHLD,由于信号的优先级很高,会打断代码的正常执行,因此父进程的阻塞被中断,转而取处理这个信号对应的函数callback( ),处理完毕后,再次回到accept( )位置,但是这时已经无法阻塞了,函数直接返回-1,此时函数调用失效,错误描述为accept:Interrupted system call,对应的错误码为EINTR,由于代码是被信号中断导致的错误,所以可以在程序中对这个的错误号进行判断,让父进程重新调用accept( ),继续阻塞或接收客户端的新连接!
3、多线程并发
(1)主线程
- 负责监听,处理客户端的连接请求,也就是在主线程中循环调用accept( )函数;
- 创建子线程:建立一个新的连接,就是创建一个新的子线程,让这个子线程和对应的客户端进行通信;
- 回收子线程资源:由于回收需要调用阻塞函数,这样就会影响accept( ),直接做线程分离即可;
(2)子线程
- 负责通信,基于主线程建立新连接之后得到的文件描述符,和对应的客户端完成数据的接受和发送;
- 发送数据:send( )/write( );
- 接收数据:recv( )/read( );
在多线程的服务器端程序中,多个线程共用一个地址空间,有些数据是共享的,有些数据是独占的,
- 同一个地址空间中的多个线程的栈空间是独占的;
- 多个线程共享全局数据区,堆区,以及内核区的文件描述符等资源,因此需要注意数据问题,并且在多个线程访问共享资源要涉及到线程同步的问题;
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
struct SockInfo
{
int fd; //通信
pthread_t tid; //线程ID
struct sockaddr_in addr; //地址信息
};
struct SockInfo infos[128];
void* working(void* arg)
{
while(true)
{
struct SockInfo* info=(struct SockInfo*)arg;
//接收数据
char buf[1024];
int ret=read(info->fd,buf,sizeof(buf));
if(ret==0)
{
printf("客户端已经关闭连接...\n");\
info->fd=-1;
break;
}else if(ret==-1)
{
printf("接收数据失败...\n");
info->fd=-1;
break;
}else
{
write(info->fd,buf,strlen(buf)+1);
}
}
return NULL;
}
int main(int argc,char* argv[])
{
//1.创建用于监听的文件描述符
int fd=socket(AF_INET,SOCK_STREAM,0);
if(fd==-1)
{
perror("socket");
exit(0);
}
//2.绑定fd到指定的地址信息上
struct sockaddr_in addr;
addr.sin_family=AF_INET; //ipv4
addr.sin_port=htons(9999); //字节序应该是网络字节序
addr.sin_addr.s_addr=INADDR_ANY;//获取ip交给内核
int ret=bind(fd,(struct sockaddr*)&addr,sizeof(addr));
if(ret==-1)
{
perror("bind");
exit(0);
}
//3.设置监听
ret=listen(fd,128);
if(ret==-1)
{
perror("listen");
exit(0);
}
//4.等待,接收连接请求
socklen_t len=sizeof(struct sockaddr);
//数据初始化
int max=sizeof(infos)/sizeof(infos[0]);
for(int i=0;i<max;++i)
{
bzero(&infos[i],sizeof(infos[i]));
infos[i].fd=-1;
infos[i].tid=-1;
}
//主线程监听,子线程通信
while(true)
{
//创建子线程
struct SockInfo* pinfo;
for(int i=0;i<max;++i)
{
if(infos[i].fd==-1)
{
pinfo=&infos[i];
break;
}
if(i==max-1)
{
sleep(1);
i--;
}
}
int connfd=accept(fd,(struct sockaddr*)&pinfo->addr,&len);
printf("parent thread,connfd:%d\n",connfd);
if(connfd==-1)
{
perror("accept");
exit(0);
}
pinfo->fd=connfd;
pthread_create(&pinfo->tid,NULL,working,pinfo);
pthread_detach(pinfo->tid);
}
//6.释放资源
close(fd);
return 0;
}
在编写多线程版并发服务器代码的时候,需要注意父子线程共用同一个地址空间中的文件描述符,因此每当在主线程中建立一个新的连接,都需要将得到文件描述符值保存起来,不能在同一变量上进行覆盖,这样做丢失了之前的文件描述符值也就不知道怎么和客户端通信了。
在上面示例代码中是将成功建立连接之后得到的用于通信的文件描述符值保存到了一个全局数组中,每个子线程需要和不同的客户端通信,需要的文件描述符值也就不一样,只要保证存储每个有效文件描述符值的变量对应不同的内存地址,在使用的时候就不会发生数据覆盖的现象,造成通信数据的混乱了。
五、TCP数据粘包的处理
1、背锅侠的TCP
TCP是一种传输协议,它是一个面向连接、可靠的、流式传输协议。因为数据的传输时基于流的因此发送端和接收端每次处理的数据的量,处理的频率可以说不是对等的,可以按照自身的需要来进程处理。
假设有如下需求:
通信过程中客户端会每次会不定期给服务器发送一个不定长度的有特定含义的字符串;
通信的服务器端每次都需要接收到客户端这个不定长度的字符串,并对其进行解析;
根据上面的描述,服务器在接收数据的时候有以下情况:
- 一次接收了客户端发送过来的一个完整数据包;
- 一次接收到客户端发送过来的N个数据包,由于每个包的长度不定,无法将各个数据包拆开;
- 一次接收到了一个或者N个数据包+下一个数据包的一部分,还是很悲观的,无法将数据包拆开;
- 一次接收到了半个数据包,下一次接收到数据的时候收到了剩下的一部分+下一个数据包的一部分;
- 另外,还有一些不可抗拒的因素,比如客户端和服务器的网速不一样,发送和接受的数据也不一样;
某种意义上说,TCP粘包问题并不是协议的问题,多个数据包粘连到一起无法拆分是我们的需求过于复杂造成的,是程序猿的问题而不是协议的问题。
一般的使用方案:
- 使用标准的应用层协议(比如:http/https)来封装要传输的不定长度的数据包;
- 在每条数据包的尾部添加特殊的字节,如果遇到特殊的字节,代表这条数据已经接收完毕了。但是存在缺陷,效率低下,需要一个字节一个字节接收,接收到的一个字节后需要判断一次,判断是不是特殊字符;
- 在发送数据块之前,在数据块最前边添加一个固定大小的数据头,这时候数据由两部分组成:数据头+数据块,
- 数据头:存储当前数据包的总字节数,接收端先接受数据头,然后在根据数据头接收对应大小的字节;
- 数据块:当前的数据内容;
简单说下自己的理解,第3种方案,类似于HTTP协议中客户端发送请求给服务器,然后服务器就对请求进行解析,先解析请求头,然后请求行,根据请求头判断是否为post请求,若是,则取出content-length长度,解析请求body的部分内容,因为这段内容没有明显的结束部分;以此类推,TCP也同理,它在发送数据的出去并没有指定结束的位置,这样在数据解析的时候就出现问题!
2、解决方案
如果使用TCP进行套接字通信,如果发送的数据包粘到一块导致接收端无法解析,我们通常使用添加包头的方式轻松解决掉这个问题,
关于数据包的包头大小可以根据自己的实际需求进行设定,这里没有啥特殊需求,因此规定包头的固定大小为4个字节,用于存储当前数据块的总字节数。
(1)发送端
对于发送端而言,数据的发送分为4个步骤:
- 根据待发送的数据长度N动态申请一块固定大小的内存:N+4(4是包头所占用的字节数);
- 将待发送的数据的总长度写入申请的内存的前四个字节中,此处需要将其转换为网络字节序(从小端->大端);
- 将待发送的数据拷贝到包头后边的地址空间中,然后将完整数据包发送出去(字符串没有字节序的问题);
- 释放申请的堆内存;
由于发送端每次都需要将这个数据包完整的发送出去,因此可以设计一个发送函数,如果当前数据包中的数据没有发送完就让它一直发送,处理代码如下:
/*
函数描述: 发送指定的字节数
函数参数:
- fd: 通信的文件描述符(套接字)
- msg: 待发送的原始数据
- size: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int writen(int fd, const char* msg, int size)
{
const char* buf = msg;
int count = size;
while (count > 0)
{
int len = send(fd, buf, count, 0);
if (len == -1)
{
close(fd);
return -1;
}
else if (len == 0)
{
continue;
}
buf += len;
count -= len;
}
return size;
}
有了这个功能函数之后就可以发送带有包头的数据块了,具体处理动作如下:
/*
函数描述: 发送带有数据头的数据包
函数参数:
- cfd: 通信的文件描述符(套接字)
- msg: 待发送的原始数据
- len: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int sendMsg(int cfd, char* msg, int len)
{
if(msg == NULL || len <= 0 || cfd <=0)
{
return -1;
}
// 申请内存空间: 数据长度 + 包头4字节(存储数据长度)
char* data = (char*)malloc(len+4);
int bigLen = htonl(len);
memcpy(data, &bigLen, 4);
memcpy(data+4, msg, len);
// 发送数据
int ret = writen(cfd, data, len+4);
// 释放内存
free(data);
return ret;
}
- 数据发送:字符串没有字节序的问题,但是数据头不是字符串而是整形,因此需要从主机字节序转换成网络字节序再发送;
(2)接收端
具体过程:
- 首先接收4个字节数据,并将其从网络字节序转换为主机字节序,这样就得到额即将要接收的数据总长度;
- 根据得到的长度中申请固定大小的堆内存,用于存储待接收的数据;
- 根据得到的数据块长度接收固定大小的数据保存到申请的堆内存中;
- 处理接收的数据;
- 释放存储数据的堆内存;
从数据包头解析出要接收的数据长度之后,还需要将这个数据块完整得接收到本地才可以进行后续的数据处理操作,因此需要编写一个接收数据的功能函数,保证能够得到一个完整的数据包数据,处理函数实现如下:
/*
函数描述: 接收指定的字节数
函数参数:
- fd: 通信的文件描述符(套接字)
- buf: 存储待接收数据的内存的起始地址
- size: 指定要接收的字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int readn(int fd, char* buf, int size)
{
char* pt = buf;
int count = size;
while (count > 0)
{
int len = recv(fd, pt, count, 0);
if (len == -1)
{
return -1;
}
else if (len == 0)
{
return size - count;
}
pt += len;
count -= len;
}
return size;
}
这个函数搞定之后,就可以轻松地接收带包头的数据块了,接收函数实现如下:
/*
函数描述: 接收带数据头的数据包
函数参数:
- cfd: 通信的文件描述符(套接字)
- msg: 一级指针的地址,函数内部会给这个指针分配内存,用于存储待接收的数据,
这块内存需要使用者释放
函数返回值: 函数调用成功返回接收的字节数, 发送失败返回-1
*/
int recvMsg(int cfd, char** msg)
{
// 接收数据
// 1. 读数据头
int len = 0;
readn(cfd, (char*)&len, 4);
len = ntohl(len);
printf("数据块大小: %d\n", len);
// 根据读出的长度分配内存,+1 -> 这个字节存储\0
char *buf = (char*)malloc(len+1);
int ret = readn(cfd, buf, len);
if(ret != len)
{
close(cfd);
free(buf);
return -1;
}
buf[len] = '\0';
*msg = buf;
return ret;
}
这样,在进行套接字通信的时候通过调用封装的 sendMsg() 和 recvMsg() 就可以发送和接收带数据头的数据包了,而且完美地解决了粘包的问题。
六、IO多路复用
IO多路复用也称为IO多路转接,它是一种网络通信机制,通过这种方式可以同时监听多个文件描述符并且这个过程是阻塞的,一旦检测有文件描述符就绪(可以读数据或者可以写数据)程序的阻塞会被解除,之后就可以基于这些(一个或多个)就绪的文件描述符进行通信了。通过这种方式在单线程/单进程的场景下也可以在服务器端实现并发。常见的 IO 多路转接方式有:select、poll、epoll。
对比多线程/多进程并发和IO多路复用的并发处理流程(服务器端):
(1)多线程/多进程并发
- 主线程/父进程:调用accept( )检测客户端连接请求
- 如果没有新的客户端连接请求,当前线程/进程会被阻塞;
- 如果有新的客户端连接请求解除阻塞,建立连接;
- 子线程/子进程:与建立连接的客户端进行通信
- 调用read( )/recv( )接收客户端发送的通信数据,如果没有通信数据,当前线程/进程就会阻塞,数据到达之后阻塞会自动解除;
- 调用write( )/send( )给客户端发送数据,如果写缓冲区(内核)已满,当前线程/进程就会被阻塞,否则将待发送的数据写入写缓冲区中;
(2)IO多路复用并发
- 使用IO多路复用委托内核检测服务器端所有的文件描述符(通信和监听两类),这个检测过程会导致进程/线程被阻塞,如果检测到已就绪的文件描述符则阻塞被解除,并将这些已就绪的文件描述符传出;
- 根据类型对传出的所有就绪文件描述符进行判断,并做出不同的处理;
- 监听的文件描述符:与客户端建立连接,此时调用accept( )是不会导致程序阻塞的,因为监听的文件描述符是已经就绪(有新请求);
- 通信的文件描述符:与已经建立连接的客户端进行通信,调用read( )/recv( )不会阻塞的,因为通信的文件描述符已经就绪的,读缓冲区内已经有数据了;调用write( )/send( )不会阻塞程序,因为通信的文件描述符已经就绪,写缓冲区不满,可以往里面写数据;
1、select
(1)函数原型
- 读缓冲区:检测读缓冲区有没有数据,如果有数据该缓冲区对应的文件描述符就绪;
- 写缓冲区:检测写缓冲区有没有可以写(存在容量),如果有容量可以写,缓冲区对应的文件描述符就绪;
- 读写异常:检测读写缓冲区是否有异常,如果有该缓冲区对应的文件描述符就绪;
委托检测的文件描述符被遍历检测完毕之后,已就绪的这些满足条件的文件描述符会通过 select() 的参数分 3 个集合传出,程序猿得到这几个集合之后就可以分情况依次处理了。
下面来看一下这个函数的函数原型:
#include <sys/select.h>
struct timeval{
time_t tv_sec; //seconds
suseconds_t tv_usec; //microseconds
}
int select(int nfds,fd_set* readfds,fd_set* writefds,
fd_set* exceptfds,struct timeval* timeout);
函数参数:
- nfds:委托内核检测的这三个集合找那个最大的文件描述符+1,内核需要线性遍历这些集合中的文件描述符,这个值是循环结束的条件;在Window中这个值无效,指定为-1即可;
- readfds:文件描述符的集合,内核只会检测这个集合中文件描述符对应的读缓冲区;传入传出参数,读集合一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收了数据;
- writefds:文件描述符的集合,内核只会检测这个集合中文件描述符对应的写缓冲区;传入传出参数,如果不需要使用这个参数可以指定为NULL;
- exceptfds:文件描述符的集合,内核检测集合中文件描述符是否有异常状态,传入传出参数,如果不设置使用这个参数为NULL;
- timeout:超时时间,用来强制解除select( )的阻塞的,NULL函数检测不到就绪的文件描述符会一直阻塞;等待固定时长(秒),那么函数检测不到就绪的文件描述符,在指定时长之后就强制解除阻塞,函数返回0;不等待,函数不会阻塞,直接将参数对应的结构体初始化为0即可;
返回值:
- 大于0:成功,返回集合中已就绪的文件描述符的总个数;
- 等于-1:函数调用失败;
- 等于0:超时,没有检测到就绪的文件描述符;
另外初始化 fd_set 类型的参数还需要使用相关的一些列操作函数,具体如下:
- 在 select() 函数中第 2、3、4 个参数都是 fd_set 类型,它表示一个文件描述符的集合,类似于信号集 sigset_t,这个类型的数据有 128 个字节,也就是 1024 个标志位,和内核中文件描述符表中的文件描述符个数是一样的。
sizeof(fd_set) = 128 字节 * 8 = 1024 bit // int [32]
这并不是巧合,而是故意为之。这块内存中的每一个 bit 和 文件描述符表中的每一个文件描述符是一一对应的关系,这样就可以使用最小的存储空间将要表达的意思描述出来了。
// 将文件描述符fd从set集合中删除 == 将fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中 == 读一下fd对应的标志位到底是0还是1
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
void FD_ZERO(fd_set *set);
下图中的fd_set中存储了要委托内核检测读缓冲区的文件描述符。其实将上面的fd_set三个集合拷贝一份到内核,与内核的进行对比,然后更新fd_set里面的集合,
- 如果集合中的标志位0代表不检测这个文件描述符状态;
- 如果集合中的标志位为1代表检测这个文件描述符状态;
内核在遍历这个读集合的过程中,如果被检测的文件描述符对应的读缓冲区中没有数据,内核将修改这个文件描述符在读集合fd_set中对应的标志位,改为0,如果有数据那么这个标志位的值不变,还是1。
当 select() 函数解除阻塞之后,被内核修改过的读集合通过参数传出,此时集合中只要标志位的值为 1,那么它对应的文件描述符肯定是就绪的,我们就可以基于这个文件描述符和客户端建立新连接或者通信了。
(2)并发实现
以读事件集合为例处理流程:
- 创建监听的套接字lfd=socket( );
- 将监听的套接字和本地的IP和端口绑定bind( );
- 给监听的套接字设置为监听listen( );
- 创建一个文件描述符集合fd_set,用于存储需要检测读事件的所有的文件描述符,步骤如下:通过设置FD_ZERO( )初始化;通过FD_SET( )将监听的文件描述符放入到检测的集合中;
- 循环调用select( ),周期性的对所有的文件描述符进行检测;
- select( )解除阻塞返回,得到内核传出的满足条件的就绪文件描述符集合,并遍历所有集合,通过FD_ISSET( )判断集合的标志位是否为1,
- 如果这个文件描述符是监听的文件描述符,调用accept( )和客户端建立连接,然后通过FD_SET将得到新的文件描述符放入到集合当中;
- 如果这个文件描述符是通信的文件描述符,调用通信函数与客户端进行通信,需要分两种情况:如果客户达和服务器断开了连接,使用FD_CLE( )将这个文件描述符从检测集合中删除;如果不是断开连接,则正常进行通信;
单线程服务端代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main(int argc,char* argv[])
{
// 1. 创建监听的fd
int lfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(9999);
addr.sin_addr.s_addr = INADDR_ANY;
bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
// 3. 设置监听
listen(lfd, 128);
// 将监听的fd的状态检测委托给内核检测
int maxfd = lfd;
// 初始化检测的读集合
fd_set rdset;
fd_set rdtemp;
// 清零
FD_ZERO(&rdset);
// 将监听的lfd设置到检测的读集合中
FD_SET(lfd, &rdset);
// 通过select委托内核检测读集合中的文件描述符状态, 检测read缓冲区有没有数据
// 如果有数据, select解除阻塞返回
// 应该让内核持续检测
while(1)
{
// 默认阻塞
// rdset 中是委托内核检测的所有的文件描述符
rdtemp = rdset;
int num = select(maxfd+1, &rdtemp, NULL, NULL, NULL);
//rdset中的数据被内核改写了,只保留了发生变化的文件描述的标志位上的1,没变化的改为0
//只要rdset中的fd对应的标志位为1->缓冲区有数据了
//判断有没有新连接
if(FD_ISSET(lfd, &rdtemp))
{
//接受连接请求, 这个调用不阻塞
struct sockaddr_in cliaddr;
int cliLen=sizeof(cliaddr);
int cfd=accept(lfd, (struct sockaddr*)&cliaddr, &cliLen);
//得到了有效的文件描述符
//通信的文件描述符添加到读集合
//在下一轮select检测的时候, 就能得到缓冲区的状态
FD_SET(cfd, &rdset);
//重置最大的文件描述符
maxfd = cfd > maxfd ? cfd : maxfd;
}
// 没有新连接, 通信
for(int i=0; i<maxfd+1; ++i)
{
//判断从监听的文件描述符之后到maxfd这个范围内的文件描述符是否读缓冲区有数据
if(i != lfd && FD_ISSET(i, &rdtemp))
{
// 接收数据
char buf[10] = {0};
//一次只能接收10个字节, 客户端一次发送100个字节
//一次是接收不完的, 文件描述符对应的读缓冲区中还有数据
//下一轮select检测的时候,内核还会标记这个文件描述符缓冲区有数据->再读一次
//循环会一直持续, 知道缓冲区数据被读完位置
int len = read(i, buf, sizeof(buf));
if(len == 0)
{
printf("客户端关闭了连接...\n");
// 将检测的文件描述符从读集合中删除
FD_CLR(i, &rdset);
close(i);
}
else if(len > 0)
{
// 收到了数据
// 发送数据
write(i, buf, strlen(buf)+1);
}
else
{
// 异常
perror("read");
}
}
}
}
return 0;
}
在上面的代码中,创建了两个 fd_set 变量,用于保存要检测的读集合
// 初始化检测的读集合
fd_set rdset;
fd_set rdtemp;
- rdset 用于保存要检测的原始数据,这个变量不能作为参数传递给 select 函数,因为在函数内部这个变量中的值会被内核修改,函数调用完毕返回之后,里边就不是原始数据了,大部分情况下是值为 1 的标志位变少了,不可能每一轮检测,所有的文件描述符都是就行的状态。因此需要通过 rdtemp 变量将原始数据传递给内核,select () 调用完毕之后再将内核数据传出,这两个变量的功能是不一样的。
客户端代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main(int argc,char* argv[])
{
// 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; // ipv4
addr.sin_port = htons(9999); // 服务器监听的端口, 字节序应该是网络字节序
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);
}
// 通信
while(1)
{
// 读数据
char recvBuf[1024];
// 写数据
// sprintf(recvBuf, "data: %d\n", i++);
fgets(recvBuf, sizeof(recvBuf), stdin);
write(fd, recvBuf, strlen(recvBuf)+1);
// 如果客户端没有发送数据, 默认阻塞
read(fd, recvBuf, sizeof(recvBuf));
printf("recv buf: %s\n", recvBuf);
sleep(1);
}
// 释放资源
close(fd);
return 0;
}
客户端不需要使用 IO 多路转接进行处理,因为客户端和服务器的对应关系是 1:N,也就是说客户端是比较专一的,只能和一个连接成功的服务器通信。
多线程服务端代码如下:
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <arpa/inet.h>
#include <assert.h>
#include <pthread.h>
#include <sys/select.h>
pthread_mutex_t mutex;
typedef struct fdinfo
{
int fd;
int* maxfd;
fd_set* rdset;
}FDInfo;
void* communication(void* arg)
{
char buf[1024];
FDInfo* info=(FDInfo*)arg;
int len=recv(info->fd,buf,sizeof(buf),0);
if(len==-1)
{
perror("recv error");
free(info);
return NULL;
}
else if(len==0)
{
printf("客户端已经断开了连接...\n");
pthread_mutex_lock(&mutex);
FD_CLR(info->fd,info->rdset);
pthread_mutex_unlock(&mutex);
close(info->fd);
free(NULL);
return NULL;
}
printf("read buf = %s\n",buf);
//小写转大写
for(int i=0;i<len;++i)
{
buf[i]=toupper(buf[i]);
}
printf("after buf = %s\n",buf);
int ret=send(info->fd,buf,strlen(buf)+1,0);
if(ret==-1)
{
perror("send error");
}
free(info);
return NULL;
}
void* acceptConn(void* arg)
{
FDInfo* info=(FDInfo*)arg;
//接受客户端的连接请求
//accept每次只能处理一个,单独放出来也行,或者放在for循环
int cfd=accept(info->fd,NULL,NULL);
pthread_mutex_lock(&mutex);
FD_SET(cfd,info->rdset);
//更新maxfd最大值
*info->maxfd=cfd>*info->maxfd?cfd:*info->maxfd;
pthread_mutex_unlock(&mutex);
free(info);
return NULL;
}
int main(int argc,char* argv[])
{
pthread_mutex_init(&mutex,NULL);
//创建监听的套接字
int fd=socket(AF_INET,SOCK_STREAM,0);
assert(fd!=-1);
//绑定
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port=htons(9999);
addr.sin_addr.s_addr=INADDR_ANY;
int ret=bind(fd,(struct sockaddr*)&addr,sizeof(addr));
assert(ret!=-1);
//设置监听
ret=listen(fd,128);
assert(ret!=-1);
fd_set redset;
FD_ZERO(&redset);//集合初始化
FD_SET(fd,&redset);//添加到集合redset
int maxfd=fd;
while(true)
{
//第一次参数可以设置为FD_SET SIZE(1024)
pthread_mutex_lock(&mutex);
fd_set tmp=redset;
pthread_mutex_unlock(&mutex);
int res=select(maxfd+1,&tmp,NULL,NULL,NULL);//最后一个参数设置为阻塞状态
//判断是不是用于监听的fd
if(FD_ISSET(fd,&tmp))
{
//创建子线程
pthread_t tid;
FDInfo* info=(FDInfo*)malloc(sizeof(FDInfo));
info->fd=fd;
info->maxfd=&maxfd;
info->rdset=&redset;
pthread_create(&tid,NULL,acceptConn,info);
pthread_detach(tid);
}
// std::cout<<"res: "<<res<<std::endl;
for(int i=0;i<=maxfd;++i)
{
if(i!=fd&&FD_ISSET(i,&tmp))
{
//接收数据
pthread_t tid;
FDInfo* info=(FDInfo*)malloc(sizeof(FDInfo));
info->fd=i;
info->rdset=&redset;
pthread_create(&tid,NULL,communication,info);
pthread_detach(tid);
}
}
}
close(fd);
pthread_mutex_destroy(&mutex);
return 0;
}
虽然使用 select 这种 IO 多路转接技术可以降低系统开销,提高程序效率,但是它也有局限性:
- 待检测结合(第2、3、4个参数)需要频繁的在用户区和内核区之间进行数据的拷贝,效率低;
- 内核对于select传递进来的待检测集合的检测方式是线性的,如果集合内待检测文件描述符比较多,检测效率会很低;如果集合内待检测的文件描述符比较少,检测效率会比较高;
- 使用select能够检测的最大文件描述符个数有上限,默认是1024,这是内核中被写死了;
2、poll
poll 的机制与 select 类似,与 select 在本质上没有多大差别,使用方法也类似,下面的是对于二者的对比:
内核对应文件描述符的检测也是以线性的方式进行轮询,根据描述符的状态进行处理;
poll 和 select 检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件描述符数量的增加而线性增大,从而效率也会越来越低;
select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制
select可以跨平台使用,poll只能在Linux平台使用;
poll 函数的函数原型如下:
#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 -> 传出 */
};
struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数:
(1)fds这是一个struct poolfd类型的数组,里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
- fd:委托内检测的文件描述符;
- events:委托内检测的fd事件(输入、输出、错误),每一事件有多个值;
- revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果;
(2)nfds这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数 1 数组的元素总个数
(3)timeout:指定 poll 函数的阻塞时长
-1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞;
0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回;
大于 0:阻塞指定的毫秒(ms)数之后,解除阻塞;
(4)函数返回值:
- 失败: 返回 - 1
- 成功:返回一个大于 0 的整数,表示检测的集合中已就绪的文件描述符的总个数
服务器代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <poll.h>
int main()
{
// 1.创建套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket");
exit(0);
}
// 2. 绑定 ip, port
struct sockaddr_in addr;
addr.sin_port = htons(9999);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("bind");
exit(0);
}
// 3. 监听
ret = listen(lfd, 100);
if(ret == -1)
{
perror("listen");
exit(0);
}
// 4. 等待连接 -> 循环
// 检测 -> 读缓冲区, 委托内核去处理
// 数据初始化, 创建自定义的文件描述符集
struct pollfd fds[1024];
// 初始化
for(int i=0; i<1024; ++i)
{
fds[i].fd = -1;
fds[i].events = POLLIN;
}
fds[0].fd = lfd;
int maxfd = 0;
while(1)
{
// 委托内核检测
ret = poll(fds, maxfd+1, -1);
if(ret == -1)
{
perror("select");
exit(0);
}
// 检测的度缓冲区有变化
// 有新连接
if(fds[0].revents & POLLIN)
{
// 接收连接请求
struct sockaddr_in sockcli;
int len = sizeof(sockcli);
// 这个accept是不会阻塞的
int connfd = accept(lfd, (struct sockaddr*)&sockcli, &len);
// 委托内核检测connfd的读缓冲区
int i;
for(i=0; i<1024; ++i)
{
if(fds[i].fd == -1)
{
fds[i].fd = connfd;
break;
}
}
maxfd = i > maxfd ? i : maxfd;
}
// 通信, 有客户端发送数据过来
for(int i=1; i<=maxfd; ++i)
{
// 如果在集合中, 说明读缓冲区有数据
if(fds[i].revents & POLLIN)
{
char buf[128];
int ret = read(fds[i].fd, buf, sizeof(buf));
if(ret == -1)
{
perror("read");
exit(0);
}
else if(ret == 0)
{
printf("对方已经关闭了连接...\n");
close(fds[i].fd);
fds[i].fd = -1;
}
else
{
printf("客户端say: %s\n", buf);
write(fds[i].fd, buf, strlen(buf)+1);
}
}
}
}
close(lfd);
return 0;
}
从上面的测试代码可以得知,使用 poll 和 select 进行 IO 多路转接的处理思路是完全相同的,但是使用 poll 编写的代码看起来会更直观一些,select 使用的位图的方式来标记要委托内核检测的文件描述符(每个比特位对应一个唯一的文件描述符),并且对这个 fd_set 类型的位图变量进行读写还需要借助一系列的宏函数,操作比较麻烦。而 poll 直接将要检测的文件描述符的相关信息封装到了一个结构体 struct pollfd 中,我们可以直接读写这个结构体变量。
另外 poll 的第二个参数有两种赋值 方式,但是都和第一个参数的数组有关系:
- 使用参数 1 数组的元素个数;
- 使用参数 1 数组中存储的最后一个有效元素对应的下标值 + 1;
内核会根据第二个参数传递的值对参数 1 数组中的文件描述符进行线性遍历,这一点和 select 也是类似的。
3、epoll
(1)概述
epoll全称evenfpoll,是linux内核实现IO多路复用(IO mutilplexing)的一个实现。IO多路复用的意思是在一个操作里同时监听多个输入输出源(文件描述符),在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作,epoll是select和epoll的升级版,相较于这两个前辈,epoll改进了工作方式,因此效率更高,但epoll局限于Linux平台!
- 对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的;
- select和poll每次都是线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降;
- select和poll工作过程中存在用户空间/内核空间数据的频繁拷贝问题,每调用一次select/poll都需要拷贝两次,比如:集合拷贝从用户区拷贝到内核区,然后内核做线性检测所得结果集合,从内核区拷贝到用户区,在epoll中内核使用的共享内存(基于mmap内存映射区实现?),省去了不必要的内存拷贝;
- 程序员需要对select和poll返回的集合进行线性判断才能知道哪些文件描述符是就绪的,通过epoll可以直接得到已经就绪的文件描述符集合,无需再次检测;
- 使用epoll没有最大文件描述符的限制,仅限制于系统中进程能打开的最大文件描述符数目;
当多路复用的文件数量庞大、IO 流量频繁的时候,一般不太适合使用 select () 和 poll (),这种情况下 select () 和 poll () 表现较差,推荐使用 epoll ()。
(2)操作函数
在 epoll 中一共提供是三个 API 函数,分别处理不同的操作,函数原型如下:
#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- select/poll抵效的原因之一是“添加/维护待检测任务”和“阻塞线程/进程”这两个步骤合二为一,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket个数相对固定,并不需要每次被修改。。epoll将这两个操作分开,先用epoll_ctl( )维护等待队列,再调用epoll_wait( )阻塞进程(解耦)。
epoll_create( )函数的作用是创建一个红黑树模型的实例,用于管理待检测文件描述符的集合。
int epoll_create(int size);
函数参数 :
- size:在 Linux 内核 2.6.8 版本以后,这个参数是被忽略的,只需要指定一个大于 0 的数值就可以了。
函数返回值:
- 失败:返回 - 1
- 成功:返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的 epoll 实例了
epoll_ctl( )函数的作用是管理红黑树实例上的节点,可以进行添加、删除、修改操作。
// 联合体, 多个变量共用同一块内存
typedef union epoll_data {
void *ptr;
int fd; // 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数参数:
- epfd:epoll_create( )函数的返回值,通过这个参数找到epoll实例;
- op:这是一个枚举值,控制通过该函数执行什么操作;
EPOLL_CTL_ADD:往epoll模型中添加新的节点;
EPOLL_CTL_MOD:修改epoll模型中已经存在的节点;
EPOLL_CTL_DEL:删除epoll模型中指定的节点;
- fd:文件描述符,即要添加、修改、删除的文件描述符;
- event:epoll事件,用来修饰第三个参数对应的文件描述符,指定检测这个文件描述符的什么事件;
events:委托epoll检测的事件 EPOLLIN:读事件,接收数据,检测读缓冲区,如果有数据该文件描述符读就绪;
EPOLLOUT:写事件,发送数据,检测写缓冲区,如果可写该文件描述符就绪;
EPOLLERR:异常事件;
data:用户数据变量 这是一个联合体类型,通常情况下使用里边的fd成员,用于存储待检测的文件描述符的值,在调用epoll_wait()函数的时候这个值被传出。
- 函数返回值:失败返回-1,成功返回0;
struct eventpoll *ep = (struct eventpoll*)calloc(1, sizeof(struct eventpoll));
- eventpoll结构体:
//调用epoll_create()的时候我们会创建这个结构的对象
struct eventpoll {
ep_rb_tree rbr; //ep_rb_tree是个结构,所以rbr是结构变量,这里代表红黑树的根;
int rbcnt;
LIST_HEAD( ,epitem) rdlist; //rdlist是结构变量,这里代表双向链表的根;
/* 这个LIST_HEAD等价于下边这个
struct {
struct epitem *lh_first;
}rdlist;
*/
int rdnum; //双向链表里边的节点数量(也就是有多少个TCP连接来事件了)
int waiting;
pthread_mutex_t mtx; //rbtree update
pthread_spinlock_t lock; //rdlist update
pthread_cond_t cond; //block for event
pthread_mutex_t cdmtx; //mutex for cond
};
- rbr结构成员:代表一颗红黑树的根节点[刚开始指向空],把rbr理解成红黑树的根节点的指针
- 红黑树,用来保存 键【数字】/值【结构】,能够快速的通过你给key,把整个的键/值取出来
- rdlist结构成员:代表 一个双向链表的表头指针
- 双向链表:从头访问/遍历每个元素特别快next。
总结:创建了一个eventpoll结构对象,被系统保存起来。
- rbr成员被初始化成指向一颗红黑树的根【有了一个红黑树】
- rdlist成员被初始化成指向一个双向链表的根【有了双向链表】
epoll_wait( )用来检测创建的epoll实例中有没有就绪的文件描述符。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
函数参数:
- epfd:epoll_create( )函数的返回值,通过这个参数找到epoll实例;
- events:传出参数,这是一个结构体数组的地址,里边存储了已经就绪的文件描述符的信息;
- maxevents:修饰第二个参数,结构体数组的容量(元素个数);
- timeout:如果检测的epoll实例中没有就绪的文件描述符,该函数阻塞的时长,单位ms毫秒;
0 函数不阻塞,不管epoll实例中有没有就绪的文件描述符,函数被调用后都直接返回; 大于0 如果epoll实例中没有就绪的文件描述符,函数阻塞对应的毫秒再返回; -1 函数一直阻塞,直到epoll实例中有已就绪的文件描述符之后才解除阻塞;
- 函数返回值:
成功 等于0:函数是阻塞被强制解除了,没有检测到满足条件的文件描述符;
大于0:检测到的已就绪的文件描述符的总个数;
失败 返回-1;
(3)使用操作
在服务器使用epoll进行IO多路复用的步骤如下:
1.创建监听的套接字
int fd=socket(AF_INET,SOCK_STREAM,0);
2.设置端口复用(可选)
int opt=1;
setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
3.使用本地的IP与端口和监听的套接字进行绑定
int ret=bind(fd,(struct sockaddr*)&addr,sizeof(addr));
4.设置监听
ret=listen(fd,128);
5.创建epoll实例对象
int epfd=epoll_create(100);
6.将监听的套接字添加到epoll实例中
struct epoll_event ev;
ev.event=EPOLLIN;//检测fd读缓冲区是否有数据
ev.data.fd=fd;
int res=epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev);
7.检测添加到epoll实例中的文件描述符是否已经就绪,并将这些已经就绪的文件描述符进行处理
int num=epoll_wait(epfd,evs,size,-1);
- 如果是监听的文件描述符,和新客户端建立连接,将得到的文件描述符添加到epoll实例中;
int cfd=accept(fd,NULL,NULL);
ev.events=EPOLLIN;
ev.data.fd=cfd;
//新得到的文件描述符添加到epoll模型中,下一轮循环的时候就可以被检测了
epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&ev);
- 如果是通信的文件描述符,和对应的客户端通信,如果连接已经断开,将该文件描述符从epoll实例中删除;
int len=recv(cfd,buf,sizeof(buf),0);
if(len==0)
{
//将这个文件描述符符从epoll模型中删除
epoll_ctl(epfd,EPOLL_CTL_DEL,cfd,NULL);
close(cfd);
}
else if(len>0)
{
send(cfd,buf,len,0);
}
#include <iostream>
#include <unistd.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/epoll.h>
#include <assert.h>
int main(int argc,char* argv[])
{
//创建监听的套接字
int lfd=socket(AF_INET,SOCK_STREAM,0);
assert(lfd!=-1);
//设置IP、端口
struct sockaddr_in serv_adddr;
serv_adddr.sin_family=AF_INET;
serv_adddr.sin_port=htons(9999);
serv_adddr.sin_addr.s_addr=htonl(INADDR_ANY);
//设置端口复用
int optval=1;
setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,&optval,sizeof(optval));
//绑定到指定的监听fd
int ret=bind(lfd,(struct sockaddr*)&serv_adddr,sizeof(serv_adddr));
assert(ret!=-1);
//监听
ret=listen(lfd,128);
assert(ret!=-1);
//创建epoll实例
int epfd=epoll_create(1);
assert(epfd!=-1);
struct epoll_event ev;
ev.events=EPOLLIN;
ev.data.fd=lfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&ev);//ev传入做了拷贝
struct epoll_event evs[1024];//用于epoll_wait传出
int size=sizeof(evs)/sizeof(evs[0]);
while(true)
{
int num=epoll_wait(epfd,evs,size,-1);//阻塞
printf("num = %d\n",num);
for(int i=0;i<num;++i)
{
int fd=evs[i].data.fd;
if(fd==lfd)
{
int cfd=accept(fd,NULL,NULL);
//struct epoll_event ev;//ev传入epoll做了拷贝,可以省略,共用外面定义的
ev.events=EPOLLIN;
ev.data.fd=cfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&ev);
}
else
{
char buf[1024];
int len=recv(fd,buf,sizeof(buf),0);
if(len==-1)
{
perror("recv error");
exit(1);
}
else if(len==0)
{
printf("客户端已经断开了连接...\n");
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);//先删除再关闭,否则返回-1
close(fd);
break;
}
printf("read buf = %s\n",buf);
ret=send(fd,buf,strlen(buf)+1,0);
if(ret==-1)
{
perror("send error");
exit(1);
}
}
}
}
close(lfd);
return 0;
}
当在服务器端循环调用epoll_wait( )的时候,就会得到一个就绪列表,并通过该函数的第二个参数传出,
struct epoll_event evs[1024];
int num = epoll_wait(epfd, evs, size, -1);
每当epoll_wait( )函数返回一次,在evs中最多可以存储size个已经就绪的文件描述符信息(linux内核链表的形式) ,但是在这个数组中实际存储的有效元素个数为num个,如果在这个epoll实例的红黑树中已经有就绪的文件描述符很多,并且evs数组元素无法将这些信息全部传出,那么这些信息会在下一次epoll_wait( )函数返回的时候被传出!
通过evs数组被传出的每一个有效元素里边都包含了已经就绪的文件描述符的相关信息,这些消息并不是凭空而来的,这取决于我们往epoll实例中添加节点的时候,往节点添加了哪些数据:
struct epoll_event ev;
// 节点初始化
ev.events = EPOLLIN;
ev.data.fd = lfd; // 使用了联合体中 fd 成员
// 添加待检测节点到epoll实例中
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
在添加节点的时候,需要对这个 struct epoll_event 类型的节点进行初始化,当这个节点对应的文件描述符变为已就绪状态,这些被传入的初始化信息就会被原样传出,这个对应关系必须要搞清楚。
(4)epoll的工作方式
1.水平模式
水平模式可以简称为LT模式,LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核通过使用者哪些文件描述符已经就绪,之后就可以对这些已经就绪的文件描述符进行IO操作了。如果我们不作任何操作,内核还是会继续通知使用者。那么内核如果判断是就绪事件了,如果读缓冲区有数据或写缓冲区不满,即为就绪事件。
水平模式的特点:
读事件:如果文件描述符对应的读缓冲区还有数据,读事件就会被触发,epoll_wait( )解除阻塞 | 1、当读事件被触发,epoll_wait( )解除阻塞,之后就可以接受数据了; 2、如果接收数据的buf很小,不能全部将缓冲区数据读出,那么读事件会继续被触发,直到数据被全部读出,如果接受数据的buf相对较大,读数据的效率也会相对较高(减少了读数据的次数); 3、因为读数据是被动的,必须要通过读事件才能知道有数据到达了,因此对于读事件的检测是必须的! |
写事件:如果文件描述符对应的写缓冲区可写,写事件就会被触发,epoll_wait( )解除阻塞 | 1、当写事件被触发,epoll_wait( )解除阻塞,之后就可以将数据写入到写缓冲区了; 2、写事件触发发生在写事件之前而不是之后,被写入到写缓冲区的数据是内核自动发送出去的; 3、如果写缓冲区没有写满,写事件会一直被触发; 4、因为写数据是主动的,并且写缓冲区一般情况下都是可写的(缓冲区不满),因此对于写事件的检测不是必须的; |
服务端程序:
#include <iostream>
#include <unistd.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/epoll.h>
#include <assert.h>
int main(int argc,char* argv[])
{
//创建监听的套接字
int lfd=socket(AF_INET,SOCK_STREAM,0);
assert(lfd!=-1);
//设置IP、端口
struct sockaddr_in serv_adddr;
serv_adddr.sin_family=AF_INET;
serv_adddr.sin_port=htons(9999);
serv_adddr.sin_addr.s_addr=htonl(INADDR_ANY);
//设置端口复用
int optval=1;
setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,&optval,sizeof(optval));
//绑定到指定的监听fd
int ret=bind(lfd,(struct sockaddr*)&serv_adddr,sizeof(serv_adddr));
assert(ret!=-1);
//监听
ret=listen(lfd,128);
assert(ret!=-1);
//创建epoll实例
int epfd=epoll_create(1);
assert(epfd!=-1);
struct epoll_event ev;
ev.events=EPOLLIN;
ev.data.fd=lfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&ev);//ev传入做了拷贝
struct epoll_event evs[1024];//用于epoll_wait传出
int size=sizeof(evs)/sizeof(evs[0]);
while(true)
{
int num=epoll_wait(epfd,evs,size,-1);//阻塞
printf("num = %d\n",num);
for(int i=0;i<num;++i)
{
int fd=evs[i].data.fd;
if(fd==lfd)
{
int cfd=accept(fd,NULL,NULL);
//struct epoll_event ev;//ev传入epoll做了拷贝,可以省略,共用外面定义的
ev.events=EPOLLIN;
ev.data.fd=cfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&ev);
}
else
{
char buf[5];
int len=recv(fd,buf,sizeof(buf),0);
if(len==-1)
{
perror("recv error");
exit(1);
}
else if(len==0)
{
printf("客户端已经断开了连接...\n");
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);//先删除再关闭,否则返回-1
close(fd);
break;
}
printf("read buf = %s\n",buf);
ret=send(fd,buf,strlen(buf)+1,0);
if(ret==-1)
{
perror("send error");
exit(1);
}
}
}
}
close(lfd);
return 0;
}
客户端程序:
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.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; // ipv4
addr.sin_port = htons(9999); // 服务器监听的端口, 字节序应该是网络字节序
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);
}
// 通信
while(1)
{
// 读数据
char recvBuf[1024]={0};
// 写数据
// sprintf(recvBuf, "data: %d\n", i++);
fgets(recvBuf, sizeof(recvBuf), stdin);
write(fd, recvBuf, strlen(recvBuf)+1);//从终端读入输入
// 如果客户端没有发送数据, 默认阻塞
int len=read(fd, recvBuf, sizeof(recvBuf));
if(len==0)
{
printf("服务器已经断开连接...\n");
break;
}
else if(len==-1)
{
perror("read error");
exit(1);
}
printf("recv buf: %s\n", recvBuf);
sleep(1);
}
// 释放资源
close(fd);
return 0;
}
2.边缘模式
边缘模式可以简称为ET模式,ET(edge-triggered)高速工作方式,只支持no-block socket。在这种模式下,当文件描述符从未就绪变成就绪时,内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知(only once)。如果我们对这个文件描述符做IO操作,从而导致它再次变成未就绪,当这个未就绪的文件描述符再次变成就绪状态(客户端再次发送数据就会变成就绪了),内核会再次进行通知,并且还是只通知一次。ET模式下很大程度下减少了epoll事件被重复触发的次数,因此效率要比LT模式高!
边缘模式的特点:
读事件:当读缓冲区有新的数据进入,读事件被触发一次,没有新数据不会触发该事件 | 1、如果有新数据进入到读缓冲区,读事件被触发,epoll_wait( )解除阻塞; 2、读事件被触发,可以通过调用read( )/recv( )函数将缓冲区数据读出: (1)如果数据没有被全部读走,并且没有新的数据进入,读事件不会再次触发,只通知一次; (2)如果数据被全部读走或者只读一部分,此时有新的数据进入,读事件被触发,并且只通知一次; (3)当缓冲区有数据可读,且对应的文件描述符行 EPOLL_CTL_MOD 修改 EPOLLIN 事件时,只会通知一次; |
写事件:当写缓冲区状态可写,写事件只会触发一次 | 1、如果写缓冲区被检测到可写,写事件被触发,epoll_wait( )解除阻塞; 2、写事件触发就可以通过调用write( )/send( )函数,将数据写入到写缓冲区中 (1)写缓冲区从不满到被写满,期间写事件只会被触发一次; (2)写缓冲区从满到不满,状态变为可写,写事件只会触发一次; (3)当有旧数据被内核发送走,即缓冲区中的内容变少的时候,只会触发一次; (4)当缓冲区有空间可写,且对应的文件描述符进行 EPOLL_CTL_MOD 修改 EPOLLOUT 事件时; |
综上所述:epoll的边缘模式下epoll_wait( )检查到文件描述符有新的事件才会通知,如果不是新的事件就不会再通知了,通知的次数比水平模式少,效率比水平模式要高。
3.ET模式的设置
边缘模式不是默认的epoll模式,需要额外进行设置。epoll设置变韵模式是非常简单的,epoll管理的红黑树实例中每个节点都是struct epoll_event类型,只需要将EPOLLET添加到结构体的events成员中即可:
struct epoll_event ev;
ev.events=EPOLLIN|EPOLLET;//设置边缘模式
ET模式下,EPOLLOUT触发条件有:
- 缓冲区满-->缓冲区非满;
- 同时监听EPOLLOUT和EPOLLIN事件 时,当有IN 事件发生,都会顺带一个OUT事件;
- 一个客户端connect过来,accept成功后会触发一次OUT事件。
其中2最令人费解,内核代码这块有注释,说是一般有IN 时候都能OUT,就顺带一个,多给了个事件。
以上案例,当只监听IN事件,读完数据后改为监听OUT事件,有时候会发现触发OUT事件并不方便,想要强制触发,可以重新设置一次要监听的events,带上EPOLLOUT即可。
ET模式下的accept问题
请思考以下一种场景:在某一时刻,有多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。在这种情形下,我们应该如何有效的处理呢?
- 解决办法是用 while 循环调用 accept,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢? accept 返回 -1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完。
一道腾讯后台开发的面试题
使用Linux epoll模型,水平触发模式;当socket可写时,会不停的触发socket可写的事件,如何处理?
- 第一种最普遍的方式:
- 需要向socket写数据的时候才把socket加入epoll,等待可写事件。
- 接受到可写事件后,调用write或者send发送数据。
- 当所有数据都写完后,把socket移出epoll。
这种方式的缺点是,即使发送很少的数据,也要把socket加入epoll,写完后在移出epoll,有一定操作代价。
- 一种改进的方式:
- 开始不把socket加入epoll,需要向socket写数据的时候,直接调用write或者send发送数据。如果返回EAGAIN,把socket加入epoll,在epoll的驱动下写数据,全部数据发送完毕后,再移出epoll。
这种方式的优点是:数据不多的时候可以避免epoll的事件处理,提高效率。
4.设置非阻塞
对于写事件的触发情况一般情况下不需要进行检查的,因为写缓冲区大部分情况下都是由足够的空间可以进行数据的写入。对于读事件的触发就必须要检查了,因为服务器也不知道客户端什么时候发送数据,如果使用epoll的边缘模式进行读事件的检查,有新数据到达就只会通知一次,那么必须保证得到通知后将数据全部从读缓冲区中读出。那么,应该如何读这些数据呢?
方式1:准备一块特别大的内存,用于存储从读缓冲区中读出的数据,但是这种方式有很大的弊端:
- 内存的大小没办法界定,太大浪费内存,太小又不够用;
- 系统能够分配的最大堆内存也是有上限的,栈内存就更不必说了;
方式2:循环接受数据
int len=0;
while(len=recv(cfd,buf,sizeof(buf),0))>0)
{
.....
}
- 这样也是有弊端的,因为套接字操作默认是阻塞的,当读缓冲区数据被读完之后,读操作就阻塞了也就是调用的read( )/recv( )函数被阻塞了,当前进程/线程被阻塞之后就无法处理其他操作了。
要解决这个问题就需要将套接字默认的阻塞模式方式改为非阻塞,需要使用fctl( )函数进行:
//设置完成之后,读写都变成了非阻塞模式
int flag=fcntl(cfd,F_GETFL);
flag|=O_NONBLOCK;
fcntl(cfd,F_SETfl,flag);
- 通过上述的分析就可以得出一个结论了:epoll在边缘触发模式下,必须要将套接字(文件描述符)设置为非阻塞模式,但是,这样就会引发另外一个bug,在非阻塞模式下,循环地将读缓冲区数据读到内存中,当缓冲区数据被读完了,调用的read( )/recv( )函数还会继续从缓冲区中读数据,此时函数调用就失效了,返回-1,对应的全局变量errno值为EAGAIN或者EWOULDBLOCK,如果打印错误信息会得到如下信息:Resource temporarily unavailable。
// 非阻塞模式下recv() / read()函数返回值 len == -1
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == -1)
{
if(errno == EAGAIN)
{
printf("数据读完了...\n");
}
else
{
perror("recv");
exit(0);
}
}
修改完的服务器程序:
#include <iostream>
#include <unistd.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/epoll.h>
#include <assert.h>
#include <fcntl.h>
int main(int argc,char* argv[])
{
//创建监听的套接字
int lfd=socket(AF_INET,SOCK_STREAM,0);
assert(lfd!=-1);
//设置IP、端口
struct sockaddr_in serv_adddr;
serv_adddr.sin_family=AF_INET;
serv_adddr.sin_port=htons(9999);
serv_adddr.sin_addr.s_addr=htonl(INADDR_ANY);
//设置端口复用
int optval=1;
setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,&optval,sizeof(optval));
//绑定到指定的监听fd
int ret=bind(lfd,(struct sockaddr*)&serv_adddr,sizeof(serv_adddr));
assert(ret!=-1);
//监听
ret=listen(lfd,128);
assert(ret!=-1);
//创建epoll实例
int epfd=epoll_create(1);
assert(epfd!=-1);
struct epoll_event ev;
ev.events=EPOLLIN;
ev.data.fd=lfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&ev);//ev传入做了拷贝
struct epoll_event evs[1024];//用于epoll_wait传出
int size=sizeof(evs)/sizeof(evs[0]);
while(true)
{
int num=epoll_wait(epfd,evs,size,-1);//阻塞
printf("num = %d\n",num);
for(int i=0;i<num;++i)
{
int fd=evs[i].data.fd;
if(fd==lfd)
{
int cfd=accept(fd,NULL,NULL);
//设置非阻塞属性
int flag=fcntl(cfd,F_GETFL);
flag|=O_NONBLOCK;
fcntl(cfd,F_SETFL,flag);
//struct epoll_event ev;//ev传入epoll做了拷贝,可以省略,共用外面定义的
ev.events=EPOLLIN|EPOLLET;//边缘触发
ev.data.fd=cfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&ev);
}
else
{
char buf[5];
while(true)
{
int len=recv(fd,buf,sizeof(buf),0);
if(len==-1)
{
if(errno==EAGAIN)
{
printf("数据已经接受完毕...\n");
break;
}
perror("recv error");
exit(1);
}
else if(len==0)
{
printf("客户端已经断开了连接...\n");
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);//先删除再关闭,否则返回-1
close(fd);
break;
}
printf("read buf = %s\n",buf);//write(STDOUT_FILENO,buf,len);
ret=send(fd,buf,strlen(buf)+1,0);
if(ret==-1)
{
perror("send error");
exit(1);
}
}
}
}
}
close(lfd);
return 0;
}
七、基于UDP的套接字通信
udp是面向无连接,不安全的,报文式传输层协议,udp的通信过程默认也是阻塞的。
UDP通信不需要建立连接,
- UDP通信不需要建立连接,因此不需要进行connect( )操作;
- UDP通信过程中,每次都需要指定数据接收端的IP和端口,和发快递差不多;
- UDP不对收到的数据进行排序,在UDP报文的首部中并没有关于数据顺序的信息;
- UDP对接收到的数据报不回复确认信息,发送端不知道数据是否被正确接收,也不会重发数据;
- 如果发生了数据丢失,不存在丢一半的情况,如果丢当前这个数据包就全部丢失了;
1、通信流程
使用UDP进行通信,服务器和客户端的处理步骤比TCP要简单很多,并且两端是对等的(通信的处理流程几乎是一样的),也就是说并没有严格意义上的客户端和服务端。UDP的通信流程如下:
(1)服务器端
假设服务器端是接收数据的角色:
- 创建通信的套接字
//第二个参数是SOCK_DGRAM,第三个参数0表示使用报文协议中的udp
inf fd=socket(AF_INET,SOCK_DGRAM,0);
- 使用通信的套接字和本地的IP和端口绑定,IP和端口需要转换为大端(可选)
bind( );
- 通信
//接收数据
recvfrom( );
//发送数据
sendto( );
- 关闭套接字(文件描述符)
close(fd);
(2)客户端
- 创建通信的套接字
//第二个参数是SOCK_DGRAM,第三个参数0表示使用报文协议中的udp
int fd=socket(AF_INET,SOCK_DGRAM,0);
- 通信
//接收数据
recvfrom( );
//发送数据
sendto( );
- 关闭套接字(文件描述符)
close(fd);
在UDP通信过程中,哪一端接收数据的角色,那么这个接收端就必须绑定一个固定的端口,如果某一个端不需要接收数据,这个绑定操作就可以省略不写了,通信的套接字会自动绑定一个随机端口。
2、通信函数
基于UDP进行套接字通信,创建套接字的函数还是socket( ),但是第二个参数的值需要指定为SOCK_DGRAM,通过该参数指定要创建一个基于报文式传输协议的套接字,最后一个参数指定为0表示使用报文式协议中的UDP协议。
int sockcet(int domain,int type,int protocol);
参数:
- domain:地址族协议,AF_INET -> IPv4,AF_INET6-> IPv6;
- type:使用的传输协议类型,报式传输协议需要指定为 SOCK_DGRAM;
- protocol:指定为 0,表示使用的默认报式传输协议为 UDP;
返回值:
- 函数调用成功返回一个可用的文件描述符(大于 0),调用失败返回 - 1
另外进行UDP通信,通信过程虽然默认还是函数阻塞
//接受数据,如果没有数据,该函数阻塞
ssize_t recfrom(int sockfd,void* buf,size_t len,int flags,struct sockaddr* src_addr,
socklen_t* addrlen);
参数:
- sockfd:基于udp的通信的文件描述符;
- buf:指针指向的地址用来存储接收的数据;
- len:buf指针指向的内存的容量,最多能存储多少个字节;
- flags:设置套接字属性,一般使用默认属性,指定为0即可;
- src_addr:发送数据的一端的地址信息,和端口都存储在这里边,是大端存储的;
如果这个参数中的信息对当前业务处理没有用处,可以指定为 NULL, 不保存这些信息
- addrlen:类似于 accept () 函数的最后一个参数,是一个传入传出参数;
传入的是 src_addr 参数指向的内存的大小,传出的也是这块内存的大小; 如果 src_addr 参数指定为 NULL,这个参数也指定为 NULL 即可;
- 返回值:成功返回接收的字节数,失败返回 - 1
// 发送数据函数
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
- sockfd: 基于 udp 的通信的文件描述符;
- buf: 这个指针指向的内存中存储了要发送的数据;
- len: 要发送的数据的实际长度;
- flags: 设置套接字属性,一般使用默认属性,指定为 0 即可;
- dest_addr: 接收数据的一端对应的地址信息,大端的 IP 和端口;
- addrlen: 参数 dest_addr 指向的内存大小;
3、通信的代码
在UDP通信过程中,服务器和客户端都可以作为数据的发送端和数据接收端,假设服务器端是被动接收数据的,客户端是主动发送数据,那么在服务器端就必须绑定固定的端口了。
(1)服务器端
#include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
int main(int argc,char* argv[])
{
//1.创建通信的套接字
int fd=socket(AF_INET,SOCK_DGRAM,0);
if(fd==-1)
{
perror("socket");
exit(0);
}
//2.通信的套接字和本地的IP与端口绑定
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port=htons(9999);//大端
addr.sin_addr.s_addr=INADDR_ANY;//0.0.0.0
int ret=bind(fd,(struct sockaddr*)&addr,sizeof(addr));
if(ret==-1)
{
perror("bind");
exit(0);
}
char buf[1024];
char ipbuf[64];
struct sockaddr_in cliaddr;
socklen_t len=sizeof(cliaddr);
//3.通信
while(true)
{
//接受数据
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;
}
作为数据接收端,服务器端通过bind( )函数绑定了固定的端口,然后基于这个固定的端口通过recvfrom( )函数接收客户端发送的数据,同时通过这个函数也得到了数据发送端的地址信息(recvfrom的第三个参数),这样就可以通过得到的地址信息通过sendto( )函数给客户端回复数据了。
(2)客户端
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main(int argc,char* argv[])
{
//1.创建通信的套接字
int fd=socket(AF_INET,SOCK_DGRAM,0);
if(fd==-1)
{
perror("socket");
exit(1);
}
//初始化服务器地址信息
struct sockaddr_in seraddr;
seraddr.sin_family=AF_INET;
seraddr.sin_port=htons(9999);
inet_pton(AF_INET,"192.168.207.129",&seraddr.sin_addr.s_addr);
char buf[1024];
char ipbuf[64];
struct sockaddr_in cliaddr;
socklen_t len=sizeof(cliaddr);
int num=0;
while(true)
{
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);
}
close(fd);
return 0;
}
作为数据发送端,客户端不需要绑定固定端口,客户端使用的端口是随机绑定的(也可以调用bind( )函数手动进行绑定)。客户端在接收服务器端回复的数据的时候需要调用recvfrom( )函数,因为客户端在发送数据之前就已经知道服务器绑定的固定的IP和端口信息了,所以接收服务器数据的时候就不需要保存服务器端的地址信息了,直接将函数的最后两个参数指定为NULL即可。
情景:使用ET多线程模式,一个socket上的同一个事件可能被触发多次(将TCP缓冲区设置的小一点,发送一个较大的数据,接受的数据会多次填满缓冲区,导致会触发多次),当某个线程在读取完某个socket的数据后开始处理这些数据,但是此时又来了新的数据,此时另外一个线程被唤醒来读取这些新的数据,于是出现了两个或多个线程同时操作一个socket的问题。LT模式下更容易出现。但我们期望一个socket在任意时刻只能被一个socket处理。此时可以使用EPOLLONESHOT 事件来解决。
- 对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非使用epoll_ctl重置该文件描述符上EPOLLONESHOT事件。其实原理就是每次触发之后,就将事件注册的socket清除掉,也就不会再被追踪了。
使用:
- 注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕(也就是触发errno=EAGAIN),该线程就应该立即重置这个socket的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN/EPOLLOUT/EPOLLERR等事件能被触发,进而让其他线程有机会继续处理这个socket。
服务端代码:
#include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <string.h>
#include <pthread.h>
#include <sys/epoll.h>
#include <sys/types.h>
#include <fcntl.h>
#include <assert.h>
#define MAX 1024
#define BUF_SIZE 200
struct SockInfo{
int epfd;
int cfd;
};
class Server
{
private:
//设置fd文件描述符非阻塞
int setnonblocking(int fd)
{
int old_flag=fcntl(fd,F_GETFL);
int new_flag=old_flag|O_NONBLOCK;
fcntl(fd,F_SETFL,new_flag);
return old_flag;
}
//向epoll红黑树注册文件描述符,并判断是否需要添加EPOLLONESHOT
void addfd(int epfd,int fd,bool oneshot)
{
struct epoll_event ev;
ev.data.fd=fd;
ev.events=EPOLLIN|EPOLLET;
if(oneshot)//若不是服务端监听socket
{
ev.events|=EPOLLONESHOT;
}
epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev);
setnonblocking(fd);
}
//工作线程
static void* working(void* arg)
{
SockInfo* info=static_cast<SockInfo*>(arg);
int fd=info->cfd,epfd=info->epfd;
printf("new thread %d start ,receive data from %d\n",pthread_self(),fd);
char buf[BUF_SIZE];
//接收数据,直到收到EAGAIN错误
while(true)
{
memset(buf,0,sizeof(buf));
int ret=recv(fd,buf,BUF_SIZE-1,0);
if(ret==0)
{
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);
close(fd);
printf("client closed the connection\n");
break;
}
else if(ret==-1)
{
if(errno==EAGAIN||errno==EWOULDBLOCK)
{
reset_sockfd(epfd,fd);
printf("read later\n");
break;
}
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);
close(fd);
perror("recv error");
break;
}
printf("Client say:%s\n",buf);
send(fd,buf,sizeof(buf),0);
sleep(5);
}
printf("thread %ld end, receiving from %d\n",pthread_self(),fd);
pthread_exit(NULL);
return NULL;
}
//重置EPOLLONESHOT事件
static void reset_sockfd(int epfd,int fd)
{
struct epoll_event ev;
ev.data.fd=fd;
ev.events=EPOLLIN|EPOLLET|EPOLLONESHOT;
epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev);
}
public:
Server(const char* Ip,int Port):ip(Ip),port(Port){}
~Server(){close(lfd);}
int Bind()
{
lfd=socket(AF_INET,SOCK_STREAM,0);
if(lfd==-1)
{
perror("socket");
return -1;
}
struct sockaddr_in addr;
bzero(&addr,sizeof(addr));
addr.sin_family=AF_INET;
addr.sin_port=htons(port);
inet_pton(AF_INET,ip,&addr.sin_addr.s_addr);
int ret=bind(lfd,(struct sockaddr*)&addr,sizeof(addr));
if(ret==-1)
{
perror("bind");
return -1;
}
}
int Listen()
{
int ret=listen(lfd,128);
if(ret==-1)
{
perror("listen");
return -1;
}
//设置ip、port复用
int on=1;
setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,(char*)&on,sizeof(on));
setsockopt(lfd,SOL_SOCKET,SO_REUSEPORT,(char*)&on,sizeof(on));
}
void Communication()
{
struct epoll_event evs[MAX];
int epfd=epoll_create(5);
if(epfd==-1)
{
perror("epoll_create");
return;
}
//监听的socket不能注册EPOLLONESHOT事件
addfd(epfd,lfd,false);
while(true)
{
int ret=epoll_wait(epfd,evs,MAX,-1);//阻塞
if(ret==-1)
{
if(errno==EINTR) continue;//信号优先级高中断
perror("epoll_wait");
break;
}
for(int i=0;i<ret;++i)
{
if(lfd==evs[i].data.fd){
struct sockaddr_in cliaddr;
socklen_t len=sizeof(cliaddr);
int cfd=accept(lfd,(struct sockaddr*)&cliaddr,&len);
//更改缓冲区大小,模拟ET模式下下一个事件触发多次
int buf=5;
setsockopt(cfd,SOL_SOCKET,SO_RCVBUF,&buf,sizeof(buf));
addfd(epfd,cfd,true);
}
else if(evs[i].events&&EPOLLIN)
{
pthread_t tid;
SockInfo info;
info.epfd=epfd;
info.cfd=evs[i].data.fd;
pthread_create(&tid,NULL,working,&info);
pthread_detach(tid);
}
else
{
printf("something else happen!");
}
}
}
}
private:
const char* ip;
const int port;
int lfd;//监听的socket
};
int main(int argc,char* argv[])
{
const char* ip="192.168.207.129";
const int port=9999;
Server server(ip,port);
int ret=server.Bind();
assert(ret!=-1);
ret=server.Listen();
assert(ret!=-1);
server.Communication();
return 0;
}
情景:非阻塞方式connect的作用?
TCP连接的建立涉及到一个三次握手的过程,且SOCKET中connect函数需要一直等到客户接收到对于自己的SYN的ACK为止才返回,这意味着每个connect函数总会阻塞其调用进程至少一个到服务器的RTT时间,而RTT波动范围很大,从局域网的几个毫秒到几百个毫秒甚至广域网上的几秒。这段时间内,我们可以执行其他处理工作,以便做到并行。在此,需要用到非阻塞connect。多数实现中,connect的超时时间在75秒到几分钟之间。有时程序希望在等待一定时间内结束,使用非阻塞connect可以防止阻塞75秒,在多线程网络编程中,尤其必要。 例如有一个通过建立线程与其他主机进行socket通信的应用程序,如果建立的线程使用阻塞connect与远程通信,当有几百个线程并发的时候,由于网络延迟而全部阻塞,阻塞的线程不会释放系统的资源,同一时刻阻塞线程超过一定数量时候,系统就不再允许建立新的线程(每个进程由于进程空间的原因能产生的线程有限),如果使用非阻塞的connect,连接失败使用select等待很短时间,如果还没有连接后,线程立刻结束释放资源,防止大量线程阻塞而使程序崩溃。
(1) fcntl函数
fcntl函数可执行各种描述符的控制操作,对于socket描述符,常用应用是将其设置为阻塞式IO,
int flags;
if((flags=fcntl(fd,F_GETFL))<0) //获取当前的flags标志
err_sys(“F_GETFL error!”);
flags|=O_NONBLOCK; //修改非阻塞标志位
if(fcntl(fd,F_SETFL,flags)<0)
err_sys(“F_SETFL error!”);
(2) connect函数
对于阻塞式套接字,调用connect函数将激发TCP的三次握手过程,而且仅在连接建立成功或者出错时才返回;对于非阻塞式套接字,如果调用connect函数会之间返回-1(表示出错),且错误为EINPROGRESS,表示连接建立,建立启动但是尚未完成;如果返回0,则表示连接已经建立,这通常是在服务器和客户在同一台主机上时发生。
if(connect(fd, (struct sockaddr*)&sa,sizeof(sa))==-1)
if (errno!=EINPROGRESS) {
return -1;
}
if(n==0)
goto done;
(3) select函数
select是一种IO多路复用机制,它允许进程指示内核等待多个事件的任何一个发生,并且在有一个或者多个事件发生或者经历一段指定的时间后才唤醒它。
connect本身并不具有设置超时功能,如果想对套接字的IO操作设置超时,可使用select函数,
fd_set wfd;
FD_ZERO(&wfd);
FD_SET(fd,&wfd);
if(select(FD_SETSIZE,NULL,&wfd,NULL,toptr)==-1){
__redisSetError(c,REDIS_ERR_IO,
sdscatprintf(sdsempty(), "select(2): %s", strerror(errno)));
close(fd);
return REDIS_ERR;
}
对于select和非阻塞connect,注意两点:
[1] 当连接成功建立时,描述符变成可写;
[2] 当连接建立遇到错误时,描述符变为即可读,也可写,遇到这种情况,可调用getsockopt函数。
(4) getsockopt函数
可获取影响套接字的选项,比如SOCKET的出错信息:
err=0;
errlen=sizeof(err);
if(getsockopt(fd,SOL_SOCKET,SO_ERROR,&err,&errlen)==-1){
sprintf("getsockopt(SO_ERROR): %s", strerror(errno)));
close(fd);
return ERR;
}
if(err){
errno=err;
close(fd);
return ERR;
}
总结,设置非阻塞connect的步骤如下:
- 创建socket,并利用fcntl将其设置为非阻塞
- 调用connect函数,如果返回0,则连接建立;如果返回-1,检查errno ,如果值为 EINPROGRESS,则连接正在建立。
- 为了控制连接建立时间,将该socket描述符加入到select的可写集合中,采用select函数设定超时。
- 如果规定时间内成功建立,则描述符变为可写;否则,采用getsockopt函数捕获错误信息
- 恢复套接字的文件状态并返回。
应用实例
(1)实例一
《unix网络编程》有一个Netscape 的web客户端的程序实例,客户端先建立一个与某个web服务器的HTTP连接,然后获取该网站的主页。该主页往往含有多个对于其他网页的引用,客户可以使用非阻塞connect同时获取多个网页,以此取代每次只获取一个网页的串行获取手段。
(2)实例二
Redis客户端CLI (command line interface),位于源代码的src/deps/hiredis下面。实际上,不仅是Redis客户端,其他类似的client/server架构中,client均可采用非阻塞式connect实现。