Skip to content

LV035-多路复用IO-03-epoll

本文主要是网络编程——多路复用IO中的epoll的相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。

一、epoll简介

epoll是在2.6内核中提出的,是之前的selectpoll的增强版本。相对于selectpoll来说,epoll更加灵活,没有描述符限制,它所支持的文件描述符上限是整个系统可以打开的文件数目。

epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,并且每个fd上边都会有一个callback函数(回调函数),只有活跃的socket才会去主动调用这个回调函数,这属于一种通知机制,就是当事件发生的时候,则主动通知;通知机制的反面,就是轮询机制。

我们主要会用到以下三个函数,这里提前说一下,后边介绍原理的话可能会用到:

c
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll通过以上三个函数把原先的select/poll调用分成了3个部分:

  • (1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源);
  • (2)调用epoll_ct()lepoll对象中添加需要连接的socket套接字;
  • (3)调用epoll_wait()收集发生的事件的连接。

这样做的好处?

假设现在有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百或者几千个TCP连接是活跃的,这种情况下如何实现高并发?

select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大。对于select来说,在FD_SETSIZE1024的情况下,则我们至少需要创建1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到100万级别的并发访问,是一个很难完成的任务。pollselect其实是很类似的,它也是难以完成这样的高并发连接。

而我们使用epoll的话,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并不会一次性向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。

二、epoll数据结构

关于epoll的源码我是找了一下,但是没有找到,也可能是自己查找的方式不对吧,但是这个嘛暂时不太重要,后边再补充吧。不过我在github上找到了一个项目,包含了linux内核源码:linux/eventpoll.c at master · torvalds/linux (github.com)

先来看一张网上找到的一张关于epoll数据结构的图,不过我修改成自己可以理解的样子啦(这里会提到红黑树,前边学习数据结构的时候好像没有了解这一种树,后边会在数据结构的笔记中补充):

image-20220704074731164

当某一进程调用epoll_create()创建epoll对象的时候,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,这两个成员是rdllistrbr,这两个成员的类型如下所示:

c
struct eventpoll
{
	...
	/* List of ready file descriptors */
	struct list_head rdlist;
    ...
    /* RB tree root used to store monitored fd structs */
	struct rb_root_cached rbr;
	...
};
  • rdlist:该成员中存放的是将要通过epoll_wait返回给用户的满足条件的事件,这些事件被存储在一个双向链表中,而rdlist存放的就是这个双向链表的首地址

  • rbr:该成员中存放的是红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件。

每一个epoll对象都会有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,这样的话,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lg(n),其中n为树的高度)。

所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

epoll中,对于每一个事件,都会建立一个epitem结构体,其中有两个成员:

c
struct epitem
{
	union
	{
		/* RB tree node links this structure to the eventpoll RB tree */
		struct rb_node rbn;
		/* Used to free the struct epitem */
		struct rcu_head rcu;
	};

	/* List header used to link this structure to the eventpoll ready list */
	struct list_head rdllink;
	...
};
  • rbn:该成员表示挂载到eventpoll 的红黑树节点,这些节点存储着我们添加到epoll的所有事件。
  • rdllink:该成员表示挂载到eventpoll.rdllist的节点,这些节点存储着已经准备好的事件。

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

总结下来就是,通过红黑树和双链表数据结构,并结合回调机制,使epoll更加高效。

三、相关API

1. epoll_create()

1.1 函数说明

linux下可以使用man 2 epoll_create命令查看该函数的帮助手册。

c
/* 需包含的头文件 */
#include <sys/epoll.h>

/* 函数声明 */
int epoll_create(int size);

函数说明】该函数用于创建一个epoll句柄,也就是新创建一个epoll对象。

函数参数

  • sizeint类型,在初始epoll_create()实现中,size参数告知内核调用者希望添加到epoll对象的文件描述符的数量。从 Linux 内核 2.6.8 版本起,不再需要这个参数了(内核不需要该参数就可以动态地调整所需数据结构的大小),但是size仍然必须大于零,以便在旧内核上运行新的epoll应用程序时确保向后兼容性。

返回值int类型,成功将返回一个epoll句柄,这其实就是一个文件描述符(非负整数)。如果发生错误,则返回-1,并设置errno来表示错误类型。

使用格式】一般情况下基本使用格式如下:

c
/* 需要包含的头文件 */
#include <sys/epoll.h>

/* 至少应该有的语句 */
#define MAX_EVENTS 500
int epollFd = -1;
epollFd = epoll_create(MAX_EVENTS);

注意事项】当我们创建好epoll句柄后,它就会占用一个文件描述符,当我们使用完毕后,需要调用close来关闭这个epoll句柄,否则可能会导致fd不足。

1.2 使用实例

暂无

2. epoll_ctl()

2.1 函数说明

linux下可以使用man 2 epoll_ctl命令查看该函数的帮助手册。

c
/* 需包含的头文件 */
#include <sys/epoll.h>

/* 函数声明 */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

函数说明】该函数用于控制 epoll 对象,主要涉及 epoll 红黑树上节点的一些操作,比如添加节点,删除节点,修改节点事件。

函数参数

  • epfdint类型,通过 epoll_create() 创建的 epoll 对象句柄。
  • opint类型,表示动作类型,是对红黑树的操作,添加节点、删除节点、修改节点事件。op 可取的值:
