本文用于笔者在学习《C++ 新经典》Linux C++ 通信架构
过程中,对项目开发的过程和项目内容框架做整体的总结与记录,更加深刻地体会整个项目的分配、架构布置和程序流程,同时对项目中遇到的一些问题以及将来遇到的框架细节作出进一步的解释。
本项目从无到有的搭建了一个质量较高的多线程高并发服务器项目程序框架
,除了没有实现具体的业务处理逻辑之外,已经是一个较为完善的服务器框架,源码可以参考Ngina-server。
项目实现了根据规定的数据包格式,在服务器端和客户端进行数据传输,同时服务器端可以根据客户端不同的数据包内容执行不同的业务处理逻辑。
在本项目中,主要的开发技术涉及 Linux
下 epoll 高并发技术
,线程池技术
,线程同步技术
和独特的延迟回收连接技术
等,同时配备了信号处理
、守护进程
、配置文件读取
等功能,为保证服务器稳定运行,还对许多细节进行了较为完善的处理,在一定程度上参考了 Nginx 的架构实现,其中许多细节也参考了 Nginx 的实现方式,是一个对笔者来说很有挑战性的一个项目。
项目源码可以在 Ngina-server 中查看,此处介绍源码文件的分布及各文件大致功能,项目分布如下:
项目根目录下包含了七个文件夹和四个文件,各文件夹和文件大致功能如下:
_include
,文件夹中存储了本项目全部的头文件,包括全部的函数声明、宏定义、全局变量声明等。
app
,文件夹中存放核心的文件,如程序入口函数、设置进程标题和配置文件读取等。
logic
,文件夹存放通信逻辑类的函数实现,比较重要。
misc
,文件夹存放不便于归类的一些文件,如线程池函数实现、内存分配和校验码等。
net
,文件夹中存放真正的核心文件,实现了许多重要的函数功能,如基础通信类、建立连接、连接请求、连接超时等核心文件,十分重要。
proc
,文件夹中存放进程相关的函数实现,如 master 进程、守护进程等函数实现。
signal
,文件夹中存放信号处理相关的函数实现,如注册信号处理函数等。
common.mk
,核心编译文件,用于编译。
config.mk
,编译配置文件,控制编译路径等。
makefile
,编译文件。
nginx.conf
,项目配置信息,存储许多重要的功能选项。
在熟悉项目文件分布后,接下来笔者将详细分析整个服务器的运行逻辑和函数流程,流程图如下:
在完成基本的项目架构后,需要明确一点,当前项目中有很多细节是在实现中不那么起眼的,笔者接下来会详细分析项目中一些值得推敲之处,以提问题的形式,对项目中的一些技术点进行拓展思考。
对于面试中可能会遇到的一些项目相关的问题以及网络编程相关问题进行分析。
本项目是我自己独立开发的一个多线程、高并发的服务器框架,采用了 epoll 水平触发模式实现高并发的通信技术,通过线程池设计处理客户端发送的数据包,为保证每一条连接的安全使用,设计了延迟回收连接的方式,尽可能消除影响服务器稳定的因素,同时为提高服务器稳定性,加入了许多细节处理,如控制并发连入数量、flood 攻击检测、心跳包和畸形数据包应对等。
在主进程完成基本的初始化后,根据配置文件中的信息开启指定数量的监听端口并加入监听队列,即创建 socket、bind、listen,再到子进程中,创建连接池,为每个监听端口分配封装后的连接对象并加入到 epoll 对象,监听端口便可以接受客户端连接,在接受客户端连接后为其分配一个新的 socket 连接保持通信,接收到读事件后便将收到的数据包放入到接收消息队列,由专门的接收消息线程处理,如果需要发送数据包,则根据发送逻辑将数据包放入待发送队列,由专门的发送消息线程处理。
线程之间常用的同步技术有互斥锁、条件变量、信号量等,在本项目中也是用到了这几种技术。
// 使用互斥锁来保证同一时刻只能有一个线程从消息队列中取出消息或者放入消息,以及各种队列
// 声明锁
pthread_mutex_t m_sendMessageQueueMutex;
// 上锁
pthread_mutex_lock(&pSocketObj->m_sendMessageQueueMutex);
// 解锁
pthread_mutex_unlock(&pSocketObj->m_sendMessageQueueMutex);
// 释放锁
pthread_mutex_destroy(&m_sendMessageQueueMutex);
// 初始化条件变量
pthread_cond_t CThreadPool::m_pthreadCond = PTHREAD_COND_INITIALIZER;
// 唤醒一个等待该条件的线程,也就是可以唤醒卡在pthread_cond_wait()的线程
pthread_cond_signal(&m_pthreadCond);
// 等待被唤醒
pthread_cond_wait(&m_pthreadCond);
// 唤醒全部线程
pthread_cond_broadcast(&m_pthreadCond);
// 释放条件变量
pthread_cond_destroy(&m_pthreadCond);
// 使用信号量,每当有消息被放入消息队列时,通知其余线程
// 声明信号量
sem_t m_semEventSendQueue;
// 初始化信号量,线程之间共享,为 0 时卡住
sem_init(&m_semEventSendQueue, 0, 0);
// 阻塞,等待信号量
sem_wait(&pSocketObj->m_semEventSendQueue);
// 激活卡住的线程
sem_post(&m_semEventSendQueue);
// 释放信号量
sem_destroy(&m_semEventSendQueue);
主线程 worker 进程中的 epoll 对象中的连接会调用读事件处理函数,将收到的消息处理后按指定格式放入同一个待处理消息队列,同时使用 pthread_cond_signal() 函数唤醒一个卡在 pthread_cond_wait() 处的线程,从队列中取出消息,此处需要注意因为可能存在惊群现象,唤醒多个线程,因此被唤醒的线程仍然需要去获取保护消息队列的锁,判断队列是否为空,不为空则直接处理,处理结束后释放锁,为空则循环继续卡在 pthread_cond_wait() 处。
进一步提问,假设我某次投递了 N 个任务,我想同时唤醒 N 个线程,这样要如何设计?
因为全部待处理的任务都是统一存放在线程共享的一个队列中的,线程只能排好队一个一个获取队列中的任务,因为每次向队列中放入一个任务,都会唤醒一个线程,因此队列中的消息肯定不会滞留,但是毕竟每个线程都需要获得锁后才能访问共享队列,因此还是需要排好队一个一个取。
常见的锁有以下几种:
读写锁,可以使得多个线程读数据,但仅能有一个县城写数据,并且写权限高于读,不能写时读。
互斥锁,即一个时刻仅能有一个线程获得资源访问权限,进行读写,缩短占用时间,也是很好的办法。
条件变量,可以通过变量值的改变通知其余等待的线程,可以搭配锁共同使用,是一种同步机制。
自旋锁,是一种应用于加锁时间很短的场景,效率高。
客户端通过监听端口连接到服务器后,会自动为其分配一个新的 socket 句柄和连接池中的一个连接保持通信,连接被绑定后会通过 epoll_ctl() 函数将读事件处理函数和写事件处理函数添加到 epoll 对象上,这样当连接收到对应的事件后便会自动调用设置的读写事件处理函数处理任务。读处理函数只负责将收到的数据读取出来并封装为规定的消息格式放入待处理消息队列,后续会有专门的线程处理,发送消息函数也是如此,因此线程仅对队列负责,不对连接负责,线程执行完一次任务后会自动阻塞,等待下一次被唤醒,线程池初始创建的线程数是固定的,不会再增加。线程池会记录当前工作的线程数,如果没有空闲的线程数,则会输出日志,考虑增加线程数。
触发 socket 可读事件有很多情况,如接收数据缓冲区有数据到达,对方正常关闭 socket,监听 socket 有新连接到达和 socket 出错。
当收到读时间用纸,但从接收数据缓冲区中读到的数据为 0,证明对方关闭连接,直接关闭本端连接即可。
服务器端基本流程如下:
// TCP 服务器端一般步骤是:
// 1、创建一个socket,用函数socket()
int isock = socket(AF_INET, SOCK_STREAM, 0);
// 2、设置socket属性,用函数setsockopt()
setsockopt(isock, SOL_SOCKET, SO_REUSEADDR, &reuseaddr, sizeof(reuseaddr));
// 3、绑定IP地址、端口等信息到socket上,用函数bind()
bind(isock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
// 4、开启监听,用函数listen()
listen(isock, NGX_LISTEN_BACKLOG);
// 5、接收客户端上来的连接,用函数accept()
s = accept4(oldc->fd, &mysockaddr, &socklen, SOCK_NONBLOCK);
// 6、收发数据,用函数send()和recv(),或者read()和write()
// 7、关闭网络连接
// 8、关闭监听
客户端基本流程如下:
// 1、创建套接字(socket)。
// 2、向服务器发出连接请求(connect)。
// 3、和服务器端进行通信(send/recv)。
// 4、关闭套接字。
总而言之,
Server端:create — bind — listen – accept – recv/send – close;
Client端:create — conncet — send/recv — close;
IO 多路复用是指,在单进程或单线程下,同时检测若干文件描述符是否可以执行 IO 操作的能力,因为在本服务器中,需要同时处理来自多个客户端的时间,但 CPU 单核在同一时间只能做一件事情,为减少进程、线程创建和切换的成本,于是需要能够在单一线程、进程中处理多个事件流的方法,有一种方法就是 IO 多路复用,本质是追求用更少的资源完成更多的事情。
在 Linux 下,提供了多种 IO 处理模型,分别如下,逐个分析。
阻塞 IO
,在发起一次 IO 操作后一直等待,直到成功或失败,期间不能做别的事情,只能对单个 fd 进行操作;非阻塞 IO
,通过循环的方式不断执行,失败则返回错误,这样的轮询方式会浪费很多资源,也只能对单个 fd 进行操作;信号驱动 IO
,利用信号机制通过内核告知,但是在网络编程中信号 SIGIO 产生原因很多,不适合使用;异步 IO
,与信号驱动类似,但是是在从用户态到内核拷贝完成后才通知程序,同步和异步的区别是同步会一直阻塞到 IO 完成,但异步不会阻塞当前程序运行。
IO 多路复用
,可以同时关注多个文件描述符,常用的包括三种,功能类似,但细节不同:
select
,使用一个 bitmap 保存全部文件描述符,如果有文件描述符可以进行 IO 操作,则会将对应的 bitmap 中置位,返回,通过线性扫描,便可查看是哪个文件描述符可以进行 IO 操作了;poll
,通过数组保存,也会标识对应位置,线性扫描,查找。缺点很明显,线性扫描复杂度高,而且每次都需要用户态和内核态之间拷贝,并且 select 还存在最大连接数上限,效率不高。并且,select
无法检测网络异常,当网络异常时,select 会检测到可读事件,read 会返回 0。
epoll
,通过红黑树保存全部文件描述符,发生事件的文件会保存到一张双向链表中,只需要返回链表中的数据即可,而且采用回调函数的方式,复杂度低。虽然不需要每次在内核态和用户态之间拷贝数据,但加入监听事件时需要拷贝到内核态,读取事件时需要从就绪链表中拷贝。还分为两种,水平触发模式和边缘触发模式,水平触发模式会一直通知,效率低,边缘触发模式仅通知一次,效率高。
虽然 IO 多路复用使得在单进程中即可处理多个 fd 事件通知,但也有一定的缺点,如业务逻辑处理难度增加,无法充分发挥多核处理器的性能等。
本项目使用了自定义的数据格式进行数据收发,通过 MFC 设计了一个小的测试工具,检测服务器运行是否正常,单一 worker 进程下每秒数据吞吐量在 10 k 左右,每秒收发包大概100个左右。
实现了一个专门的单例类,用于在堆区申请和释放指定大小的内存,再使用 placement new 创建具体的对象,同时实现了自动上锁、解锁的单例类,保护共享队列,为保证连接稳定,设计了延迟回收连接的方式,保证连接清空后再回收;对收到的数据包进行格式化处理,防止畸形数据包影响服务器稳定性。
首先是 TCP 三次握手,在连接开始时保证连接合法性;记录连接收发消息的相关信息,如是否一直在发送数据包但不接收数据包,这种连接直接主动关闭;对短时间多次频繁发送大量数据包的连接直接踢出,防范 flood 攻击;控制连入用户的最大数量,防止服务器崩溃;设计了心跳包相关技术,对迟迟不发送心跳包、占用连接数的连接踢出;如果短时间某一连接发送大量数据,即待接收队列中积压了太多来自某一连接的数据包,则将其从 epoll 对象中移除,处理完毕后再加入;对收到的数据包谨慎处理,如末尾设置结束符,防止恶意数据包导致内存非法访问等情况破坏服务器稳定。
第一种方式:
// 使用 top 命令,然后 shift+p 按照 CPU 排序,找到占用 CPU 过高的进程的 pid
top
shift+p
// 使用 top -H -p [pid] ,找到进程中消耗资源最高的线程的 id
top -H -p [pid]
// 使用 echo ‘obase=16;[pid]’ | bc “%x\n” [pid] 将线程 id 转换为16进制
echo ‘obase=16;[pid]’ | bc “%x\n” [pid]
// 执行 jstack [pid] | grep -A 10 [线程id的16进制] 查看线程状态信息
jstack [pid] | grep -A 10 [线程id的16进制]
在一个线程或进程之内,同时监控多个文件描述符的网络 IO 事件
,当事件发生后,将对应的文件描述符返回给进程,常用的函数有 select、poll、epoll。
在水平触发模式下,当 epoll 检测到 IO 事件后,会不断通知程序,不会产生事件丢失的情况,但是在大并发和大流量下,效率会比较低。
在边缘触发模式下,当 epoll 检测到 IO 事件后,金辉通知一次,效率更高,但对编程要求高,且容易产生数据丢失。
connect 方法会导致阻塞,为避免导致线程阻塞,可以将套接字设置为非阻塞,这样 connect 会立刻返回,或者添加定时器,也可以选择采用异步传输机制,立刻返回。
客户端掉线或者重启,服务器端会受到复位信号,但每种 TCP/IP 实现不同,及职业不同,在我的项目中加入了心跳包机制,如果客户端超时不发送心跳包,会主动断开并回收连接,减少资源浪费。
在 TCP 三次握手过程中,客户端的 connect 和服务器的 accept 函数如何发挥作用?
服务端的监听连接打开 accept 后便阻塞,当客户端调用 connect 后,发起三次握手第一次,当客户端收到服务端的第二次握手后,客户端的 connect 便可以返回,服务器收到客户端的第三次握手后, accept 返回一个 fd。
本文深入挖掘了项目相关的流程知识点,同时对相关问题作出解答,希望对深刻理解本项目有所帮助。
最后,我是Alkaid#3529,一个追求不断进步的学生,期待你的关注!