一、为什么服务端选择事件驱动
服务端要同时服务多个客户端。最直观的做法是"一个连接一个线程":

但这个模型在预约系统这类场景下并不划算,原因是:
-
预约业务的计算量极小,主要时间花在等网络和等数据库。
-
线程大部分时间在阻塞等待,却各自占用栈空间和内核调度资源。
-
连接数一多,线程切换开销急剧上升。
本项目采用的是事件驱动模型(Reactor):

优势一目了然:
| 维度 | 多线程模型 | 事件驱动模型 |
|---|---|---|
| 线程数 | 随连接数增长 | 固定 1 个 |
| 内存占用 | 每线程 MB 级栈 | 共享一份 |
| 上下文切换 | 频繁 | 几乎没有 |
| 编程模型 | 直观 | 回调式 |
| 适用场景 | CPU 密集 | IO 密集 |
预约系统是典型的 IO 密集型:CPU 只在解析 JSON、拼 SQL 时短暂工作,其余时间都在等。事件驱动正好把"等待"这段时间让给其他连接,单线程就能撑住大量并发。
libevent 帮我们把这些细节封装好了------底层自动选择 epoll(Linux)、kqueue(BSD/macOS)或 select,上层统一用 event 接口。
二、libevent 三个核心概念
用一句话概括 libevent 的模型:
把"fd + 关心的事件 + 回调函数"打包成一个
event,注册到event_base,然后启动循环,事件就绪时自动调用回调。
三个核心对象:

本项目里,event_base 只有一个,event 有两个层级:
-
监听套接字的 event:关心"有新连接到来"。
-
每个客户端连接的 event:关心"这个连接有数据可读"。
三、socket_listen:监听者的职责
socket_listen 类封装了服务端的"入口"。
3.1 初始化:socket / bind / listen 三步走
cpp
bool socket_listen::socket_init()
{
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd) return false;
struct sockaddr_in saddr;
memset(&saddr, 0, sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr(m_ips.c_str());
int res = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (-1 == res) { close(sockfd); return false; }
res = listen(sockfd, LIS_MAX);
if (res == -1) return false;
return true;
}
这三步是 TCP 服务端的标准动作:

LIS_MAX = 10 是全连接队列长度 :已完成三次握手但还没被 accept 取走的连接,最多排 10 个。
3.2 accept_client:从队列里取一个连接
cpp
int socket_listen::accept_client()
{
int c = accept(sockfd, NULL, NULL);
return c;
}
accept 返回一个全新的 fd,代表"这条具体的客户端连接"。
这里有一个容易混淆的点:

两个 fd 分开,是事件驱动模型的关键:监听 fd 上的可读事件 = 有新连接;连接 fd 上的可读事件 = 有数据到达。两者用不同的回调处理。
3.3 持有 event_base
cpp
class socket_listen
{
public:
void Set_base(struct event_base* base) { this->base = base; }
int Get_sockfd() const { return sockfd; }
struct event_base* Get_base() const { return base; }
private:
int sockfd;
short m_port;
string m_ips;
struct event_base* base; // 与 main 共享同一个事件循环
};
socket_listen 不自己创建 event_base,而是通过 Set_base 接收外部传入的。这样设计的好处是:
-
event_base全局唯一,所有 event 都挂在同一个循环上。 -
类本身的职责纯粹:只管监听,不管循环。
这是一种依赖注入的思想:谁创建,谁管理;谁使用,谁接收。
四、每个连接一个 socket_con
这是本项目服务端最精妙的设计之一:为每个客户端连接创建一个独立的对象。
4.1 为什么需要"每连接对象"?
每条连接都有自己的上下文:
-
通信用的 fd(
c) -
挂在自己身上的 event(
c_ev) -
本次请求解析出的 JSON(
val)
如果所有连接共用一个结构,就无法区分"现在处理的是哪个客户端"。
所以:
cpp
class socket_con
{
public:
socket_con(int fd) : c(fd) { c_ev = NULL; }
void Set_ev(struct event* ev) { c_ev = ev; }
~socket_con()
{
event_free(c_ev);
close(c);
}
void Recv_data();
void Send_err();
void Send_ok();
void User_Resgister();
void User_Login();
void User_Show_Ticket();
void User_Subscribe_Ticket();
void User_Show_Sub_Ticket();
void User_Cancel_Sub_Ticket();
private:
int c; // 本连接的 fd
struct event* c_ev; // 本连接注册的事件
Json::Value val; // 本连接当前的请求数据
};
一个 socket_con = 一条连接的完整生命周期:

4.2 生命周期的自管理
连接断开时,recv 返回 0 或负数,Recv_data 里的处理是:
cpp
void socket_con::Recv_data()
{
char buff[256] = {0};
int n = recv(c, buff, 255, 0);
if (n <= 0)
{
cout << "client close" << endl;
delete this; // ← 自己删自己
return;
}
// ...
}
delete this 会触发析构函数:
cpp
~socket_con()
{
event_free(c_ev); // 从 event_base 摘掉
close(c); // 关闭 fd
}
三步连锁释放:

这样保证了连接断开时资源不会泄漏 ,也不需要外部维护一个"连接表"来清理。每个对象自己管好自己,是典型的 RAII(资源获取即初始化) 思想。
五、两级回调:从新连接到业务处理
libevent 的回调驱动分两级:监听级 和连接级。
5.1 监听级回调:SOCK_LIS_CALLBACK
cpp
void SOCK_LIS_CALLBACK(int sockfd, short ev, void *arg)
{
socket_listen *p = (socket_listen *)arg;
if (p == NULL) return;
if (ev & EV_READ)
{
int c = p->accept_client();
if (c == -1) return;
cout << "accept c=" << c << endl;
socket_con *q = new socket_con(c);
struct event *c_ev = event_new(p->Get_base(), c,
EV_READ | EV_PERSIST,
SOCK_CON_CALLBACK, q);
if (c_ev == NULL)
{
close(c);
delete q;
return;
}
q->Set_ev(c_ev);
event_add(c_ev, NULL);
}
}
这段代码做了四件事:

这里体现了"回调套回调"的设计:

事件从"监听层"向下传递到"连接层",每一层只关心自己的事件,职责清晰。
5.2 连接级回调:SOCK_CON_CALLBACK
cpp
void SOCK_CON_CALLBACK(int c, short ev, void *arg)
{
socket_con *q = (socket_con *)arg;
if (ev & EV_READ)
{
q->Recv_data();
}
}
它只做一件事:把事件转发给对象自己的方法。
arg 是 event_new 时传入的 q(socket_con*),所以回调能拿到具体是哪个连接------这就是把上下文和事件绑定在一起的关键。
5.3 EV_PERSIST:一次注册,多次触发
cpp
event_new(base, c, EV_READ | EV_PERSIST, SOCK_CON_CALLBACK, q);
EV_PERSIST 的含义是:事件触发后不自动移除,保持注册状态。
不加 EV_PERSIST 会怎样?

加了 EV_PERSIST 之后:

一条 TCP 连接通常会承载多次请求 (用户登录后还会预约、查看、取消),所以连接级事件必须是 EV_PERSIST。
这是一个从"短连接思维"切换到"长连接思维"的关键点。
六、业务路由:从 JSON 到函数调用
事件到达后,Recv_data 完成从字节流 → JSON → 函数的转换。
6.1 完整的路由链路
cpp
void socket_con::Recv_data()
{
char buff[256] = {0};
int n = recv(c, buff, 255, 0);
if (n <= 0) { delete this; return; }
cout << "recv:" << buff << endl;
Json::Reader Read;
if (!Read.parse(buff, val))
{
cout << "Recv_data:解析Json失败" << endl;
Send_err();
return;
}
int ops = val["type"].asInt();
switch (ops)
{
case DL: User_Login(); break;
case ZC: User_Resgister(); break;
case CKYY: User_Show_Ticket(); break;
case YD: User_Subscribe_Ticket(); break;
case CKYD: User_Show_Sub_Ticket(); break;
case QXYD: User_Cancel_Sub_Ticket(); break;
default: break;
}
}
链路图:

每一层都在做"类型收窄":
字节流 → 字符串 → JSON对象 → 整数 → 函数指针
这是典型的分层解析思路。每一层只负责一件事,出错时也好定位。
6.2 参数直接取自 JSON
业务函数不接收参数,而是自己从 val 成员里取:
cpp
void socket_con::User_Subscribe_Ticket()
{
int tk_id = val["index"].asInt();
string tel = val["tel"].asString();
mysql_client cli;
if (!cli.mysql_ConnectServer()) { Send_err(); return; }
if (!cli.mysql_Subscribe_Ticket(tk_id, tel)) { Send_err(); return; }
Send_ok();
}
为什么不让函数带参数?
-
val是成员变量 ,每个socket_con一份,天然隔离。 -
业务函数可以按需取字段,不同操作取不同字段,不用为每个函数定制参数列表。
-
路由
switch里写起来统一:case YD: User_Subscribe_Ticket(); break;。
这是一种"共享上下文 + 按需取值"的设计,简洁且扩展方便。
七、统一响应:Send_ok / Send_err
服务端有两个发送辅助函数:
cpp
void socket_con::Send_err()
{
Json::Value res_val;
res_val["status"] = "ERR";
send(c, res_val.toStyledString().c_str(),
strlen(res_val.toStyledString().c_str()), 0);
}
void socket_con::Send_ok()
{
Json::Value res_val;
res_val["status"] = "OK";
send(c, res_val.toStyledString().c_str(),
strlen(res_val.toStyledString().c_str()), 0);
}
所有业务函数的返回路径都是这两者之一:

统一用 status 字段表达结果,客户端处理也统一:
cpp
string st = val["status"].asString();
if (st.compare("OK") != 0)
{
cout << "操作失败" << endl;
return;
}
协议统一 → 服务端统一 → 客户端统一,这是接口设计中非常值得学习的一致性思维。
唯一例外是 User_Login,它需要在成功时额外携带用户名:
cpp
void socket_con::User_Login()
{
string tel = val["user_tel"].asString();
string password = val["user_password"].asString();
string user_name;
mysql_client cli;
if (!cli.mysql_ConnectServer()) { Send_err(); return; }
if (!cli.mysql_Login(tel, password, user_name)) { Send_err(); return; }
Json::Value res_val;
res_val["status"] = "OK";
res_val["user_name"] = user_name;
send(c, res_val.toStyledString().c_str(),
strlen(res_val.toStyledString().c_str()), 0);
}
这是一种渐进式响应格式 :基础格式固定,需要时再加字段。客户端只有登录逻辑会读 user_name,其它逻辑只读 status,互不干扰。
八、主函数:把一切串起来
cpp
int main()
{
// 1. 监听套接字
socket_listen sock_ser;
if (!sock_ser.socket_init())
{
cout << "socket_init err" << endl;
exit(1);
}
// 2. 创建 libevent base
struct event_base *base = event_init();
if (base == NULL)
{
cout << "base nurr" << endl;
exit(1);
}
// 3. 把 base 交给监听者
sock_ser.Set_base(base);
// 4. 监听套接字也作为事件注册
struct event *sock_ev = event_new(base,
sock_ser.Get_sockfd(),
EV_READ | EV_PERSIST,
SOCK_LIS_CALLBACK,
&sock_ser);
event_add(sock_ev, NULL);
// 5. 启动事件循环
event_base_dispatch(base);
// 6. 释放资源
event_free(sock_ev);
event_base_free(base);
}
整个服务端的骨架就这六步:

event_base_dispatch 是分界线:
-
之前:初始化阶段,一切同步执行。
-
之后:交给 libevent,回调开始驱动业务。主线程"消失"在事件循环里,直到程序退出才返回。
这就是 Reactor 模型的核心思想------把控制权交给事件循环,让回调驱动一切。
九、事件驱动设计的四个优点
回到整体设计,本项目的服务端体现了事件驱动模型的几个核心优势:
9.1 资源占用极低
一个线程就能处理所有连接,不需要为每个客户端分配线程栈。
多线程模型: 线程栈 × N + 内核调度开销
本模型: 1 个线程 + event_base + 每连接一个小对象
9.2 上下文天然隔离
每个连接一个 socket_con,val 和 c 都是成员变量:
socket_con#1 ─ val: {"type":5,...} c: 5
socket_con#2 ─ val: {"type":3,...} c: 6
socket_con#3 ─ val: {"type":1,...} c: 7
不存在共享数据,也不需要加锁。并发安全性由架构保证。
9.3 扩展路径清晰
如果业务逻辑变重(比如加缓存、加业务规则),只需要在 socket_con 的业务函数里扩展,事件循环和网络层完全不用改。

三层解耦,改一层不影响其他层。
9.4 回调参数传递统一
两个回调函数签名一致:
cpp
void SOCK_LIS_CALLBACK(int sockfd, short ev, void *arg);
void SOCK_CON_CALLBACK (int c, short ev, void *arg);
arg 分别指向 socket_listen 和 socket_con,通过 void* 传递。这就是 libevent 的通用回调接口设计------任何对象只要提供回调函数,就能被纳入事件循环。
十、面试高频题
Q1:libevent 的 Reactor 模型是怎么工作的?
答:Reactor 核心是"事件循环 + 回调"。libevent 把 fd、事件类型、回调函数打包成
event,注册到event_base。底层用 epoll/select 监听所有 fd,任一 fd 就绪时,event_base_dispatch调用对应回调。本项目里监听 socket 的回调负责accept,每个连接的回调负责recv和业务处理。
Q2:为什么每个连接要 new 一个 socket_con?
答:每条连接需要独立的上下文------自己的 fd、自己的 event、自己的请求 JSON。用对象封装这些状态,一是隔离性好 ,不同连接互不干扰;二是生命周期清晰 ,
delete this时统一释放 fd 和 event,符合 RAII 思想。
Q3:EV_PERSIST 有什么用?
答:让事件触发后保持注册状态。不加的话事件触发一次就被移除,只能处理一条消息;加上之后可以持续监听,一条长连接可以处理多次请求,正好匹配"用户登录后连续操作"的场景。
Q4:事件驱动相比多线程,优势在哪?
答:预约系统是 IO 密集型,业务计算量小,大部分时间在等网络和数据库。事件驱动用一个线程处理所有连接,避免线程切换和栈开销,资源占用低,扩展性好。多线程模型适合 CPU 密集型任务,IO 密集场景反而浪费资源。
Q5:主函数里 event_base_dispatch 之后为什么没代码了?
答:
event_base_dispatch是阻塞 的,进入事件循环后不会返回,直到调用event_base_loopbreak或出错。之后的资源释放代码只在程序退出时执行。这就是 Reactor 的分界------初始化在主线程同步执行,之后就交给回调驱动。
十一、一句话总结
服务端的本质是"一个 event_base 驱动两级回调"------监听回调负责迎接新连接,连接回调负责处理请求,每个连接一个 socket_con 管好自己的上下文与生命周期。