EPOLL_CTL_ADD注册新的 fd 到 epoll 对象中,相当于往红黑树添加一个节点。
EPOLL_CTL_MOD修改已注册的 fd 的监听事件,相当于把红黑树上监听的 socket 对应的监听事件做修改。
EPOLL_CTL_DEL从 epoll 对象中删除一个 fd,相当于取消监听 socket 的事件。
- `fd`:`int`类型,需要添加监听的 `socket` 描述符,可以是监听套接字,也可以是与客户端通讯的通讯套接字。 - `event`:`struct epoll_event`类型结构体指针变量,表示事件的信息。event 参数详细说明:

我们在使用man手册的时候,下边会有该参数类型的说明:

c
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 */
};

(1)eventsuint32_t类型,代表要监听的 epoll 事件类型,有读事件,写事件等。

events取值含义
EPOLLIN表示对应的文件描述符可读。例如,如果客户端发送消息过来,代表服务器收到了可读事件。
EPOLLOUT表示对应的文件描述符可写。如果 fd 对应的发数据内核缓冲区不为满,只要监听了写事件,就会触发可写事件。
EPOLLPRI表示对应的文件描述符有紧急数据可读(带外数据)。
EPOLLERR表示对应的文件描述符发生错误。
EPOLLHUP表示对应的文件描述符被挂起。
EPOLLET将对应的文件描述符设置为边缘触发(edge-triggered),这是相对于水平触发(level-triggered)而言的。
EPOLLRDHUP监听套接字关闭或半关闭事件,Linux 内核 2.6.17 后可用。
(2)`data`:`epoll_data_t`类型,这是一个联合体类型,它可以在我们调用 `epoll_ctl` 给 `fd` 添加或者修改描述符监听的事件时附带一些数据。最典型的用法就是每个通讯套接字会对应内存中的一块数据区,这块数据区一般存放着一些连接相关的信息,比如对端的 `IP`,端口等。当我们要添加该通讯套接字监听事件时就可以把这块内存的地址赋值给 `ptr`,这样当我们调用 `epoll_wait` 时也可以取出这些信息。

返回值int类型,成功返回0;失败返回-1,并设置errno来表示错误类型。

使用格式】一般情况基本使用格式如下:

c
/* 需要包含的头文件 */
#include <sys/epoll.h>

/* 至少应该有的语句 */
struct epoll_event event;      /* 定义epoll事件 */
epfd = epoll_create(1);        /* 创建epoll对象 */

event.data.fd = socket_fd;     /* 填充事件的 fd */
event.events = EPOLLIN;        /* 填充事件类型 */
epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &event);/* 把socket_fd 添加到epoll中 */

注意事项none

2.2 使用实例

暂无

3. epoll_wait()

3.1 函数说明

linux下可以使用man 2 epoll_wait命令查看该函数的帮助手册。

c
/* 需包含的头文件 */
#include <sys/epoll.h>

/* 函数声明 */
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

函数说明】该函数用于等待事件发生,返回事件集合,也就是获取内核的事件通知。其实就是遍历双向链表,把双向链表里的节点数据拷贝出来,拷贝完毕后就从双向链表移除。

函数参数

  • epfdint类型,通过 epoll_create() 创建的 epoll 对象句柄。

  • eventsstruct epoll_event类型结构体指针变量,这是一个传出参数,用于获取从内核传出的已经符合用户要求的事件集合,一般这里我们会定义一个struct epoll_event结构体数组,并将数组首地址赋值给该变量。

  • maxeventsint类型,代表可以存放的事件个数,就是每次可以处理的最大事件数,也就是events数组的大小。注意这个值要大于0,并且在以前的时候不能大于epoll_create()中参数size的大小,后来epoll_create()忽略size参数后,好像就没有这个限制了。

  • timeoutint类型,表示超时时间,单位为毫秒(ms)。 timeout 取值说明:

timeout = 0 立即返回
timeout = -1 阻塞等待
timeout > 0超时后返回
【**返回值**】`int`类型,可能会有。以下三种情况:
  • 返回-1:表示发生错误,并会设置errno表示错误类型。
  • 返回0:超时时间timeout到了,但是监听的事件中依然没有符合我们要求的事件发生。
  • 返回正整数:表示符合我们要求的事件发生了,返回值代表这些事件的个数。

使用格式】一般情况基本使用格式如下:

c
/* 需要包含的头文件 */
#include <sys/epoll.h>

/* 至少应该有的语句 */
epret = epoll_wait(epfd, events, 20, -1); /* 开始监听事件 */
if(epret < 0)
{
	perror("epoll_wait error");
	continue;
}
for(i = 0; i < epret; i++) /* 查询事件并处理 */
{
	/* 如果事件的fd是监听的accept等待连接的socket fd 说明有客户端连接 */
	if(events[i].data.fd == socket_fd)
	{
		...
	}
	else /* 其他情况的话就是数据的处理 */
	{
		...
	}
}

注意事项none

3.2 使用实例

暂无

4. epoll_pwait()

4.1 函数说明

linux下可以使用man 2 epoll_pwait命令查看该函数的帮助手册。

c
/* 需包含的头文件 */
#include <sys/epoll.h>

/* 函数声明 */
int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);

函数说明】该函数与epoll_wait()一样,只是多了一个信号掩码的参数,是一个防止信号干扰的增强型 epoll_wait()函数。

函数参数

  • epfdint类型,通过 epoll_create() 创建的 epoll 对象句柄。

  • eventsstruct epoll_event类型结构体指针变量,这是一个传出参数,用于获取从内核传出的已经符合用户要求的事件集合。

  • maxeventsint类型,代表可以存放的事件个数,就是每次可以处理的最大事件数,也就是events数组的大小。注意这个值要大于0,并且不能大于epoll_create()中参数size的大小。

  • timeoutint类型,表示超时时间,单位为毫秒(ms)。

