Linux网络编程学习笔记(TCP)

文章目录

  • 1 字节序
    • 1.1 定义
    • 1.2 字节序转换函数
  • 2 Socket地址
    • 2.1 通用socket地址(实际开发不使用)
    • 2.2 专用socket地址
    • 2 IP地址转换
  • 3 TCP通信流程
    • 3.1 服务器端 (被动接受连接的角色)
    • 3.2 客户端
  • 4 套接字函数
    • 4.1 头文件
    • 4.2 创建套接字sockfd
    • 4.3 绑定本地的IP和端口(服务器端)
    • 4.4 监听socket的连接(服务器端)
    • 4.5 接收客户端连接(服务器端,阻塞)
    • 4.6 客户端连接服务器(客户端)
    • 4.7 读数据和写数据
  • 5 TCP通信并发
    • 5.1 进程并发
    • 5.2 线程并发
  • 6 通信半关闭以及端口复用
    • 6.1 半关闭
    • 6.2 端口复用(设置套接字sockfd属性)
  • 7 I/O多路复用(I/O多路转接)
    • 7.1 背景
    • 7.2 select函数介绍
      • 使用流程
      • 函数
      • 对select监听列表的操作
      • 缺点
    • 7.3 poll函数介绍
      • 结构体
      • 函数
      • 对poll监听列表的操作
      • 缺点
    • 7.4 epoll函数介绍
      • 使用流程
      • 检测事件的结构体
      • 函数
    • 7.5 epoll工作模式
      • LT触发(水平触发)--默认方式
      • ET触发(边沿触发)
      • 两者的比较
      • 阻塞和非阻塞
      • 情景理解两个模式
  • 8 IO模型
    • 8.1 阻塞与非阻塞,同步与异步概念
    • 8.2 Linux上五种IO模型(各个函数的使用背景)
      • 阻塞(阻不阻塞并不是跟函数有关,主要是文件描述符)
      • 非阻塞
      • IO复用
      • 信号驱动
      • 异步
  • 9 服务器编程基本框架和两种高效的事件处理模式
    • 9.1 服务器的基本框架
    • 9.2 两种高效的事件处理模式
      • Reactor模式
      • Proactor模式
    • 9.3 服务器流程实现(同步模拟Proactor)
  • 10 线程池
    • 10.1 概念
    • 10.2 线程池的数量
    • 10.3 线程池的特点
    • 10.4 线程池的实现
  • 11 引用

1 字节序

前面的进程间通信都是在一个电脑上进行的,但是网络是跨电脑进行的,这就可能因为电脑底层的不同而导致通信出了问题,因此需要消除这些不同。

1.1 定义

在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编码/译码从而导致通信失败。

字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序

字节序分为大端字节序(Big-Endian)和小端字节序(Little-Endian)。例如对于十六进制0x01020304,其共四个字节

  • 大端字节序是指一个整数的最高位字节存储在内存的低地址处,低位字节存储在内存的高地址处。例子中,假设内存地址增长方向是从左到右,那么存储的值就是0x 01 02 03 04
  • 小端字节序则是指整数的高位字节工储在内存的高地址处,而低位字节则存储在内存的低地址处。例子中,假设内存地址增长方向是从左到右,那么存储的值就是0x 04 03 02 01(大部分计算机采用小端字节序)

1.2 字节序转换函数

为了解决发收双方可能存在的字节序问题,因此引入网络字节顺序的概念:

网络宁节顺序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用大端排序方式。

因此 ,网络通信时,需要将主机字节序转换成网络字节序(大端),另外一段获取到数据以后根据情况将网络字节序转换成主机字节序。

    // 转换端口(16位2个字节)
    uint16_t htons(uint16_t hostshort);		// 主机字节序 - 网络字节序(host->network-short)
    uint16_t ntohs(uint16_t netshort);		// 网络字节序 - 主机字节序

    // 转IP(32位4个字节)
    uint32_t htonl(uint32_t hostlong);		// 主机字节序 - 网络字节序(host->network-long)
    uint32_t ntohl(uint32_t netlong);		// 网络字节序 - 主机字节序

2 Socket地址

socket地址其实是一个结构体,封装端口号和IP等信息。后面的socket相关的api中需要使用到这个socket地址。

2.1 通用socket地址(实际开发不使用)

