目录
一、Libevent 概述
1.0 Libevent的安装
1.0.1 使用源码方式
1.0.2 终端命令行安装
1.1 主要特性
1.2 主要组件
1.3 Libevent 使用模型
1.4 原理
1.5 使用的基本步骤
1.5.1 初始化事件基础设施
1.5.2. 创建和绑定服务器套接字
1.5.3. 设置监听事件
1.5.4. 定义事件处理回调函数
1.5.5. 启动事件循环
1.5.6. 释放资源
二、 使用示例
三、Libevent 实现 TCP 服务器
四、Libevent 结构图
五、守护进程
5.1 基本概念(面试)
5.2 如何实现呢?怎么理解
5.3 守护进程编程流程(面试)
5.4 实验
一、Libevent 概述
Libevent 是一个高效的、轻量级的事件通知库,用于开发需要处理大量并发连接的网络应用程序。它提供了一种机制来执行回调函数,当特定事件发生在文件描述符上,或在超时发生时,Libevent 可以处理不同类型的事件,包括 I/O 事件、信号事件和定时事件。
1.0 Libevent的安装
1.0.1 使用源码方式
Libevent 使用源码安装的方式,源码下载地址:http://libevent.org/ 下载下来后,将 Libevent 的压缩包拷贝到 Linux 系统中,然后按照以下步骤执行:
1、 打开终端,并且进入到 Libevent 所在位置
2、 切换到 root 用户
3、 利用 tar 命令解压 Libevent 压缩包
4、 进入到解压开的目录中
5、 执行命令: ./configuer --prefix=/usr
6、 使用 make 命令完成编译
7、 使用 make install 命令完成安装
8、 使用 ls -al /usr/lib | grep libevent 测试安装是否成功
1.0.2 终端命令行安装
- 切换到管理员身份:sudo su
- 执行命令:apt install libevent-dev
1.1 主要特性
- 跨平台支持:Libevent 支持多种平台,包括 Linux、BSD、Windows 和 macOS。
- 多种后端支持:Libevent 支持多种 I/O 复用后端,包括 select、poll、epoll、kqueue、devpoll 和 Windows IOCP。
- 高效的 I/O 处理:通过使用适当的 I/O 复用机制(如 epoll 或 kqueue),Libevent 能够处理大量并发连接,适用于高性能服务器开发。
- 事件优先级:Libevent 支持为事件设置优先级,使得重要事件可以优先处理。
- 线程安全:Libevent 提供线程安全的 API,可以在多线程环境中使用。
- 基于 Reactor 模式的实现。
1.2 主要组件
- event_base:事件循环的核心,用于管理所有的事件。
- event:表示一个具体的事件,比如一个文件描述符上的读写事件。
- bufferevent:用于处理缓冲 I/O,封装了事件和缓冲区的管理,提供更高层次的接口。
- evbuffer:提供灵活的缓冲区管理,可以高效地读写数据。
1.3 Libevent 使用模型
把描述符和回调函数注册到libevent中,让libevent检测是否有读事件发生,我们此时不用管底层io复用方法是如何实现的,等到事件发生就会调用我们注册的回调函数。也可以检测信号。
Libevent 通过高效的 I/O 复用机制(如 epoll、kqueue 等)和事件驱动模型来处理大量并发连接。这些机制允许一个线程同时监视多个文件描述符上的事件,从而避免了每个连接一个线程或进程的资源开销问题。
1.4 原理
-
I/O 复用机制:
- select:遍历整个文件描述符集,检查每个文件描述符的状态,适用于小规模并发连接。
- poll:类似于 select,但使用链表结构,支持更大规模的文件描述符。
- epoll(Linux)和 kqueue(BSD、macOS):高级的 I/O 复用机制,采用事件通知模型,适合处理大量并发连接。
- IOCP(Windows):基于完成端口的高效 I/O 复用机制。
-
事件驱动模型:
- Libevent 使用事件循环模型,通过
event_base_dispatch()
进入事件循环,等待并处理事件。 - 每个文件描述符上的读写事件、超时事件和信号事件都会注册到事件循环中。
- 当一个事件发生时,Libevent 会调用预先定义的回调函数来处理该事件。
- Libevent 使用事件循环模型,通过
1.5 使用的基本步骤
1.5.1 初始化事件基础设施
在使用 Libevent 之前,首先需要初始化事件基础设施,即创建一个
event_base
对象。这个对象是事件循环的核心,负责管理事件的分发和处理。初始化事件基础设施的目的是为了为后续的事件处理做准备,确保程序能够正确地处理事件。
1.5.2. 创建和绑定服务器套接字
在服务器端,需要创建一个监听套接字,并将其绑定到特定的 IP 地址和端口上。这个套接字将用于接受客户端的连接请求。将服务器套接字设置为非阻塞模式,可以确保服务器在等待客户端连接时不会被阻塞,可以同时处理多个连接请求。
1.5.3. 设置监听事件
为监听套接字设置事件,当有新的连接请求到达时,触发相应的回调函数。这样可以将新的连接请求转换为事件,方便后续的处理。事件驱动模型是一种高效的处理并发连接的方式,通过事件处理器来处理各种事件,避免了阻塞式的处理方式,提高了系统的并发能力。
1.5.4. 定义事件处理回调函数
为每种事件类型定义相应的处理回调函数。例如,针对新连接的事件,定义一个回调函数来处理新连接;针对读写事件,定义相应的回调函数来处理数据的读写操作。这些回调函数是处理事件的核心逻辑,通过它们来实现具体的业务逻辑。
1.5.5. 启动事件循环
调用
event_base_dispatch()
函数启动事件循环,开始等待并处理事件。事件循环是 Libevent 的核心机制,负责监视事件的发生,并调用相应的回调函数来处理这些事件。通过事件循环,可以实现高效地处理大量并发连接,提高系统的并发能力和性能。
1.5.6. 释放资源
在程序结束时,释放分配的事件和事件基础设施资源。这是良好的编程习惯,确保程序在结束时能够正确地释放资源,避免内存泄漏和资源泄漏问题。释放资源也包括关闭服务器套接字,释放事件基础设施等操作。
二、 使用示例
定义两个事件,一个是信号(ctrl+c)打印信号值,另一个是定时事件,将这两个事件加入到Libevent中,进行事件循环检测,当事件发生,自动的调用回调函数处理。
关键函数解释:
struct event*sig_ev=evsignal_new(base,SIGINT,sig_cb,NULL);
第一个参数是添加到那个libevent实例中,第二个参数是信号代号,第三个参数是回调函数,第四个参数是传给回调函数的参数。
struct event* sig_ev=event_new(base,SIGINT,EV_SIGNAL,sig_cb,NULL);
展开 EV_SIGNAL信号事件
evsignal_new 和 evtimer_new都是宏,他们统一的入口都是event_new函数。
Libevent 支持的事件类型
三、Libevent 实现 TCP 服务器
使用 libevent 库实现的 TCP 服务器代时,将监听 socket 和连接 socket 分别生成一个 Libevent 事件(指定其对应的回调函数),并将其添加到 Libevent 的一个 Base 中,执行事件 循环,检测事件发生。(客户端的代码与 select 部分客户端代码相同),代码示例如下:
1. #include <stdio.h>
2. #include <stdlib.h>
3. #include <assert.h>
4. #include <string.h>
5. #include <unistd.h>
6. #include <sys/types.h>
7. #include <sys/socket.h>
8. #include <netinet/in.h>
9. #include <arpa/inet.h>
10. #include <event.h>
11.
12. #define MAX_CLIENT 100 // 最大客户端数量
13. #define DATALENGTH 1024 // 数据缓冲区长度
14.
15. struct event_base *base = NULL; // 全局事件基础结构
16.
17. typedef struct ClientData
18. {
19. int fd; // 客户端文件描述符
20. struct event *ev; // 客户端事件
21. } ClientData;
22.
23. // 初始化客户端数据
24. void InitClients(ClientData clients[])
25. {
26. int i = 0;
27. for (; i < MAX_CLIENT; ++i)
28. {
29. clients[i].fd = -1; // -1表示未使用
30. clients[i].ev = NULL; // 初始化事件指针为空
31. }
32. }
33.
34. // 插入客户端数据
35. void InsertToClients(ClientData clients[], int fd, struct event *ev)
36. {
37. int i = 0;
38. for (; i < MAX_CLIENT; ++i)
39. {
40. if (clients[i].fd == -1)
41. {
42. clients[i].fd = fd; // 设置客户端文件描述符
43. clients[i].ev = ev; // 设置客户端事件
44. break; // 找到一个空位后跳出循环
45. }
46. }
47. }
48.
49. // 删除客户端数据
50. struct event *DeleteOfClients(ClientData clients[], int fd)
51. {
52. int i = 0;
53. for (; i < MAX_CLIENT; ++i)
54. {
55. if (clients[i].fd == fd)
56. {
57. clients[i].fd = -1; // 将文件描述符重置为-1
58. return clients[i].ev; // 返回相应的事件指针
59. }
60. }
61.
62. return NULL; // 未找到时返回NULL
63. }
64.
65. // 初始化 socket
66. int InitSocket()
67. {
68. int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
69. if (sockfd == -1) return -1; // 出错返回-1
70.
71. struct sockaddr_in saddr;
72. memset(&saddr, 0, sizeof(saddr)); // 清空地址结构
73. saddr.sin_family = AF_INET; // 设置地址族为IPv4
74. saddr.sin_port = htons(6000); // 设置端口号(网络字节序)
75. saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置IP地址
76.
77. int res = bind(sockfd, (struct sockaddr*)&saddr, sizeof(saddr)); // 绑定套接字
78. if (res == -1) return -1; // 出错返回-1
79.
80. res = listen(sockfd, 5); // 开始监听
81. if (res == -1) return -1; // 出错返回-1
82.
83. return sockfd; // 返回套接字文件描述符
84. }
85.
86. // 客户端事件处理函数
87. void client_fun(int fd, short event, void *arg)
88. {
89. ClientData *clients = (ClientData*)arg;
90.
91. char buff[DATALENGTH] = { 0 };
92. int n = recv(fd, buff, DATALENGTH - 1, 0); // 接收数据
93. if (n <= 0)
94. {
95. struct event *ev = DeleteOfClients(clients, fd); // 删除客户端
96. event_free(ev); // 释放事件
97. printf("A Client Disconnect\n"); // 打印断开连接消息
98. return;
99. }
100.
101. printf("%d:%s\n", fd, buff); // 打印接收到的数据
102. send(fd, "OK", 2, 0); // 发送回应
103. }
104.
105. // 服务器套接字事件处理函数
106. void sockfd_fun(int fd, short event, void *arg)
107. {
108. ClientData *clients = (ClientData*)arg;
109. struct sockaddr_in caddr;
110. socklen_t len = sizeof(caddr);
111. int c = accept(fd, (struct sockaddr*)&caddr, &len); // 接受客户端连接
112. if (c < 0)
113. {
114. return; // 出错直接返回
115. }
116.
117. struct event *ev = event_new(base, c, EV_READ | EV_PERSIST, client_fun, arg); // 创建新事件
118. InsertToClients(clients, c, ev); // 插入客户端数据
119. event_add(ev, NULL); // 添加事件到事件基础结构
120. printf("A client Link\n"); // 打印连接成功消息
121. }
122.
123.
124. int main()
125. {
126. int sockfd = InitSocket(); // 初始化服务器套接字
127. assert(sockfd != -1); // 断言检查套接字是否创建成功
128.
129. ClientData clients[MAX_CLIENT]; // 定义客户端数据数组
130. InitClients(clients); // 初始化客户端数据
131.
132. base = event_init(); // 初始化事件基础结构
133.
134. struct event *ev = event_new(base, sockfd, EV_READ | EV_PERSIST, sockfd_fun, (void*)clients); // 创建服务器套接字事件
135. event_add(ev, NULL); // 添加事件到事件基础结构
136.
137. event_base_dispatch(base); // 进入事件循环
138. event_free(ev); // 释放事件
139. event_base_free(base); // 释放事件基础结构
140.
141. exit(0); // 退出程序
142. }
这是一个简单的基于Libevent库的服务器程序示例。它创建一个监听端口(6000),通过事件循环处理客户端连接和数据传输。服务器能够同时处理多个客户端连接,每个客户端连接都会被添加到一个事件中,并且在接收到数据时会调用相应的回调函数来处理。代码中定义了ClientData
结构体用于保存客户端的文件描述符和事件,并提供了初始化、插入和删除客户端的函数。主函数初始化服务器套接字和事件基础结构,设置事件处理函数,并进入事件循环,直到程序结束。
四、Libevent 结构图
五、守护进程
5.1 基本概念(面试)
守护进程(Daemon)是一种在后台运行的计算机程序,不直接与用户交互。通常,它们在系统启动时启动,并持续运行直到系统关闭。这类进程通常执行系统级任务,比如日志记录、处理网络请求、管理打印任务等。守护进程的几个主要特点包括:
后台运行:守护进程通常在后台运行,不会直接与用户进行交互。它们可以在系统引导时启动,并在整个系统运行期间保持活动状态。
长时间运行:守护进程通常需要长时间运行,不会因为完成某个任务后就退出,而是持续运行以处理多个任务或事件。
独立运行:一旦启动,守护进程通常独立于启动它的终端,甚至终端关闭后它们仍然可以继续运行。
系统服务:守护进程提供各种系统服务,例如网络服务(如HTTP服务器、FTP服务器)、系统任务(如cron调度器)、设备管理(如打印机服务)等。
命名规范:在Unix和类Unix系统中,守护进程的名字通常以字母“d”结尾,例如,
httpd
(处理HTTP请求)、sshd
(处理SSH连接)、crond
(管理定时任务)。
5.2 如何实现呢?怎么理解
我们跟系统之间每次打开一个终端交互的时候,等于和内核之间建立了一个会话。一旦关闭终端,这个会话就会结束。会话中运行的所有进程都会被结束,而我们希望这个进程,服务进程能够在后台长久的运行,所以我们在关闭会话的时候,那么我们就不能关闭这个进程。这个进程不能关闭,如何解决呢?
我们就必须让这个进程脱离这个会话,它不属于这个会话,那你关闭它就不会结束这个进程了。举个例子,假如你们是三班,我说三班同学都出去,那三班所有人都出去,但是我现在不想让小明出去,我说小明你以后就是四班的人,然后我说三班人都出去,你们都出去,小明是不是就被留下来了?就跟这个道理一样,就现在我们需要这个进程长久的执行,但是我们在关闭这个终端的时候,所有会话也就这个会话中所属的所有进程全部被结束,那就只能怎么办?让你不属于这个会话,但你还得有个会话啊。所以这个就是创建一个新会话,就等于把你放到新会话中,然后我把原来会话结束,新会话我们保留下来,那为啥原来的会话不能留下来,你这个新会话可留下来,因为新会话不附着于任何终端。我们之前跟系统之间交互,是不是都是打开一个终端?那现在这个地方创建的时候,我们这个会话并没有依存于这个终端,所以关闭这个终端,这个会话它不结束。我们每一次跟系统交互啊,通过打开这个终端来和它交互的时候,那么我们这个会话你可以理解为附着于这个终端之上,如果这个终端关闭这个会话,是不是就结束了?但是我们内部通过程序去创建,是我们不依附于那个终端,所以关闭那个终端,我们的会话它不结束。
如下图所示:假如你打开了这么一个终端。我们会为此创建一个会话,这个会话与这个终端相关联,终端一旦关闭,该会话就结束了。在这个会话中,我们要执行一个一个的命令,其实这些命令(进程)呢,又是以进程组的形式呈现了,也就是说,我们其实管理它的时候是先创建一个进程组,然后再去执行我们的命令,我们用三角形代表进程,或者咱们的命令。比如说你执行ls,那么你执行ls的时候,它不止创建了一个进程,还创建一个进程组,只不过该进程组中只有你一个人。那这个进程的结束,这个进程组也就随时结束了,但有时候咱们可以fork,或者把多个进程合在一起去执行,所以呢,我们也可能在这个地方发现这个进程组中有多个进程。那多个进程的话,我们是这样的,第一个进程,它是我们的组长进程,我们会用它的ID号标识整个进程组,当然它也可能里头只有两个进程,都有可能存在,也可能是一个,并且大部分情况下可能这个进程组都只有一个进程。解释一个概念:会话。怎么表示这个会话呢?我们让会话中的第一个进程用它的ID号来标识这个会话,所以我们会有个概念叫做会话首进程,就是会话首进程的PID,就是会话ID。这个进程的PID用来干什么呢啊?用来标识该会话。那么这个进程组又是什么呢?它就有一个组长进程,这个组长进程就是我们在这个进程组中第一个运行的那个进程。你也可以理解为我们当前进程的PID,如果等于我们的组长ID等于我们进程。组的ID那你就是我们的组长,也可以理解为我们使用这个组长进程ID出来标识整个进程组。
5.3 守护进程编程流程(面试)
- fork() 退出父进程
- setsid()
- fork() 退出父进程
- chdir("/")
- umask(0)
- close()
第一次为啥要fork?
因为我们要把当前进程要拿出来重新创建一个会话,那么这个时候呢?我们会成为整个会话中的第一个进程,就成为了会话首进程。要用我们的ID标识整个会话,其次呢,我们也是整个会话中是不是运行的第一个进程,那这个进程是本身也得是一个进程组,对不对?也要生成一个进程组,也就说我们要用这个进程ID号来命名一个进程组,它会变成我们的组长进程,那么我们思考一下。我们这个进程如果在原来的会话中,本来就是一个进程组的组长,那么我们把它拿出来,如果去调用sets ID创建新会话,那么它在新会话中是不是也是一个继承组的组长,那等于就是我要用这个进程的一个ID是不是要标识两个继承组?这就不合适了。所以我怎么办呢?我需要挑一个普通的组员进程,把它拿出来啊,然后让它能变成这个新会话中的首进程,这样我们是不是可以用这个组员进程ID来标识我们一个新的进程组?就比如说咱们班有两个组小明是一个组的组长,我现在要成立第二个组,我能不能再把小明揪出来做第二个组,我是不是要揪一个组员出来做第二个组?所以在这块一样,就是我们如果只有一个进程的话,那我们自己本来就是一个进程组的组长,我们已经用我们的ID号是标识一个进程组了。但是一旦我们把它拿出来调用set SD去创建新会话,去成立第二个会话,那么它是不是在第二个会话中,那么它还会扮演着该会话内部一个进程的进程组,因为那个会话中就他一个进程嘛,对不对?他自身肯定还要有一个组来管理他,就是他自己的ID的。创建的一个组,但是已经用他ID是不是在旧的会话中?是不是已经创建过一个组了?所以我们第一步先fork,就会有个什么结果呢?退出父进程,那我们留下是不是一个子进程?可以这么讲,留下这个子进程。肯定是一个组员进程。所以们就保证我们先拿到一个组员,组员再调sets ID就会创建一个新会话,那这个组员就会变成新会话的首进程。同时也是这个新会话中目前出现第一个进行组的组长,那然后我们再fork一次?那么就会使我们失去组长的身份,失去首会话进程的身份。因为你fork的一个子进程嘛。你又变成子进程了嘛,这样父进程就丢掉了嘛。为什么要做这个事情呢?这个事情其实可以不做啊,那么有些书上这么讲的就是它可以跟一个终端,就是再通过执行相应的操作。关联起来,但是如果你不是会话首进程的话,你没有这个能力,所以就等于让它失去这个能力,防止它你不小心在于哪个终端去执行了某某个操作。去关联起来,就比如说某个函数一调,可以附着于某个终端,对吧?因为我们希望你是不是后面要长久运行呢?所以把这个操作一执行的话,那么你就失去了会话,手机上的身份,那么就不用再担心你会不小心又掉了某个操作,把你跟哪个终端?去附着在一起的,比如我们在这儿调创建新的话,是不是好不容易从原来终端中,但是脱离出来了,掉色台是从原来终端中?所在这个会话中就脱离出来了,那你就不要把你再去和其他已打开终端再去关联了,你一关联人家一关把你识别就关掉了。它是起这么一个作用,但你不做这样操作是不做第三步也不影响,大部分人也不会去干这样的事情。有些资料上就不做第三步,有些资料上做了,所以做你就知道就这个原因,他不做他也对,
然后现在就变成变成新会话中的一个会话首进程,而且是个进程组的组长,然后通过第三步一执行,我们就失去了会话首进程和进程组长身份,我们就变成一个普通组员进程,就让他去长久执行。因为我们运行时间比较长,我们把当前的工作路径要切换到根目录底下,不能把它出现在一些可被卸载的目录上,就比如说我们如果在U盘中运行一个程序。你一旦把这个U盘想要弹出这个程序,还在运行,没结束,你会发现U盘是弹不出去的,这跟这个道理一样,因为这个路径是不是正在被你用?因为实物进程运行时间比较长,所以不管从哪执行,我们都把它的工作路径直接调到根目录底下,因为不可能有人去卸载根。当然,你如果整个目录也并不会被卸载,你没做第四步,其实也不影响,所以一般我们还是把它做上就行了。
然后这个是清除掩码,因为你挪到根目录,或者说你在啊某一个固定的目录底下。你也不知道那个木水下线的这个掩码是多少,那我们把它清零就行了,不要把不用的描述符关闭就可以,你后面想要用哪些,你再打开哪些就行了。那这样一套流程走下来之后,那么这个程序就脱离了这个终端,在后台运行嘛,然后呢,它就可以长久的执行了,只要你不q它,它就不结束。因为它不负责终端关终端,它还在运行,只要程序自己不想退出,你也不q它,它就可以一直在后台运行,它也不需要跟你进行交互,因为这个close点标准输入标准输出标准错误输入全都关闭了。你也别想让他让他打印信息了,那么他如果想要有一些信息提示你怎么办?因为这个程序总得有输出。他一般会写日志文件,磁盘上创建一个文件,所以服务器一般都会有一个日志啊,写日志的这么一个。文件,来存放一些发生的事情,同时还得有个日志的进程,专门用来啊,管理写这个日志啊,我系统几点几分启动?现在几点几分?我访问数据库,哪个用户登录?就是这些,我觉得我需要记录就把它存起来,我这个函数报error了,我把它存一下。本来这种咱们就打屏幕,因为现在没屏幕,让你用了。而且管理员不可能一直盯着屏幕去看,写到日志中的话,是不是永久存到磁盘上了?比如说啊,管理员过了两天想看,那你过两天再把它打开看就行了。你要在屏幕上的话,有新信息,把它不就刷新了吗?你就观察不到了啊,所以这样就可以做。
fork()
退出父进程:
fork()
系统调用会创建一个子进程,子进程是父进程的副本。在创建子进程后,父进程和子进程会在fork()
调用的返回值处分别得到不同的值,父进程得到子进程的进程ID(PID),而子进程得到0。这样,父进程和子进程可以通过返回值来区分彼此。在守护进程创建过程中,父进程负责创建子进程,然后退出,让子进程继续独立运行。- 原因:父进程退出后,子进程会成为孤儿进程,并被
init
进程接管,从而确保守护进程不会与任何终端关联。
setsid()
:
setsid()
系统调用会创建一个新的会话,并将调用进程设置为该会话的领导。该进程成为新会话的唯一成员,没有控制终端。- 原因:守护进程需要脱离控制终端,以免受到终端的影响,
setsid()
调用可以确保守护进程完全独立于终端会话。
fork()
退出父进程:
- 这一步是为了确保守护进程不会重新获取控制终端。在已经创建了新会话的前提下,再次调用
fork()
是为了防止守护进程通过setsid()
成为会话领导后再次获取控制终端。- 原因:通过再次调用
fork()
,确保守护进程不会意外地重新获取控制终端。
chdir("/")
:
- 将当前工作目录更改为根目录(
/
),确保守护进程不会占用任何文件系统。- 原因:避免守护进程的当前工作目录影响其他进程的操作,也防止某些文件系统被锁定。
umask(0)
:
- 将文件创建掩码设置为0,即不屏蔽任何权限,确保守护进程创建的文件具有完全开放的权限。
- 原因:避免文件权限问题,确保守护进程创建的文件具有所需的权限。
close()
:
- 关闭所有从父进程继承的文件描述符,包括标准输入、输出和错误描述符(
stdin
、stdout
、stderr
)。- 原因:关闭继承的文件描述符,以防止守护进程意外地使用这些文件描述符与终端或其他进程进行交互,同时也释放了不需要的系统资源。
5.4 实验
写一个程序,让它每隔五秒钟向文件中写入当前时间,以此来模拟这个程序一直在后台执行。
至此,已经讲解完毕!篇幅较长,慢慢消化,以上就是全部内容!请务必掌握,创作不易,欢迎大家点赞加关注评论,您的支持是我前进最大的动力!下期再见!