timeout = 0 立即返回
timeout = -1 阻塞等待
timeout > 0超时后返回
- `sigmask`:`sigset_t`类型,表示信号掩码,指定一个需要屏蔽的信号集,当该参数为`NULL`时,该函数就等价于`epoll_wait()`。

返回值int类型,可能会有。以下三种情况:

  • 返回-1:表示发生错误,并会设置errno表示错误类型。
  • 返回0:超时时间timeout到了,但是监听的事件中依然没有符合我们要求的事件发生。
  • 返回正整数:表示符合我们要求的事件发生了,返回值代表这些事件的个数。

使用格式none

注意事项none

4.2 使用实例

暂无

四、工作模式

epoll对文件描述符的操作有两种模式:LTlevel trigger)和ETedge trigger),其中LT模式是默认模式。

  • LT模式

LTLevel Triggered),水平触发模式。该模式为epoll的默认工作模式,它同时支持阻塞和非阻塞的socket。在该种模式下,当epoll_wait检测到描述符事件发生并将此事件通知进程,进程可以不立即处理该事件,当下次调用epoll_wait时,会再次响应进程并通知此事件,直到该事件被进程处理。在后边的例子中,当数据不被处理的话,可能会导致epoll_wait()函数不再阻塞,从而一直通知进程有新数据到达。

  • ET模式

ETEdge Triggered),边缘触发模式。该模式是一种高速处理模式,当且仅当状态发生变化时进程才会获得通知。当epoll_wait检测到描述符事件发生并将此事件通知进程,进程必须立即处理该事件,如果不处理,或者只是处理了一部分的数据,并没有全部处理,那么下次调用epoll_wait时,不会再次响应进程并通知此事件。需要注意的是,每个使用ET模式的文件描述符都应该是非阻塞的,如果文件描述符是阻塞的,那么读或写操作将会因为没有后续的事件而一直处于阻塞状态(饥渴状态)。

ET模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。但是需要注意 epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

五、使用实例

下边的程序中,我只在server中使用了epoll,客户端的程序使用的还是select

1. 基本使用实例

1.1 server服务器端

c
/** =====================================================
 * Copyright © hk. 2022-2022. All rights reserved.
 * File name: server.c
 * Author   : fanhua
 * Description: server服务器端——epoll(TCP)
 * ======================================================
 */
/* 头文件 */
#include <stdio.h>      /* perror */
#include <stdlib.h>     /* exit atoi */
#include <errno.h>      /* errno号 */
#include <sys/types.h>  /* socket           bind listen accept send */
#include <sys/socket.h> /* socket inet_addr bind listen accept send */
#include <strings.h>    /* bzero */
#include <arpa/inet.h>  /* htons  inet_addr inet_pton */
#include <netinet/in.h> /* ntohs  inet_addr inet_ntop*/
#include <unistd.h>     /* close */
#include <string.h>     /* strlen strcat*/
#include <sys/epoll.h> /* epoll_create epoll_ctl epoll_wait */
void usage(char *str); /* 提示信息打印函数 */