#include 
struct sockaddr_storage
{
	//16位的地址类型
	sa_family_t sa_family;
	unsigned long int __ss_align;//对齐用的
	//这个就是下面所说的sa_data(14字节的地址数据)
	char __ss_padding[ 128 - sizeof(__ss_align) ];
};
typedef unsigned short int sa_family_t;

  • sa_family成员表示地址族的类型(sa_family_t)。地址族类型通常与协议族类型对应。常见的协议族(protocol family,也称 domain)和对应的地址族入下所示:(两者在实际中,经常混用)
    Linux网络编程学习笔记(TCP)_第1张图片
  • sa_data成员用于存放 socket 地址值。但是,不同的协议族的地址值具有不同的含义和长度,如下所示:
    Linux网络编程学习笔记(TCP)_第2张图片

2.2 专用socket地址

很多网络编程函数诞生早于 IPv4 协议,那时候都使用的是 struct sockaddr 结构体,为了向前兼容,现sockaddr 退化成了(void *)的作用,传递一个地址给函数,至于这个函数是 sockaddr_in 还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型

 // TCP/IP 协议族有 sockaddr_in 和 sockaddr_in6 两个专用的 socket 地址结构体,它们分别用于 IPv4 和 IPv6:
#include 
struct sockaddr_in
{
	sa_family_t sin_family; /* __SOCKADDR_COMMON(sin_) */
	in_port_t sin_port; /* Port number. */
	struct in_addr sin_addr; /* Internet address. */
	/* Pad to size of `struct sockaddr'. */
	unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof (in_port_t) - sizeof (struct in_addr)];
};

struct in_addr
{
	in_addr_t s_addr;
};

struct sockaddr_in6
{
	sa_family_t sin6_family;
	in_port_t sin6_port; /* Transport layer port # */
	uint32_t sin6_flowinfo; /* IPv6 flow information */
	struct in6_addr sin6_addr; /* IPv6 address */
	uint32_t sin6_scope_id; /* IPv6 scope-id */
};
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))

Linux网络编程学习笔记(TCP)_第3张图片

所有专用 socket 地址(以及 sockaddr_storage)类型的变量在实际使用时都需要转化为通用 socket 地址类型 sockaddr(强制转化即可),因为所有 socket 编程接口使用的地址参数类型都是 sockaddr

2 IP地址转换

人们习惯用可读性好的字符串来表示 IP 地址,但编程中我们需要先把它们转化为整数(二进制数)方能使用。而记录日志时则相反,我们要把整数表示的 IP 地址转化为可读的字符串。

#include 
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
//返回值1表示成功,0非法,-1失败
  • af:地址族: AF_INET(ipv4)AF_INET6(ipv6)
  • src:需要转换的点分十进制的IP字符串
  • dst:转换后的结果保存在这个里面
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
//返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
  • af:地址族: AF_INET(ipv4)AF_INET6(ipv6)
  • src: 要转换的ip的整数的地址
  • dst: 转换成IP地址字符串保存的地方
  • size:指定第三个参数可用的大小(数组的大小)

3 TCP通信流程

Linux网络编程学习笔记(TCP)_第4张图片

3.1 服务器端 (被动接受连接的角色)

  1. 创建一个用于监听的套接字
    • 监听:监听有客户端的连接
    • 套接字:这个套接字其实就是一个文件描述符
  2. 将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)
    • 客户端连接服务器的时候使用的就是这个IP和端口
  3. 设置监听,监听的fd开始工作(就是监听fd的读缓冲区有没有数据(例如三次握手等信息))
  4. 阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个和客户端通信的套接字(fd)
  5. 通信
    • 接收数据
    • 发送数据
  6. 通信结束,断开连接

3.2 客户端

  1. 创建一个用于通信的套接字(fd)
  2. 连接服务器,需要指定连接的服务器的 IP 和 端口
  3. 连接成功了,客户端可以直接和服务器通信
    • 接收数据
    • 发送数据
  4. 通信结束,断开连接

4 套接字函数

4.1 头文件

#include 
#include 
#include  // 包含了这个头文件,上面两个就可以省略

4.2 创建套接字sockfd

创建一个套接字

int socket(int domain, int type, int protocol);
// 成功:返回文件描述符,操作的就是内核缓冲区。
//失败:返回-1
  • domain: 协议族
    AF_INET : ipv4
    AF_INET6 : ipv6
    AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)
  • type: 通信过程中使用的协议类型
    SOCK_STREAM : 流式协议(例如TCP)
    SOCK_DGRAM : 报式协议(例如UDP)
  • protocol : 具体的一个协议。一般写0
    • 上面若为SOCK_STREAM : 流式协议默认使用 TCP
    • 上面若为SOCK_DGRAM : 报式协议默认使用 UDP

