# proconnect **Repository Path**: EdwardElric233/proconnect ## Basic Information - **Project Name**: proconnect - **Description**: proconnect 是一款高效而用户友好的多进程网络通信框架,专为Linux平台设计。它使用epoll系统调用实现IO复用,采用统一的事件源处理方式,无缝地处理定时任务等复杂操作。proconnect 能够通过配置文件自动建立全连通的通信连接,极大地简化了网络设定过程。通过使用proconnect,用户可以快速、简便地构建稳定的多进程网络通信环境,从而提高开发效率和运行效能。 - **Primary Language**: Unknown - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 0 - **Created**: 2023-05-17 - **Last Updated**: 2023-05-26 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 多进程通信框架 # 项目介绍 在场景中有固定数目的终端,每个任务终端执行一系列的任务,同时需要和控制终端进行交流,包括控制终端向任务终端发出指令、任务终端向控制终端汇报状态、任务终端之间需要协同完成任务等。 为了应对灵活多变的任务场景,项目的目标是开发一套简单易用的多进程通信框架,进程之间通过socket进行全双工通信,每个进程使用IO复用监听对应socket文件描述符上的事件,将其加入到自己的任务队列中,再使用多线程进行处理。 终端的操作系统为Linux系统,要求使用C语言进行编码。 # 中心化框架 ## 框架设计 控制终端监听固定的端口,控制终端的IP和端口信息通过读入配置文件获取,在每个任务终端启动阶段,对其控制终端的端口尝试进行连接,连接成功后向控制终端和日志文件写入成功启动信息。 每个终端在启动成功后会采用linux的IO复用系统调用epoll_wait阻塞在对应的服务端socket文件描述符上,等待来自其他终端的消息。在收到消息后会根据协议格式进行解析,将其转换成任务对象后加入到对应的任务队列中等待处理,向控制终端发送对应的信息,控制终端处理后进行记录或者转发。初始阶段使用LT模式再每次从epoll_wait返回后单线程处理任务,后面考虑在ET模式下采用生产者消费者模式多线程优化(单Reactor模式)。 控制终端在与所有任务终端建立连接后,采用linux的IO复用系统调用epoll_wait同时监听所有任务终端的socket,负责发送、接收、处理并转发与任务终端的消息。 消息的格式采用ProtoBuf。 ## 框架实现 在多个进程通信的框架中,被动等待连接的一方(一般被称为服务端,也就是这里的控制终端)需要处理的事件主要有三个半事件: - 连接建立 - 连接断开 - 消息到达 - 消息发送完毕(半个事件) 最后一个事件往往我们并不关心,对于比较关心的前三个事件我们可以分别设置回调函数,等待用户填充业务逻辑。 使用epoll也符合我们进程之间连接虽多但是活动连接较少的情况。 ### 消息字段说明 框架中使用的消息包含以下字段: 1. 发送方编号(sender_id):整型字段,用于标识消息的发送方。 2. 接收方编号(receiver_id):整型字段,用于标识消息的接收方。 3. 指令编号(command_id):整型字段,表示要执行的指令的编号。 4. 指令数据(command_data):整型字段,用于传递指令的数据。 5. 附加信息(additional_info):可选的字符串字段,用于传递额外的信息。 以上字段将按照 Protocol Buffers 的格式进行序列化和反序列化,以便在网络中传递消息。 # 去中心化框架 ## 框架设计 之前框架的设计思路是存在一个中心化的控制终端负责所有任务终端消息的收集、转发,任务的调度分配。但是在实际场景中,这种中心化的设计模式会使得延迟比较大,因此还需要对框架进行改进,使得任意两个任务终端之间都可以进行全双工通信,从而让任务终端自己进行消息传递和任务调度。 在这种去中心化的设计思路下,要求每个任务终端既是客户端,也是服务端。每个终端都监听固定的端口(由配置文件指定)等待其他终端的连接,当与其他所有终端的连接都建立完成后,向初始控制终端(由配置文件指定)发送连接建立完成的消息,初始控制终端开始调度任务。 > 由于每个终端既是客户端,也是服务端,那么连接建立的顺序就变得非常重要——我们无法一边监听连接端口,同时又和其他终端建立连接,而且每条连接总有主动发起的一方,如何确定由谁发起,被动一方又要求必须在此之前已经完成了连接建立,经过思考,建立连接的过程仍然是需要一个中心化的控制,我们不妨就让初始控制终端负责:要求初始控制终端必须首先运行,其他终端都主动连接初始终端,初始终端在收集到其他终端连接建立完成的消息后就向其他终端发令要求他们互相之间建立连接,这样就可以确保任意终端在尝试连接其他终端的时候,其他终端已经就绪。 > > > 初始控制终端不需要主动发起连接,其他终端不需要再次向初始控制终端发起连接,只需要根据配置文件给出的向目标终端发起连接,在所有连接建立后通知初始控制终端进行任务调度。 > 上面的方案当然可行,然而却显得有一些傻——不仅要求某个终端首先运行,而且后面的连接建立也是半手动进行的(虽然其他终端没有开始运行的依赖,但是其实是由控制终端控制),这与我们去中心化的进程通信框架设计理念不符,经过思考后重新设计通信建立的机制: 每个终端在启动后首先监听固定的端口,根据配置文件中指定的编号主动非阻塞地连接其他终端,使用epoll进行IO复用,同时处理主动连接和被动连接,等到所有连接建立完成后通知初始控制终端进行任务调度。 这里有一个需要处理的问题:如果主动连接其他终端时其他终端尚未启动该如何处理?此时的connect调用会立即返回,但是立即尝试再次connect显然是不明智的选择,会自旋在这个connect调用上,而导致其他终端也无法连接该终端。为了避免这种情况,可以在connect失败后设置定时任务,在一段时间后再次尝试connect。 配置文件的格式采用json。 ## 消息格式 由于项目无法使用malloc和free动态分配内存,因此也无法使用protobuf-c进行消息的序列化和反序列化。因此决定自己进行消息的序列化和反序列化。 消息的字段说明如下: - 发送方编号(sender_id):1个字节,用于标识消息的发送方。 - 接收方编号(receiver_id):1个字节,用于标识消息的接收方。 - 指令编号(command_id):1个字节,表示要执行的指令的编号。 - 指令数据(command_data):1个字节,用于传递指令的数据。 每条消息的长度固定为4字节。根据不同的指令编号,终端会执行不同的指令。 ### 消息编号 - 0:确认消息ACK,指令数组字段为确认的消息类型 - 1:身份认证消息,在主动连接一方发现连接建立完成后立马向对端发送一条消息,指令数据字段无意义 - 2: 通知控制终端消息,0表示已就绪 ## 框架实现 程序会要求用户指定终端编号,终端在运行后会从配置文件中读入相应数据获得自己和其他终端的ip和端口等信息。 ### 连接建立 在完成配置后,终端先在epoll上注册监听连接socket等待其他终端连接,再尝试主动连接其他终端。如果连接失败,就将连接事件加入定时器列表,否则就进行非阻塞connect,在epoll上注册写事件等待连接建立,当从epoll_wait返回时,使用getsockopt判断连接状态,如果连接失败,将连接事件加入定时器列表,直到连接成功。 在实现的时候发现当对端尚未监听端口的时候进行connect,可能会当场返回失败,也可能会返回正在处理,再通过epoll_wait返回。 ### 确认编号 在连接建立的过程中,主动发起连接的一方是知道对端的终端编号的(通过配置文件读入),但是被动连接的一方并不知道对端的编号,也无从知道,因为主动发起连接一方的socket端口地址是操作系统指定的,如果终端确定是在不同ip上运行还可以通过ip进行区分,可是这样不利于分布式拓展。 为了得到对端的编号,也为了保证通信建立的成功,在连接建立后要求主动连接一方必须立即向被动连接一方发送确认编号消息,消息编号为1。 被动建立连接一方收到1号消息后知道对方在通知自己它的编号,因为之前accept时是随机给定了一个编号,此时就要对比编号是否正确,如果不正确就进行改正。然后发送一个ACK消息0,表示自己已经得到了正确的编号。 ### 配置文件 配置文件包含了整个通信框架搭建所需要的信息,具体来说: - 每个任务终端的编号、ip和端口号 - 每个任务终端主动连接的终端编号 - 初始控制终端的编号 需要思考如何在配置文件中指定每个终端主动连接的终端编号。为了使得每个终端连接其他终端的负载尽可能均衡,在36个终端的情况下,每个终端应该连接36*35/2/36=17-18个连接。这里我们构造每个终端i都连接i+1到i+18的终端,并处理重复情况。 刚开始使用配置文件的格式为json,使用Jansson库进行解析。但是项目要求不能使用malloc和free进行动态分配内存,因此也提供解析txt格式的简单配置文件的能力。 # 运行环境 确保你的系统是基于 Linux 的操作系统,如 Ubuntu、Debian等,框架不支持其他操作系统。使用C语言进行搭建,为了编译和运行框架的源代码,你需要在 Linux 系统上安装 C 语言的编译器,如 GCC。 框架使用 log.c 作为其日志库。请确保你的系统上已经正确安装了 log.c。你可以从 log.c 的官方 GitHub仓库([https://github.com/rxi/log.c](https://github.com/rxi/log.c))获取安装指南和源代码。 框架使用 protobuf-c 库来处理 Protocol Buffers 消息。请确保在你的系统上已正确安装 protobuf-c。你可以从 protobuf-c 的官方 GitHub 仓库([https://github.com/protobuf-c/protobuf-c)](https://github.com/protobuf-c/protobuf-c) 获取安装指南和源代码。 此外,由于配置文件采用的是JSON格式,框架还需要用到 Jansson 库来解析 JSON 文件。请确保你的系统上已正确安装 Jansson。你可以通过包管理器进行安装,如在Ubuntu或Debian系统中,可以通过运行`sudo apt-get install libjansson-dev`来进行安装。也可以从 Jansson 的官方 GitHub 仓库([https://github.com/akheron/jansson)](https://github.com/akheron/jansson) 获取安装指南和源代码。 # 运行指南 程序需要指定进程编号,并可选地指定日志级别、配置文件路径和终端数目。以下是如何运行程序的简要指南: - **进程编号**:必须作为第一个参数提供,表示进程的编号。例如,`./proconnect 5` - **日志级别**:可选参数,使用`-log-level`后接`DEBUG`、`INFO`、`WARN`或`ERROR`。例如,`./proconnect 5 --log-level DEBUG` - **配置文件路径**:可选参数,使用`-config`后接文件路径。例如,`./proconnect 5 --config /path/to/config.json` - **终端数目**:可选参数,使用`-terminal-num`后接一个整数,表示终端的数目。例如,`./proconnect 5 --terminal-num 10` 你可以同时指定日志级别、配置文件路径和终端数目,例如: ```bash ./proconnect 5 --log-level DEBUG --config /path/to/config.json --terminal-num 10 ``` 此命令将设置进程编号为5,日志级别为`DEBUG`,从指定路径加载配置,并设置终端数目为10。所有参数应以空格分隔。 # 调试日志 ## 2023.05.24 11:00 在基本完成了连接的建立后我写了一个简单的启动集群程序进行测试,运行程序会创建三个终端进程,并分别编号为0、1、2,1号终端会主动连接0号终端,2号终端会主动连接0号和1号终端。但是运行后效果却非常的诡异:一会连接建立正常,一会会有一个连接在2号终端看起来已经建立,但是0号终端并没有看到,通过lsof命令查看端口信息可以看到: ![Untitled](images/Untitled.png) 对于在端口40680和5000之间的TCP连接,在进程23134看起来并不存在。实际上日志信息中也没有这条连接请求,但是在进程23136看起来这条连接已经建立成功了,并在之后向其发送通知,只是没有回应。 这是我第一次遇到这种奇怪的现象,在网络上搜索也没有找到什么有用的信息,没有办法我只能自己硬着头皮调试。 首先我尝试控制启动进程的速度,试图观察一下这个现象是否是一种竞态,在启动集群程序中,我控制每隔一秒启动一个终端进程,观察日志后发现没有出现异常,这说明异常出现的原因和进程的并发启动有关系,但是我还是不理解为什么连接看起来都已经建立了,进程0却没有观察到。 休息了一晚后今天早上我回过头认真的思考,刚开始觉得是不是和端口的状态有关系,但是仔细一看我只有监听的端口设置了端口复用,而且消息很少,根本不可能发生残留的消息造成的干扰。 随后我又思考是不是我在建立连接后在我没有注意到的情况下关闭了连接。为了确定这种情况,我使用wireshark对对应的端口进行抓包,发现并没有连接关闭的现象,相反,那条看起来没有建立成功的连接已经完成了三次握手,主动连接一方甚至还发送了数据,被动连接一方回应了ACK,但是日志中并没有这条消息。 ![Untitled](images/Untitled%201.png) 这让我更加陷入了郁闷,于是我怀疑是否是在accept连接后我没有正确的保留socket文件描述符,仔细检查后发现的确有一个bug,但是并不是造成问题的原因:我忘记把listen fd 保存到对应的数据结构中。 认真检查了一下accept的出错处理,修复了因为信号中断后错误退出的问题 但是我还是没有搞清楚问题出在哪里,正盯着日志发呆,我突然观察到在0号终端accept 1号终端之前,2号终端已经发起了连接请求,为什么0号终端没有accept 2号终端呢?难道是由于设置了边沿触发导致只处理了一次accept事件?仔细一想果然如此,由于我错误地将监听socket设置为ET模式,而在处理accept的时候又每次只接收一个连接,这才导致连接在内核看起来已经就绪,但是进程没有接管,从而出现了上面的诡异现象。 修改listen fd 为LT模式成功解决问题,当然也可以在ET模式下循环调用accept直到非阻塞返回。 ## 2023.05.24 16:00 在解决了将listen fd 设为ET模式导致的bug后,进程集群基本可以正常建立通信链路,但是这座大厦上还有一片乌云(笑:修改配置文件使得36个进程终端互相主动连接后,有时候初始控制终端会汇报还有一两个终端没有就绪。 根据设计,当一个终端完成所有主动连接任务后,会向初始控制终端汇报自己依旧就绪,初始控制终端的编号由配置文件写入。只有初始控制终端获得所有终端的就绪消息后才会进行任务的调度,因此如果有终端还没有就绪就无法开始工作。 经过早上bug的修复,我认为问题应该不在连接的建立上,可能是我的消息通知哪里存在问题,但是肉眼观察实在难以发现。于是我将集群的数量减小为4个,经过多次运行,在某次失败的状况下对照日志中每个进程收发的消息进行手动分析,发现问题在于:如果一个终端不必要向初始控制终端主动发起连接,当它认为自己就绪向初始控制终端发送就绪消息的时候,初始控制终端可能还没有建立和它的连接,从而导致就绪消息丢失。 为了解决这个问题,比较简单的方式是规定初始控制终端不得主动发起连接,只能够被动接受连接,但是这样与去中心化的设计思路相悖。如果某个终端已经就绪却发现初始控制终端尚未建立连接(说明这个终端不负责主动发起和初始控制终端的连接),那么只能等待一段时间后再尝试发送就绪消息。由于框架中本身就有定时器事件机制,我们将发送就绪消息的动作加入定时器事件即可。