int main(int argc, char *argv[])
{
	/* 参数判断及端口号处理 */
	int port = -1;
	if (argc != 3)/* 参数数量不对时打印提示信息 */
	{
		usage(argv[0]);
		exit(-1);
	}
	port = atoi(argv[2]);/* 字符串转数字 */
	if (port < 5000)
	{
		usage(argv[0]);
		exit(-1);
	}
	/* 1.创建套接字,得到socket描述符 */
	int socket_fd = -1; /* 接收服务器端socket描述符 */
	if((socket_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0)/* SOCK_STREAM,使用TCP协议 */
	{
		perror ("socket");
		exit(-1);
	}
	/* 允许绑定地址快速重用 */
	int b_reuse = 1;
	setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &b_reuse, sizeof (int));
	/* 2.将套接字与指定端口号和IP进行绑定 */
	/* 2.1填充struct sockaddr_in结构体变量 */
	struct sockaddr_in sin;
	bzero (&sin, sizeof (sin));  /* 将内存块(字符串)的前n个字节清零 */
	sin.sin_family = AF_INET;    /* 协议族, IPv4 */
	sin.sin_port = htons(port);  /* 网络字节序的端口号 */
	if(inet_pton(AF_INET, argv[1], (void *)&sin.sin_addr) != 1)/* 填充IP地址,INADDR_ANY表示允许监听任意IP,但是它其实是(in_addr_t) 0x00000000 */
	{
		perror ("inet_pton");
		exit(-1);
	}
	/* 2.2绑定 */
	if(bind(socket_fd, (struct sockaddr *)&sin, sizeof(sin)) < 0)
	{
		perror("bind");
		exit(-1);
	}
	/*3.调用listen()把主动套接字变成被动套接字 */
	if (listen(socket_fd, 5) < 0)
	{
		perror("listen");
		exit(-1);
	}
	printf ("Server starting....OK!\n");
	/*4.阻塞等待客户端连接请求相关变量定义 */
	int client_fd = -1;
	struct sockaddr_in cin;          /* 用于存储成功连接的客户端的IP信息 */
	socklen_t addrlen = sizeof(cin);
	/* 5.打印成功连接的客户端的信息相关变量定义 */
	char ipv4_addr[16];
	/* 6.数据读写相关变量定义 */
	int ret = -1;
	char buf[BUFSIZ]; /* BUFSIZ是在stdio.h中定义的一个宏,值为8192 */
	char replay[BUFSIZ];
	/* 7.epoll实现多路复用相关变量定义 */
	int epfd, epret, i;
	struct epoll_event event;      /* 定义epoll事件 */
	struct epoll_event events[20]; /* 定义epoll事件集合 */
	epfd = epoll_create(1);        /* 创建epoll对象 */

	event.data.fd = socket_fd;     /* 填充事件的 fd */
	event.events = EPOLLIN;        /* 填充事件类型 */
	epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &event);/* 把socket_fd 添加到epoll中 */

	/* 8.数据处理 */
	while(1)
	{
		/* 开始监听事件 */
		epret = epoll_wait(epfd, events, 20, -1);
		if(epret < 0)
		{
			perror("epoll_wait error");
			continue;
		}
		/* 查询事件并处理 */
		for(i = 0; i < epret; i++)
		{
			/* 如果事件的fd是监听的accept等待连接的socket fd 说明有客户端连接 */
			if(events[i].data.fd == socket_fd)
			{
				if ((client_fd = accept(socket_fd, (struct sockaddr *)&cin, &addrlen)) < 0)
				{
					perror("accept");
					exit(-1);
				}
				event.data.fd = client_fd;        /* 填充事件的 fd */
				event.events = EPOLLIN | EPOLLET; /* 填充事件类型 */
				epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &event);/* 把socket_fd 添加到epoll中 */
				if (!inet_ntop(AF_INET, (void *)&cin.sin_addr, ipv4_addr, sizeof(cin)))
				{
					perror ("inet_ntop");
					exit(-1);
				}
				printf ("Clinet(%s:%d) is connected successfully![client_fd=%d]\n", ipv4_addr, ntohs(cin.sin_port), client_fd);

			}
			else /* 其他情况的话就是数据的处理 */
			{
				bzero(buf, BUFSIZ);
				bzero(replay, BUFSIZ);
				do{
					ret = read(events[i].data.fd, buf, BUFSIZ - 1);
				}while(ret < 0 && EINTR == errno);
				if(ret < 0)
				{
					perror("read");
					exit(-1);
				}
				if(!ret) /* 对方已经关闭,客户端断开 */
				{
					close(events[i].data.fd);
					printf("closed client: %d \n", events[i].data.fd);
					epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, &event);/* 把 events[i].data.fd 从 epoll 中移除 */
				}
				else
				{
					printf("Receive data[client_fd=%d]: %s", events[i].data.fd, buf);
					strcat(replay, buf);
					ret = send(events[i].data.fd, replay, strlen(replay), 0);
					if(ret < 0)
					{
						perror("send");
						continue;
					}
				}
			}
		}


	}
	/* 9.关闭文件描述符 */
	close(socket_fd);

	return 0;
}

/**
 * @Function: usage
 * @Description: 用户提示信息打印函数
 * @param str : 当前应用程序命令字符串,一般是./app
 * @return  : none
 */
void usage(char *str)
{
	printf ("\n%s serv_ip serv_port", str);
	printf ("\n\t serv_ip: server ip address");
	printf ("\n\t serv_port: server port(>5000)\n\n");
	printf ("\n\t Attention: The IP address must be the IP address of the local nic or 0.0.0.0 \n\n");
}

1.2 client客户端

c
/** =====================================================
 * Copyright © hk. 2022-2022. All rights reserved.
 * File name: cilent.c
 * Author   : fanhua
 * Description: client客户端程序——epoll(TCP)
 * ======================================================
 */
/* 头文件 */
#include <stdio.h>     /* perror fgets */
#include <stdlib.h>    /* exit atoi*/
#include <errno.h>     /* errno号 */
#include <unistd.h>    /* write close */

#include <sys/types.h> /* socket           connect send */
#include <sys/socket.h>/* socket inet_addr connect send */
#include <netinet/in.h>/* inet_addr */
#include <arpa/inet.h> /* inet_addr inet_pton htonl*/

#include <string.h>    /* bzero strncasecmp strlen */
#include <sys/epoll.h> /* epoll_create epoll_ctl epoll_wait */
void usage(char *str); /* 提示信息打印函数 */