4.3 绑定本地的IP和端口(服务器端)

绑定,将fd 和本地的IP + 端口进行绑定

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
//成功返回0,失败返回-1
  • sockfd : 通过socket函数得到的文件描述符
  • addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
  • addrlen : 第二个参数结构体占的内存大小
    【应用】
//先创建一个socoket地址来用
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
 //可以将本机ip转化到容器中
 //但这个不常用,因为一台机器往往有多个网卡,可能存在多个ip
//inet_pton(AF_INET, "192.168.193.128", saddr.sin_addr.s_addr);
saddr.sin_addr.s_addr = htonl(INADDR_ANY);  // 0.0.0.0,表示监听来到本机的所有ip,偷懒小做法
//假设端口为9999
saddr.sin_port = htons(9999);
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
assert(ret >= 0);

4.4 监听socket的连接(服务器端)

监听这个socket上的连接

int listen(int sockfd, int backlog); 
// 与backlog相关联的文件在/proc/sys/net/core/somaxconn
//成功返回0,失败返回-1
  • sockfd : 通过socket()函数得到的文件描述符
  • backlog : 因为调用listen会创建一个未连接的队列和一个已连接的队列,而这个参数就是去指定这两个队列里未连接的和已经连接的的最大值。一般指定5即可,因为accept会再已经连接的队列中抽出连接来。

4.5 接收客户端连接(服务器端,阻塞)

接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//成功则返回用于通信的文件描述符
//失败返回-1
  • sockfd : 用于监听的文件描述符
  • addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
  • addrlen : 指定第二个参数的对应的内存大小,注意这个是要传递存储大小的变量的地址。
    【应用】
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int cfd = accept(lfd, (struct sockaddr *)&clientaddr, &len);
if(cfd == -1) {
        perror("accept");
        exit(-1);
    }

4.6 客户端连接服务器(客户端)

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//返回值:成功 0, 失败 -1
  • sockfd : 用于通信的文件描述符
  • addr : 客户端要连接的服务器的地址信息
  • addrlen : 第二个参数的内存大小

4.7 读数据和写数据