/* 主函数 */
int main(int argc, char *argv[])
{
	/* 参数判断及端口号处理 */
	int port = -1;
	int portClient = -1;
	if (argc != 4)/* 参数数量不对时打印提示信息 */
	{
		usage(argv[0]);
		exit(-1);
	}
	port = atoi(argv[2]);/* 字符串转数字 */
	portClient = atoi(argv[3]);
	if (port < 5000 || portClient < 5000 || (port == portClient))
	{
		usage(argv[0]);
		exit(-1);
	}

	/* 1.打开套接字,得到套接字描述符 */
	int socket_fd = -1; /* 接收服务器端socket描述符 */
	if ((socket_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
	{
		perror ("socket");
		exit(-1);
	}
	/* 允许绑定地址快速重用 */
	int b_reuse = 1;
	setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &b_reuse, sizeof (int));
	/* 2.连接服务器 */
	/* 2.1填充struct sockaddr_in结构体变量 */
	struct sockaddr_in sin;
	bzero (&sin, sizeof (sin)); /* 将内存块(字符串)的前n个字节清零 */
	sin.sin_family = AF_INET;   /* 协议族 */
	sin.sin_port = htons(port); /* 网络字节序的端口号 */
	/* 客户端的 argv[1] 需要与系统的IP一致 */
	if(inet_pton(AF_INET, argv[1], (void *)&sin.sin_addr) != 1)/* IP地址 */
	{
		perror ("inet_pton");
		exit(-1);
	}
	/* 绑定固定的IP 和 端口号 */
	struct sockaddr_in sinClient;
	bzero(&sinClient, sizeof (sinClient)); /* 将内存块(字符串)的前n个字节清零 */
	sinClient.sin_family = AF_INET;   /* 协议族 */
	sinClient.sin_port = htons(portClient); /* 网络字节序的端口号 */
	if(inet_pton(AF_INET, argv[1], (void *)&sinClient.sin_addr) != 1)/* IP地址 */
	{
		perror ("inet_pton");
		exit(-1);
	}
	if(bind(socket_fd, (struct sockaddr *)&sinClient, sizeof(sinClient)) < 0)
	{
		perror("bind");
		exit(-1);
	}

	/* 2.2连接 */
	if(connect(socket_fd, (struct sockaddr *)&sin, sizeof(sin)) < 0)
	{
		perror("connect");
		exit(-1);
	}
	printf("Client staring...OK!\n");
	/* 3.数据读写相关变量定义 */
	int ret = -1;
	char buf[BUFSIZ]; /* BUFSIZ是在stdio.h中定义的一个宏,值为8192 */
	char replay[BUFSIZ];
	/* 4.select实现多路复用相关变量定义 */
	fd_set rset;   /* 用于检测读状态的文件描述符集 */
	int maxfd = -1;/* 最大文件描述符编号 */
	struct timeval tout;/* 超时时间结构体变量 */
	tout.tv_sec = 5;   /* 设置超时时间秒数 */
	tout.tv_usec = 0;  /* 设置超时时间微秒数 */
	/* 5.开始数据传输 */
	while (1)
	{
		FD_ZERO(&rset);    /* 清空文件描述符集 */
		FD_SET(0, &rset);  /* 将文件描述符0(标准输入)添加到文件描述符集 */
		FD_SET(socket_fd, &rset); /* 将用于监听的socket套接字添加到文件描述符集 */
		maxfd = socket_fd;        /* 重置最大文件描述符集大小 */
		/* 5.1开始检测文件描述符 */
		ret = select(maxfd + 1, &rset, NULL, NULL, &tout);
		if(ret < 0)
		{
			perror("select");
			continue;
		}
		/* 5.2标准键盘文件描述符就绪时的处理 */
		if(FD_ISSET(0, &rset))
		{
			bzero(buf, BUFSIZ);/* 清空buf */
			do{
				ret = read(0, buf, BUFSIZ - 1);/* 从标准输入获取数据 */
			}while(ret < 0 && EINTR == errno);
			if (ret < 0)/* 获取数据失败 */
			{
				perror ("read() from stdin");
				continue;
			}
			/* 获取数据成功,但是标准输入中没有数据,不需要写入,继续循环即可 */
			if (!ret) continue;
			/* 向服务器发送数据 */
			if(write(socket_fd, buf, strlen(buf)) < 0)/* 将从标准输入获取的数据写入到socket 网络套接字中 */
			{
				perror ("write to socket error");
				continue;
			}
			/* 判断是否需要退出 */
			if (!strncasecmp(buf, "quit", strlen ("quit")))  	//用户输入了quit字符
			{
				printf ("Client is exiting!\n");
				break;
			}
			
		}
		/* 5.3socket文件描述符就绪时的处理(服务器有数据发送过来) */
		if(FD_ISSET(socket_fd, &rset))
		{
			bzero (replay, BUFSIZ);
			do{
				ret = recv(socket_fd, replay, BUFSIZ, 0);
			}while (ret < 0 && EINTR == errno);
			if(ret < 0)
			{
				perror("recv");
				continue;
			}
			/* 若服务器已关闭,则直接退出客户端 */
			if (ret == 0) break;
			printf("server replay:%s", replay);
			if (!strncasecmp(buf, "quit", strlen("quit")))
			{
				printf ("Sender Client is exiting... ...!\n");
				break;
			}
		}
	}
	/* 6.关闭文件描述符 */
	close(socket_fd);

	return 0;
}

/**
 * @Function: usage
 * @Description: 用户提示信息打印函数
 * @param str : 当前应用程序命令字符串,一般是./app
 * @return  : none
 */
void usage(char *str)
{
	printf ("\n%s serv_ip serv_port", str);
	printf ("\n\t serv_ip: server ip address");
	printf ("\n\t serv_port: server port(>5000)\n\n");
	printf ("\n\t client_port: client portClient(>5000 && !=port )\n\n");
}

1.3 Makefile

由于需要生成两个可执行程序,自己输命令有些繁琐,这里使用make来进行。

makefile
## =====================================================
# Copyright © hk. 2022-2022. All rights reserved.
# File name: Makefile
# Author   : fanhua
# Description: Makefile文件
## ======================================================
# 
CC = gcc

DEBUG = -g -O2 -Wall
CFLAGS += $(DEBUG)

# 所有.c文件去掉后缀
TARGET_LIST = ${patsubst %.c, %, ${wildcard *.c}} 
all : $(TARGET_LIST)

%.o : %.c
	$(CC) $(CFLAGS) -c $< -o $@

.PHONY: all clean clean_o clean_out
clean : clean_o clean_out
	@rm -vf $(TARGET_LIST) 
	
clean_o :
	@rm -vf *.o
	
clean_out :
	@rm -vf *.out

1.4 测试结果

我们执行以下命令编译链接程序,生成两个可执行文件:

shell
make

然后会有如下提示,并生成两个可执行文件吗,分别为服务器端程序server和客户端程序client

shell
gcc -g -O2 -Wall    client.c   -o client
gcc -g -O2 -Wall    server.c   -o server

对于服务器端,我们执行以下命令启动服务器进程:

shell
./server 0.0.0.0 5001 # 允许监听所有网卡IP及端口

对于客户端,我们执行以下命令启动客户端进程:

shell
./client 192.168.10.101 5001 5002 # 连接到本地一块网卡的IP,并设置客户端向外发送数据的端口为5002
./client 192.168.10.101 5001 5003 # 连接到本地一块网卡的IP,并设置客户端向外发送数据的端口为5003

然后我们就会看到如下现象:

image-20220704104311802

可以发现我们发送的数据,在客户端都被打印了出来,说明客户端与服务器端可以进行正常通信。

2. 工作模式测试

下边的例子依然只是修改了服务器的代码,我们在启动服务器的时候选择epoll工作模式,并删除服务器端读取数据部分的代码。

2.1 server服务器端

c
/** =====================================================
 * Copyright © hk. 2022-2022. All rights reserved.
 * File name: server.c
 * Author   : fanhua
 * Description: server服务器端——epoll(TCP)
 * ======================================================
 */
/* 头文件 */
#include <stdio.h>      /* perror */
#include <stdlib.h>     /* exit atoi */
#include <errno.h>      /* errno号 */
#include <sys/types.h>  /* socket           bind listen accept send */
#include <sys/socket.h> /* socket inet_addr bind listen accept send */
#include <strings.h>    /* bzero */
#include <arpa/inet.h>  /* htons  inet_addr inet_pton */
#include <netinet/in.h> /* ntohs  inet_addr inet_ntop*/
#include <unistd.h>     /* close */
#include <string.h>     /* strlen strcat*/
#include <sys/epoll.h> /* epoll_create epoll_ctl epoll_wait */
void usage(char *str); /* 提示信息打印函数 */

int main(int argc, char *argv[])
{
	/* 参数判断及端口号处理 */
	int port = -1;
	if (argc != 3)/* 参数数量不对时打印提示信息 */
	{
		usage(argv[0]);
		exit(-1);
	}
	port = atoi(argv[2]);/* 字符串转数字 */
	if (port < 5000)
	{
		usage(argv[0]);
		exit(-1);
	}
	/* 1.创建套接字,得到socket描述符 */
	int socket_fd = -1; /* 接收服务器端socket描述符 */
	if((socket_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0)/* SOCK_STREAM,使用TCP协议 */
	{
		perror ("socket");
		exit(-1);
	}
	/* 允许绑定地址快速重用 */
	int b_reuse = 1;
	setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &b_reuse, sizeof (int));
	/* 2.将套接字与指定端口号和IP进行绑定 */
	/* 2.1填充struct sockaddr_in结构体变量 */
	struct sockaddr_in sin;
	bzero (&sin, sizeof (sin));  /* 将内存块(字符串)的前n个字节清零 */
	sin.sin_family = AF_INET;    /* 协议族, IPv4 */
	sin.sin_port = htons(port);  /* 网络字节序的端口号 */
	if(inet_pton(AF_INET, argv[1], (void *)&sin.sin_addr) != 1)/* 填充IP地址,INADDR_ANY表示允许监听任意IP,但是它其实是(in_addr_t) 0x00000000 */
	{
		perror ("inet_pton");
		exit(-1);
	}
	/* 2.2绑定 */
	if(bind(socket_fd, (struct sockaddr *)&sin, sizeof(sin)) < 0)
	{
		perror("bind");
		exit(-1);
	}
	/*3.调用listen()把主动套接字变成被动套接字 */
	if (listen(socket_fd, 5) < 0)
	{
		perror("listen");
		exit(-1);
	}
	int mode = 0;
	printf("please select epoll mode(1:LT 2:ET):");
	scanf("%d", &mode);
	printf ("Server starting...OK, epoll mode is %d(1:LT 2:ET)!\n", mode);
	/*4.阻塞等待客户端连接请求相关变量定义 */
	int client_fd = -1;
	struct sockaddr_in cin;          /* 用于存储成功连接的客户端的IP信息 */
	socklen_t addrlen = sizeof(cin);
	/* 5.打印成功连接的客户端的信息相关变量定义 */
	char ipv4_addr[16];
	/* 6.数据读写相关变量定义 */
	int count = 0;
	/* 7.epoll实现多路复用相关变量定义 */
	int epfd, epret, i;
	struct epoll_event event;      /* 定义epoll事件 */
	struct epoll_event events[20]; /* 定义epoll事件集合 */
	epfd = epoll_create(1);        /* 创建epoll对象 */

	event.data.fd = socket_fd;     /* 填充事件的 fd */
	event.events = EPOLLIN;        /* 填充事件类型 */
	epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &event);/* 把socket_fd 添加到epoll中 */

	/* 8.数据处理 */
	while(1)
	{
		/* 开始监听事件 */
		epret = epoll_wait(epfd, events, 20, -1);
		if(epret < 0)
		{
			perror("epoll_wait error");
			continue;
		}
		/* 查询事件并处理 */
		for(i = 0; i < epret; i++)
		{
			/* 如果事件的fd是监听的accept等待连接的socket fd 说明有客户端连接 */
			if(events[i].data.fd == socket_fd)
			{
				if ((client_fd = accept(socket_fd, (struct sockaddr *)&cin, &addrlen)) < 0)
				{
					perror("accept");
					exit(-1);
				}
				event.data.fd = client_fd;        /* 填充事件的 fd */
				if(mode == 2)
					event.events = EPOLLIN | EPOLLET; /* ET 模式*/
				else
					event.events = EPOLLIN; /* LT 模式 */
				epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &event);/* 把socket_fd 添加到epoll中 */
				if (!inet_ntop(AF_INET, (void *)&cin.sin_addr, ipv4_addr, sizeof(cin)))
				{
					perror ("inet_ntop");
					exit(-1);
				}
				printf ("Clinet(%s:%d) is connected successfully![client_fd=%d]\n", ipv4_addr, ntohs(cin.sin_port), client_fd);

			}
			else /* 其他情况的话就是数据的处理,为测试两种模式,这里并不读取数据 */
			{
				count++;
				printf("new data arrive!count = %d\n",count);
				if(count > 10)
					exit(0);
			}
		}


	}
	/* 9.关闭文件描述符 */
	close(socket_fd);

	return 0;
}

/**
 * @Function: usage
 * @Description: 用户提示信息打印函数
 * @param str : 当前应用程序命令字符串,一般是./app
 * @return  : none
 */
void usage(char *str)
{
	printf ("\n%s serv_ip serv_port", str);
	printf ("\n\t serv_ip: server ip address");
	printf ("\n\t serv_port: server port(>5000)\n\n");
	printf ("\n\t Attention: The IP address must be the IP address of the local nic or 0.0.0.0 \n\n");
}

2.2 client客户端

c
/** =====================================================
 * Copyright © hk. 2022-2022. All rights reserved.
 * File name: cilent.c
 * Author   : fanhua
 * Description: client客户端程序——epoll(TCP)
 * ======================================================
 */
/* 头文件 */
#include <stdio.h>     /* perror fgets */
#include <stdlib.h>    /* exit atoi*/
#include <errno.h>     /* errno号 */
#include <unistd.h>    /* write close */

#include <sys/types.h> /* socket           connect send */
#include <sys/socket.h>/* socket inet_addr connect send */
#include <netinet/in.h>/* inet_addr */
#include <arpa/inet.h> /* inet_addr inet_pton htonl*/

#include <string.h>    /* bzero strncasecmp strlen */
#include <sys/epoll.h> /* epoll_create epoll_ctl epoll_wait */
void usage(char *str); /* 提示信息打印函数 */