ssize_t write(int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据

5 TCP通信并发

5.1 进程并发

要实现TCP通信服务器处理并发的任务,使用多线程或者多进程来解决。

  • 一个父进程,多个子进程
  • 父进程负责等待并接受客户端的连接(不断循环地调用accept()的应用部分
  • 子进程:完成通信,接受一个客户端连接,就创建一个子进程用于通信。(在上面的while循环中进行创建子进程,并在嵌套一个while来通信等操作。。)
  • 回收子进程:因为父进程需要阻塞循环accpet,因此不可以主动wait去阻塞回收。只能通过信号来解决回收子进程的功能。
    • 首先注册信号捕捉
    struct sigaction act;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    act.sa_handler = recyleChild;
    // 注册信号捕捉
    sigaction(SIGCHLD, &act, NULL);
    
    • 其次是信号捕获
    void recyleChild(int arg) {
    //这里的while是为了一次能够回收多个!
        while(1) {
            int ret = waitpid(-1, NULL, WNOHANG);
            if(ret == -1) {
                // 所有的子进程都回收了
                break;
            }else if(ret == 0) {
                // 还有子进程活着
                break;
            } else if(ret > 0){
                // 被回收了
                printf("子进程 %d 被回收了\n", ret);
            }
        }
    }
    
    • 但注意执行信号属于软件中断,此时会打断accpet从而报错,因此我们需要在accpet处做一个判断,如果是中断就continue
    int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &len);
        if(cfd == -1) {
            if(errno == EINTR) {
                continue;
            }
            perror("accept");
            exit(-1);
    

5.2 线程并发

其实主要流程跟进程并发一模一样。
【需要注意的细节】

  • 由于创建子线程的函数中 pthread_create(&pinfo->tid, NULL, working, pinfo);第四个值只有一个,因此想要传入多个参数需要定义一个结构体来承接。
    • 如果想直接拿到一个结构体的内容,除了一个个的成员赋值外,还可以靠memcpy函数,例如memcpy(&pinfo->addr, &cliaddr, len);
  • 回收子线程使用pthread_detach

6 通信半关闭以及端口复用

6.1 半关闭

当 TCP 连接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN_WAIT_2状态),并没有立即发送 FIN 给 A,A 方处于半连接状态(半开关),此时A 可以接收 B 发送的数据,但是 A 已经不能再向 B 发送数据

从程序的角度,可以使用 API 来控制实现半连接状态

#include 
int shutdown(int sockfd, int how);
  • sockfd: 需要关闭的socket的描述符
  • how: 允许为shutdown操作选择以下几种方式:
    • SHUT_RD(0): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
    • SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
    • SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR。

使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为 0 时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。
【注意】

如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放。
在多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。但如果一个进程 close(sfd) 将不会影响到其它进程。

6.2 端口复用(设置套接字sockfd属性)

端口复用最常用的用途是:

  • 防止服务器重启时之前绑定的端口还未释放(如:服务器主动关闭后,由于处于TIME_WAIT阶段(即等待客户端回复(四次挥手的最后一次),需要等一分钟,才会自动释放)。
  • 程序突然退出而系统没有释放端口,想关闭后,立刻能够使用端口,就需要用到端口复用
#include 
#include 
// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
  • sockfd : 要操作的文件描述符
  • level : 级别 - SOL_SOCKET (端口复用的级别)
  • optname : 选项的名称(下面两个端口复用,哪个都行)
    • SO_REUSEADDR
    • SO_REUSEPORT
  • optval : 端口复用的值(整型)
    • 1 : 可以复用
    • 0 : 不可以复用
  • optlen : optval参数的大小

【应用】
端口复用,设置的时机是在服务器绑定端口之前。

int optval = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
bind(...);

7 I/O多路复用(I/O多路转接)

【解释】此处的I/O并不是传统的输入和输出,而是对套接字sockfd中的读和写缓冲区进行的I/O操作。

I/O多路复用使得程序能同时监听多个文件描述符,能够提高程序的性能,Linux 下实现 I/O 多路复用的系统调用主要有 selectpollepoll

7.1 背景

先看一下之前的服务器工作的阻塞IO模型,前面提到的多个线程/进程,当遇到读/写以及连接socket时,都会发生阻塞。
Linux网络编程学习笔记(TCP)_第5张图片
但如果仅仅非阻塞而去轮询的话,虽然提高了程序的执行效率,但是需要占用更多的CPU和系统资源。因此IO多路转接技术应运而生!
Linux网络编程学习笔记(TCP)_第6张图片

7.2 select函数介绍

使用流程

如何使用:

  1. 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。【创建fd_set结构体,并通过FD_SET函数,将描述符加入列表】
  2. 调用一个系统函数(即select),监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回。
    • 这个函数是阻塞
    • 函数对文件描述符的检测的操作是由内核完成的

在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作。

函数

// sizeof(fd_set) = 128个字节 1024位
#include 
#include 
#include 
#include 
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
//返回值若为-1,则失败;若>0,则为检测的集合中有n个文件描述符发生了变化
  • nfds : 委托内核检测的最大文件描述符的值 + 1
  • readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性sizeof(fd_set) = 128个字节 1024位
    • 一般检测读操作,一共1024位,也就是最多能同时监听1024个文件描述符
    • 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区
    • 是一个传入传出参数(传入是为了让函数知道需要监听哪些描述符,传出是为了接下来去检测那些描述符发生了读操作)
  • writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性
    • 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写)
    • 一般不使用(该参数传递NULL
  • exceptfds : 检测发生异常的文件描述符的集合(一般也不用)(该参数传递NULL
  • timeout : 设置的超时时间
    •  struct timeval {
       	long tv_sec; /* seconds */
       	long tv_usec; /* microseconds */
       };```
      
    • NULL : 永久阻塞,直到检测到了文件描述符有变化(一般使用NULL)
    • tv_sec = 0,tv_usec = 0,不阻塞
    • tv_sec > 0,tv_usec > 0,阻塞对应的时间

【应用时需要注意的细节】
因为rdset是传入传出参数,因此我们把需要监听的位置置为1,调用select函数后,该函数会将没有发生读的位置置为0,此时我们如果再重新将rdset传入进select,那么应该监听的位置,其就不监听了。

因此,在使用rdset时,常初始化是一个,需要额外拷贝一个tem用来当传出参数以来后序检测哪个文件描述符发生了读操作。

对select监听列表的操作

【初始化监听列表】

// fd_set一共有1024 bit, 全部初始化为0(清空列表)
void FD_ZERO(fd_set *set);

【将描述符加入监听列表】

void FD_SET(int fd, fd_set *set);

【断开连接后需要清除】

// 将参数文件描述符fd对应的标志位设置为0(clear)
void FD_CLR(int fd, fd_set *set);

【遍历寻找需要读的文件描述符】

// 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,如果是0返回0, 是1返回1
int FD_ISSET(int fd, fd_set *set);
// 将参数文件描述符fd 对应的标志位,设置为1

缺点

  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  3. select支持的文件描述符数量太小了,默认是1024
  4. fds集合不能重用,每次都需要重置(即上面提到的需要注意的细节)

7.3 poll函数介绍

结构体

select使用方法一样,不过是它的改进版。首先改进的是其监听队列的集合(与上面的fdset一样,只不过这个数组储存的内容更多):

#include 
struct pollfd {
	int fd; /* 委托内核检测的文件描述符 */
	short events; /* 委托内核检测文件描述符的什么事件 */
	short revents; /* 文件描述符实际发生的事件 */
};

【参数类型】下面的可以通过|选择多个属性
Linux网络编程学习笔记(TCP)_第7张图片
【结构体应用】

struct pollfd myfd;
myfd.fd = 5;//监听5文件描述符
myfd.events = POLLIN | POLLOUT; //同时委托内核进行读写的监听操作

函数

【poll函数】

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
//返回值:-1失败;>0则成功,n表示检测到集合中有n个文件描述符发生变化
  • fds : 是一个struct pollfd 结构体数组(上面所示),这是一个需要检测的文件描述符的集合(这样也是传入传出参数,它不仅没1024限制,还不用担心重用问题[详见epoll的细节])
  • nfds : 这个是第一个参数数组中最后一个有效元素的下标 + 1
  • timeout : 阻塞时长
    • 0 : 不阻塞
    • -1 : 阻塞,当检测到需要检测的文件描述符有变化,解除阻塞
    • >0 : 阻塞的时长

对poll监听列表的操作

【初始化】需要人为初始化,好分辩哪些位置是还没放入文件描述符的,以及断开连接的位置。:

//此处1024可以更大
 struct pollfd fds[1024];
    for(int i = 0; i < 1024; i++) {
        fds[i].fd = -1;
        fds[i].events = POLLIN;//以监听读为例
    }

【遍历查看操作】这个也是位操作的,例如查看数组中第一个放入的文件描述符是否发生了读操作:if(fds[0].revents & POLLIN)
【加入监听队列操作】

// 将新的文件描述符加入到集合中
for(int i = 1; i < 1024; i++) {
    if(fds[i].fd == -1) {
        fds[i].fd = cfd;
        fds[i].events = POLLIN;
        break;
    }
}

缺点

只剩下select缺点的前两条了:

  1. 每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  2. 同时每次调用poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

7.4 epoll函数介绍

使用流程

  1. 使用epoll_create()去创建一个epoll的实例。而这个epoll的实例并不在用户区,而是在内核区(是一个eventpoll的结构体,里面有两个关键成员rbrrdlist,这样就不用用户区的数组转内核区监听,直接内核区的结构了!)
    • rbr是记录需要监听的文件描述符的集合,底层不同于前面两个由数组组成,这个是由红黑树实现的。
    • rdlist是就绪列表,是那些已经发生事件(读或者写)了。这个是由双向链表实现的。
  2. 我们通过第一步函数返回的文件描述符,可以通过epoll提供的api,对内核区的eventpoll进行操作。因此我们需要创建一个检测事件的结构体epoll_event ,并修改其两个成员epev.events = EPOLLIN;//以读监听为例epev.data.fd = lfd;(放入监听文件描述符)
  3. 通过epoll_ctl(),将文件描述符加入监听队列(加到上面的rbr) ,但此时并不会开始检测
  4. 通过epoll_wait(),内核开始检测(若有发生事件的,内核就会将rbr中的描述符送给rdlist,最终由rdlist递给用户)。
  5. 通过创建一个承接数组,由epoll_wait()传出参数来将就绪事件填上数组,并且该函数会返回一个n,因此此时直接for(i = 0; i < n; ++i) 挨个处理就绪事件即可
    • 可通过结构体的.data.fd来区分是哪个文件描述符。
    • 可通过结构体的.events 来区分是哪个事件,例如查看写事件就是if(epevs[i].events & EPOLLOUT)

检测事件的结构体

typedef union epoll_data {
	void *ptr;
	int fd;
	uint32_t u32;
	uint64_t u64;
} epoll_data_t;

struct epoll_event {
	uint32_t events; /* Epoll events */
	epoll_data_t data; /* User data variable */
};

常见的Epoll检测事件:

  • EPOLLIN
  • EPOLLOUT
  • EPOLLERR
  • EPOLLET(注意设置ET触发,也要将该事件对应的文件描述符fd设置为非阻塞——通过fcntl的一系列操作)
  • EPOLLRDHUP(这个是内核2.6以后才有的,当对面断开连接时,会直接触发这个事件,而不用recv等函数去判断对面是否断开了)
  • EPOLLONESHOT
    • 即使可以使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。
    • 一个socket连接在任一时刻都只被一个线程处理,可以使用epoll的EPOLLONESHOT事件实现。
    • 注册了EPOLLONESHOT事件的 socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket 上的EPoLlTpNESHOT事件,以确保这个socket 下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket。

函数

【epoll函数】

#include 
// 创建一个新的epoll实例。
//在内核中创建了一个数据,这个数据中有两个比较重要的数据,
//一个是需要检测的文件描述符的信息(红黑树)
//还有一个是就绪列表,存放检测到数据发送改变的文件描述符信息(双向链表)
int epoll_create(int size);
//返回值-1则失败,> 0则成功,返回操作epoll实例的文件描述符
  • size : 目前没有意义了。随便写一个数,必须大于0.

【epoll实例管理】添加文件描述符信息,删除信息,修改信息(如:由监听读改为写)

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfd : epoll实例对应的文件描述符
  • op : 要进行什么操作
    EPOLL_CTL_ADD: 添加
    EPOLL_CTL_MOD: 修改
    EPOLL_CTL_DEL: 删除
  • fd : 要检测的文件描述符
  • event : 检测文件描述符什么事情(见上面结构体描述)

【epoll开始检测】

// 检测函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//失败返回-1,成功,则返回发送变化的文件描述符的个数 > 0
  • epfd : epoll实例对应的文件描述符
  • events : 传出参数,常通过定义一个epoll_event 数组来承接,发送了变化的文件描述符的信息(与前两个复用方法相比,不仅仅返回发生的次数,还把谁发生的也传过来了)
  • maxevents : 第二个参数结构体数组的大小(给个容器去承接结果,这个就是说明容器的大小)
  • timeout : 阻塞时间
    • 0 : 不阻塞
    • -1 : 阻塞,直到检测到fd数据发生变化,解除阻塞
    • > 0 : 阻塞的时长(毫秒)

7.5 epoll工作模式

LT触发(水平触发)–默认方式

LT(level - triggered)是缺省(即默认)的工作方式,并且同时支持 block 和 no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。只要你没读/写完,或者你不作任何操作,内核还是会继续通知你的【在epoll_wait()那里告诉你】。
【实现】 默认就是LT触发

ET触发(边沿触发)

是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你【在epoll_wait()那里告诉你】。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

【注意】epoll工作在 ET 模式的时候,必须使用非阻塞套接口(是文件描述符fd,而不是wait那里,wait那里一般都是阻塞的!),以避免由于一个文件句柄(win下叫句柄,linux下叫文件描述符)的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

【实现】在事件中,设置上EPOLLET

两者的比较

【ET相比LT的好处】

  1. ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。
  2. 其可以先接触一部分信息考虑要不要剩下的信息,如果不要,直接丢弃即可。

【ET的缺点】在使用readn函数要读取500B数据时,假设此时服务端只发来了200B数据,readn函数会发生阻塞,等待剩下300B数据的到来,此时如果采用边沿触发模式,readn会在下一次数据到来前阻塞,而readn函数阻塞等待,导致epoll_wait函数无法监听下次数据的到来,readn又在阻塞等下次数据到来,最后的结果就是形成“死锁”。因此,只能工作在非阻塞的情况。

阻塞和非阻塞

因此此时就要引进阻塞非阻塞了。阻塞就是办完再到下一个,否则不动。而非阻塞是办不完先去干别的,然后轮询。这个轮询的操作就是要用到epoll了,那么epoll下的LT和ET有以下情况:
【LT水平触发模式】

  • epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序可以不立即处理该事件。
  • 当下一次调用epoll_wait时,epoll_wait还会再次向应用程序报告此事件,直至被处理
    【ET边缘触发模式】
  • epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序必须立即处理该事件(就报告一次)
  • 必须要一次性将数据读取完,使用非阻塞I/O,读取到出现eagain

情景理解两个模式

LT 模式 (水平触发)
假设委托内核检测读事件 -> 检测fd的读缓冲区

  • 读缓冲区有数据 - > epoll检测到了会给用户通知【在epoll_wait()那里告诉你】
    • 用户不读数据,数据一直在缓冲区,epoll 会一直通知
    • 用户只读了一部分数据,epoll会通知
  • 缓冲区的数据读完了,不通知

ET 模式(边沿触发)
假设委托内核检测读事件 -> 检测fd的读缓冲区

  • 读缓冲区有数据 - > epoll检测到了会给用户通知【在epoll_wait()那里告诉你】
    • 用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了
    • 用户只读了一部分数据,epoll不通知
  • 缓冲区的数据读完了,不通知

8 IO模型

8.1 阻塞与非阻塞,同步与异步概念

一个典型的网络IO接口调用,分为以下两个阶段:
【操作系统的TCP接收缓冲区】
数据就绪:根据系统IO操作的就绪状态(以使用recv服务端接收函数为例)

  • 阻塞 (此时线程将挂起,系统将会去调度其他线程)
    • 阻塞当前线程,等待返回
  • 非阻塞(不会改变线程的状态,通过返回值判断)
    • 当recv使用非阻塞时,需要去循环调用recv,并根据其返回值判断是重新循环or直接退出循环or去处理读取到的数据
    • 直接返回

【应用程序去读写】
数据读写:根据应用程序与内核的交互方式(以使用recv服务端接收函数为例解释)

  • 同步
    • 当应用调用recv时,会等待执行完这个函数,而执行这个函数时,系统会把TCP接收缓冲区的数据存入到recv函数的传出参数buf中,而在没完全存入buf中,即函数没调用之前,应用不可以执行下面的函数,因此这是同步
    • 即应用层完成这个IO动作
    • 注意,IO多路复用属于同步,因为多路复用仅仅是起通知作用,而实际的IO操作中使用的read或者write依旧是同步函数实现的
  • 异步
    • 为了实现上面的recv功能,假设有一个异步IO的接口(例如linux中的aio_read函数),那么应用程序此时可以通过这个接口,将sockfd、buf以及通知方式传递给系统,自己继续向下执行,当系统完成了IO后,来通知应用程序。
    • 即由操作系统完成这个IO动作,应用程序可以继续执行下面的语句
    • 因此异步是跟非阻塞搭配的。如果跟阻塞搭配,那么异步即使由系统来做了,程序还仍阻塞在那里,就失去了异步的意义了。

在处理IO的时候,阻塞和非阻塞都是同步IO,只有使用了特殊的API才是同步IO

8.2 Linux上五种IO模型(各个函数的使用背景)

阻塞(阻不阻塞并不是跟函数有关,主要是文件描述符)

调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作。
Linux网络编程学习笔记(TCP)_第8张图片

非阻塞

非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。非阻塞I/O执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据 errno 区分这两种情况,对于accept,recv 和 send,事件未发生时,errno 通常被设置成 EAGAIN。
Linux网络编程学习笔记(TCP)_第9张图片
【优点】发现没准备好,可以先去做其他事情,然后再查询其状态。

IO复用

Linux 用 select/poll/epoll 函数实现 IO 复用模型,这些函数也会使进程阻塞,但是和阻塞IO所不同的是这些函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。直到有数据可读或可写时,才真正调用IO操作函数。
Linux网络编程学习笔记(TCP)_第10张图片
【优点】

  • 一次可以检测多个线程是否完成,可用于监听线程
  • 并不用于处理高并发(其需要用多线程解决),而是可以一次处理监听

信号驱动

Linux 用套接口进行信号驱动 IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO 信号,然后处理 IO 事件。
Linux网络编程学习笔记(TCP)_第11张图片
【优点】

  • 内核在第一个阶段是异步,在第二个阶段(read操作)是同步;
  • 与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统API的调用次数,提高了效率。

异步

Linux中,可以调用 aio_read 函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。
Linux网络编程学习笔记(TCP)_第12张图片

9 服务器编程基本框架和两种高效的事件处理模式

9.1 服务器的基本框架

虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理。
Linux网络编程学习笔记(TCP)_第13张图片

  • I/O 处理单元是服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是数据的收发不一定在 I/O 处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式(reactor还是proactor)。
  • 一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给 I/O 处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并发处理。
  • 网络存储单元可以是数据库、缓存和文件,但不是必须的。
  • 请求队列是各单元之间的通信方式的抽象。I/O 处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。

9.2 两种高效的事件处理模式

Reactor模式

要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元),将 socket 可读可写事件放入请求队列,交给工作线程处理。

除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。

使用同步 I/O(以 epoll_wait 为例)实现的 Reactor 模式的工作流程是:
Linux网络编程学习笔记(TCP)_第14张图片

Proactor模式

Proactor 模式将所有 I/O 操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。
Linux网络编程学习笔记(TCP)_第15张图片

9.3 服务器流程实现(同步模拟Proactor)

同步模拟Proactor就是主线程通过一个EPOLL对象来实现读、写、监听的功能,因为一个epoll对象监听一个事件组,根据事件组中出现的描述符是否是监听可以判断是否是连接;根据事件组里面的events标志位,可以判断是发生了读事件还是写事件。

  • 首先先添加信号捕捉,对SIGPIE信号设为忽略,
  • 其次再初始化线程池(详见下一章)
  • 创建一个数组用于保存所有的客户端信息(数组的类型为自己写的http_conn类型)
  • 创建一个监听的套接字,并绑定本地的IP和端口,最后启用监听
  • 下面开始主线程的循环监听
    • 为了能同时监听多个,因此需要创建epoll对象以及事件数组, 向epoll中添加需要监听的文件描述符,最后调用epoll_wait函数等待结果
    • 循环遍历事件数组
      • 若事件的套接字是监听套接字,那么通过accept连接客户端,并将新的客户进行数据初始化,并放入第三步的数组中
      • 若该事件的套接字为EPOLLRDHUPEPOLLHUPEPOLLERR则是对方异常断开或者错误等事件,因此需要关闭连接。
        • 关闭连接其实就是移除epoll监听,并且重置为-1。
      • 若事件的套接字为EPOLLIN,则表示有需要读的事件发生,因此要一次性把所有数据都读完,读完后,将该套接字传给线程池
      • 若事件的套接字为EPOLLOUT,则表示有需要写的事件发生,因此要一次性把所有数据都写完,写完后,将该套接字传给线程池

10 线程池

10.1 概念

线程池是由服务器预先创建的一组子线程。线程池中的所有子线程都运行着相同的代码。

当有新的任务到来时,主线程将通过某种方式选择线程池中的某一个子线程来为之服务。相比与动态的创建子线程,选择一个已经存在的子线程的代价显然要小得多。至于主线程选择哪个子线程来为新任务服务,则有多种方式:

  • 主线程使用某种算法来主动选择子线程。最简单、最常用的算法是随机算法和 Round Robin(轮流选取)算法,但更优秀、更智能的算法将使任务在各个工作线程中更均匀地分配,从而减轻服务器的整体压力。
  • 主线程和所有子线程通过一个共享的工作队列来同步,子线程都睡眠在该工作队列上。当有新的任务到来时,主线程将任务添加到工作队列中。这将唤醒正在等待任务的子线程,不过只有一个子线程将获得新任务的”接管权“,它可以从工作队列中取出任务并执行之,而其他子线程将继续睡眠在工作队列上。

10.2 线程池的数量

如果是CPU密集型应用,则线程池大小设置为N+1

  • 对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。(即使当计算密集型的线程偶尔由于缺失故障或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费。

如果是IO密集型应用,则线程池大小设置为2N+1

任务一般可分为:CPU密集型、IO密集型、混合型,对于不同类型的任务需要分配不同大小的线程池。

  • CPU密集型任务 尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,只能增加上下文切换的次数,因此会带来额外的开销。

  • IO密集型任务 可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候去处理别的任务,充分利用CPU时间。

  • 混合型任务可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。 因为如果划分之后两个任务执行时间相差甚远,那么先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。

10.3 线程池的特点

  • 空间换时间,浪费服务器的硬件资源,换取运行效率。
  • 池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源
  • 当服务器进入正式运行阶段,开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配。
  • 当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源

10.4 线程池的实现

  • 消息队列是集成在线程池当中的,因此用信号量来实现,但是主线程在放、子线程在取信号量的时候都要记得上锁解锁(互斥锁)
    • 主线程上锁,放入任务(文件描述符),解锁,调用信号量的post函数
    • 子线程调用信号量的wait函数,等能向下执行就(上锁,取出,删除,解锁),然后执行任务处理即可。
  • 线程池的创建时依靠for循环pthread_create函数来实现的,而这个函数中所传人的子线程执行的函数需要是静态函数,但静态函数只能访问静态成员,为了解决这个问题,这里在pthread_create中传入this指针充当变量,这样可以将this指针类型强转会对象,随后使用其私有属性。

11 引用

本笔记是针对牛客的高境老师的《第四章Linux网络开发》内容书写的。默默感谢一下高老师,确实很细致。

你可能感兴趣的:(cpp项目开发,网络,linux,学习)