/* 主函数 */
int main(int argc, char *argv[])
{
	/* 参数判断及端口号处理 */
	int port = -1;
	int portClient = -1;
	if (argc != 4)/* 参数数量不对时打印提示信息 */
	{
		usage(argv[0]);
		exit(-1);
	}
	port = atoi(argv[2]);/* 字符串转数字 */
	portClient = atoi(argv[3]);
	if (port < 5000 || portClient < 5000 || (port == portClient))
	{
		usage(argv[0]);
		exit(-1);
	}

	/* 1.打开套接字,得到套接字描述符 */
	int socket_fd = -1; /* 接收服务器端socket描述符 */
	if ((socket_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
	{
		perror ("socket");
		exit(-1);
	}
	/* 允许绑定地址快速重用 */
	int b_reuse = 1;
	setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &b_reuse, sizeof (int));
	/* 2.连接服务器 */
	/* 2.1填充struct sockaddr_in结构体变量 */
	struct sockaddr_in sin;
	bzero (&sin, sizeof (sin)); /* 将内存块(字符串)的前n个字节清零 */
	sin.sin_family = AF_INET;   /* 协议族 */
	sin.sin_port = htons(port); /* 网络字节序的端口号 */
	/* 客户端的 argv[1] 需要与系统的IP一致 */
	if(inet_pton(AF_INET, argv[1], (void *)&sin.sin_addr) != 1)/* IP地址 */
	{
		perror ("inet_pton");
		exit(-1);
	}
	/* 绑定固定的IP 和 端口号 */
	struct sockaddr_in sinClient;
	bzero(&sinClient, sizeof (sinClient)); /* 将内存块(字符串)的前n个字节清零 */
	sinClient.sin_family = AF_INET;   /* 协议族 */
	sinClient.sin_port = htons(portClient); /* 网络字节序的端口号 */
	if(inet_pton(AF_INET, argv[1], (void *)&sinClient.sin_addr) != 1)/* IP地址 */
	{
		perror ("inet_pton");
		exit(-1);
	}
	if(bind(socket_fd, (struct sockaddr *)&sinClient, sizeof(sinClient)) < 0)
	{
		perror("bind");
		exit(-1);
	}

	/* 2.2连接 */
	if(connect(socket_fd, (struct sockaddr *)&sin, sizeof(sin)) < 0)
	{
		perror("connect");
		exit(-1);
	}
	printf("Client staring...OK!\n");
	/* 3.数据读写相关变量定义 */
	int ret = -1;
	char buf[BUFSIZ]; /* BUFSIZ是在stdio.h中定义的一个宏,值为8192 */
	char replay[BUFSIZ];
	/* 4.select实现多路复用相关变量定义 */
	fd_set rset;   /* 用于检测读状态的文件描述符集 */
	int maxfd = -1;/* 最大文件描述符编号 */
	struct timeval tout;/* 超时时间结构体变量 */
	tout.tv_sec = 5;   /* 设置超时时间秒数 */
	tout.tv_usec = 0;  /* 设置超时时间微秒数 */
	/* 5.开始数据传输 */
	while (1)
	{
		FD_ZERO(&rset);    /* 清空文件描述符集 */
		FD_SET(0, &rset);  /* 将文件描述符0(标准输入)添加到文件描述符集 */
		FD_SET(socket_fd, &rset); /* 将用于监听的socket套接字添加到文件描述符集 */
		maxfd = socket_fd;        /* 重置最大文件描述符集大小 */
		/* 5.1开始检测文件描述符 */
		ret = select(maxfd + 1, &rset, NULL, NULL, &tout);
		if(ret < 0)
		{
			perror("select");
			continue;
		}
		/* 5.2标准键盘文件描述符就绪时的处理 */
		if(FD_ISSET(0, &rset))
		{
			bzero(buf, BUFSIZ);/* 清空buf */
			do{
				ret = read(0, buf, BUFSIZ - 1);/* 从标准输入获取数据 */
			}while(ret < 0 && EINTR == errno);
			if (ret < 0)/* 获取数据失败 */
			{
				perror ("read() from stdin");
				continue;
			}
			/* 获取数据成功,但是标准输入中没有数据,不需要写入,继续循环即可 */
			if (!ret) continue;
			/* 向服务器发送数据 */
			if(write(socket_fd, buf, strlen(buf)) < 0)/* 将从标准输入获取的数据写入到socket 网络套接字中 */
			{
				perror ("write to socket error");
				continue;
			}
			/* 判断是否需要退出 */
			if (!strncasecmp(buf, "quit", strlen ("quit")))  	//用户输入了quit字符
			{
				printf ("Client is exiting!\n");
				break;
			}
			
		}
		/* 5.3socket文件描述符就绪时的处理(服务器有数据发送过来) */
		if(FD_ISSET(socket_fd, &rset))
		{
			bzero (replay, BUFSIZ);
			do{
				ret = recv(socket_fd, replay, BUFSIZ, 0);
			}while (ret < 0 && EINTR == errno);
			if(ret < 0)
			{
				perror("recv");
				continue;
			}
			/* 若服务器已关闭,则直接退出客户端 */
			if (ret == 0) break;
			printf("server replay:%s", replay);
			if (!strncasecmp(buf, "quit", strlen("quit")))
			{
				printf ("Sender Client is exiting... ...!\n");
				break;
			}
		}
	}
	/* 6.关闭文件描述符 */
	close(socket_fd);

	return 0;
}

/**
 * @Function: usage
 * @Description: 用户提示信息打印函数
 * @param str : 当前应用程序命令字符串,一般是./app
 * @return  : none
 */
void usage(char *str)
{
	printf ("\n%s serv_ip serv_port", str);
	printf ("\n\t serv_ip: server ip address");
	printf ("\n\t serv_port: server port(>5000)\n\n");
	printf ("\n\t client_port: client portClient(>5000 && !=port )\n\n");
}

2.3 Makefile

由于需要生成两个可执行程序,自己输命令有些繁琐,这里使用make来进行。

makefile
## =====================================================
# Copyright © hk. 2022-2022. All rights reserved.
# File name: Makefile
# Author   : fanhua
# Description: Makefile文件
## ======================================================
# 
CC = gcc

DEBUG = -g -O2 -Wall
CFLAGS += $(DEBUG)

# 所有.c文件去掉后缀
TARGET_LIST = ${patsubst %.c, %, ${wildcard *.c}} 
all : $(TARGET_LIST)

%.o : %.c
	$(CC) $(CFLAGS) -c $< -o $@

.PHONY: all clean clean_o clean_out
clean : clean_o clean_out
	@rm -vf $(TARGET_LIST) 
	
clean_o :
	@rm -vf *.o
	
clean_out :
	@rm -vf *.out

2.4 测试结果

我们执行以下命令编译链接程序,生成两个可执行文件:

shell
make

然后会有如下提示,并生成两个可执行文件吗,分别为服务器端程序server和客户端程序client

shell
gcc -g -O2 -Wall    client.c   -o client
gcc -g -O2 -Wall    server.c   -o server

对于客户端,我们执行以下命令启动客户端进程:

shell
./client 192.168.10.101 5001 5002 # 连接到本地一块网卡的IP,并设置客户端向外发送数据的端口为5002
  • LT模式启动

对于服务器端,我们执行以下命令启动服务器进程:

shell
./server 0.0.0.0 5001 # 允许监听所有网卡IP及端口

然后会得到以下提示:

shell
please select epoll mode(1:LT 2:ET):

我们选择输入1并按下enter后,服务器端中用于通信的文件描述符将会使用默认的工作模式,也就是LT模式,此时我们来看一下现象:

image-20220704114023374

可以发现我们发送的数据,并没有被读取,而服务器端会一直提醒有数据到达,提醒11次后,程序退出,这是因为我设置了超过10次就结束进程,否则会一直打印一直打印,这也说明了,LT模式下,不处理数据的话,内核会一直通知进行,并且epoll_wait()函数也不再阻塞了。

  • ET模式启动

对于服务器端,我们执行以下命令启动服务器进程:

shell
./server 0.0.0.0 5001 # 允许监听所有网卡IP及端口

然后会得到以下提示:

shell
please select epoll mode(1:LT 2:ET):

我们选择输入2并按下enter后,服务器端中用于通信的文件描述符将会使用ET模式,此时我们来看一下现象:

image-20220704114305589

可以发现我们发送的数据,并没有被读取,然后我们发送一次就提示一次,即便数据没有被处理。