
大家好,我是Halcyon.平安
欢迎文末添加好友交流,共同进步!
一、模块概述
会话管理模块负责维护客户端的用户登录状态。HTTP 是无状态协议,服务器需要一种机制来"记住"哪个用户已经登录------这就是 Session(会话)。本模块实现了完整的 Session 生命周期管理,包括创建、查找、超时删除、永久保持,以及一种"代校验"机制来防止旧定时器误删新会话。
| 类 | 功能说明 |
|---|---|
session |
会话实体类 --- 保存单个用户的 ssid、uid、登录状态、定时器 |
session_manager |
会话管理器 --- 创建/查找/删除/设置过期时间,管理所有会话 |
模块依赖关系:
util.hpp(wsserver_t 类型定义)
↓
session.hpp(本模块,会话管理)
↓
server.hpp(上层模块通过 session_manager 管理用户登录状态)
二、完整源码 --- session.hpp
cpp
//session模块:保存客户端的用户状态信息
#ifndef __M_SESSION_H__
#define __M_SESSION_H__
#include "util.hpp"
#include <websocketpp/server.hpp> /*引入 WebSocket 服务器的核心功能*/
#include <websocketpp/config/asio_no_tls.hpp> /*引入【非加密、异步】WebSocket 配置*/
#include <unordered_map>
typedef enum{
UNLOGIN,
LOGIN
} ss_status;
class session{
private:
uint64_t _ssid; //标识符
uint64_t _uid; //用户id
ss_status _status; //用户状态:是否登录
wsserver_t::timer_ptr _tp; //session关联的定时器
uint64_t _timer_gen; //定时器代计数器,每次设置新定时器时递增
public:
session(uint64_t ssid):_ssid(ssid), _uid(0), _status(UNLOGIN), _timer_gen(0) { DLOG("SESSION %p 被创建!", this); }
~session(){ DLOG("SESSION %p 被释放!", this); }
uint64_t ssid() { return _ssid; }
void set_status(ss_status status) { _status = status; }
void set_user(uint64_t uid) { _uid = uid; }
uint64_t get_user() { return _uid; }
bool is_login() { return (_status == LOGIN); }
void set_timer(const wsserver_t::timer_ptr &tp) { _tp = tp; }
//引用,返回的是本体,方便外部修改
wsserver_t::timer_ptr &get_timer() { return _tp; }
//获取现在是第几代定时器
uint64_t timer_gen() { return _timer_gen; }
//刷新一代,让旧定时器直接作废
void inc_timer_gen() { _timer_gen++; }
};
#define SESSION_TIMEOUT 30000
#define SESSION_FOREVER -1
using session_ptr = std::shared_ptr<session>;
class session_manager{
private:
uint64_t _next_ssid;
std::recursive_mutex _mutex;
std::unordered_map<uint64_t, session_ptr> _session;
wsserver_t *_server;
public:
session_manager(wsserver_t *srv):_next_ssid(1), _server(srv) { DLOG("session管理器初始化完毕!"); }
~session_manager() { DLOG("session管理器即将销毁!"); }
session_ptr create_session(uint64_t uid, ss_status status)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
session_ptr ssp(new session(_next_ssid));
ssp->set_user(uid);
ssp->set_status(status);
_session[ssp->ssid()] = ssp;
_next_ssid++;
return ssp;
}
void append_session(const session_ptr &ssp)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
_session[ssp->ssid()] = ssp;
}
session_ptr get_session_by_ssid(uint64_t ssid)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
auto it = _session.find(ssid);
if(it == _session.end())
{
return session_ptr();
}
return it->second;
}
//带代校验的session删除:只有当 session 当前的 timer_gen 与 expected_gen 一致时才删除
//这样被 cancel 的旧定时器回调触发时,代已不匹配,不会误删 session
void remove_session_by_gen(uint64_t ssid, uint64_t expected_gen)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
auto it = _session.find(ssid);
if(it == _session.end())
{
return;
}
if(it->second->timer_gen() != expected_gen)
{
//代不匹配,说明定时器已被替换/取消,不删除
return;
}
_session.erase(ssid);
}
void set_session_expire_time(uint64_t ssid, int ms)
{
//依赖于websocketpp的定时器来完成session生命周期的管理
//在http通信的时候(登录、注册)session应具备生命周期,指定时间无通信后删除(短链http)
//登录之后,创建session,session需要在指定时间无通信后删除
//但是进入游戏大厅,或者游戏房间,这个session应该永久存在
//等退出游戏大厅,或者游戏房间,这是session应该被重新设置为临时,在长时间无通信后被删除
//在客户端建立websocket长连接之后,session应该是永久存在的
session_ptr ssp = get_session_by_ssid(ssid);
if(ssp.get() == nullptr) //获取智能指针内部保存的原始指针session*
{
return;
}
wsserver_t::timer_ptr tp = ssp->get_timer(); //如果session对象存在,获取超时定时器设置的是多少
//1. 因为没有超时定时设置,所以session永久存在,且我们也想设置永久存在
if(tp.get() == nullptr && ms == SESSION_FOREVER)
{
return;
}
//2. 因为没有超时定时设置,所以session永久存在,但我们想设置定时删除
else if(tp.get() == nullptr && ms != SESSION_FOREVER)
{
ssp->inc_timer_gen();
//自带定时器创建函数,需要传入超时时间和需要调用回调函数;
//等待 ms 毫秒后,调用当前 session_manager 的 remove_session_by_gen 函数(带代校验)
//成员函数不会不能自己跑,必须知道谁调用的它,我们这里让他自己调用自己,所以传入this指针
wsserver_t::timer_ptr tmp_tp = _server->set_timer(ms,
std::bind(&session_manager::remove_session_by_gen, this, ssid, ssp->timer_gen()));
//这个是我自己创建的,直接把系统函数创建的定时器对象传入session里面,方便修改
ssp->set_timer(tmp_tp);
}
//3. 在session设置了定时删除的情况下,我们想将session设置为永久存在
else if(tp.get() != nullptr && ms == SESSION_FOREVER)
{
//先递增代,这样旧定时器回调触发时代会不匹配,不会误删session
ssp->inc_timer_gen();
tp->cancel();
ssp->set_timer(wsserver_t::timer_ptr());//将session关联的定时器设置为空
}
//4. 在session设置了定时删除的情况下,我们想将session重置删除时间
else if(tp.get() != nullptr && ms != SESSION_FOREVER)
{
//先递增代,使旧定时器回调失效
ssp->inc_timer_gen();
tp->cancel();
ssp->set_timer(wsserver_t::timer_ptr());//将session关联的定时器设置为空
//创建新定时器,绑定带代校验的删除函数
wsserver_t::timer_ptr tmp_tp = _server->set_timer(ms,
std::bind(&session_manager::remove_session_by_gen, this, ssid, ssp->timer_gen()));
//重新设置session关联的定时器
ssp->set_timer(tmp_tp);
}
}
};
#endif
三、会话实体类 --- session
3.1 类结构总览
cpp
class session{
private:
uint64_t _ssid; // 会话唯一标识符
uint64_t _uid; // 关联的用户ID
ss_status _status; // 登录状态(UNLOGIN / LOGIN)
wsserver_t::timer_ptr _tp; // 关联的超时定时器
uint64_t _timer_gen; // 定时器代计数器
};
| 字段 | 类型 | 说明 |
|---|---|---|
_ssid |
uint64_t |
会话 ID,由 session_manager 自增分配,全局唯一 |
_uid |
uint64_t |
该会话关联的用户 ID,初始为 0(未关联任何用户) |
_status |
ss_status |
登录状态枚举,UNLOGIN 或 LOGIN |
_tp |
wsserver_t::timer_ptr |
WebSocket++ 定时器智能指针,用于控制会话超时 |
_timer_gen |
uint64_t |
定时器"代"计数器,防止旧定时器回调误删新会话 |
3.2 登录状态枚举
cpp
typedef enum{
UNLOGIN,
LOGIN
} ss_status;
| 值 | 含义 | 场景 |
|---|---|---|
UNLOGIN |
未登录 | 用户注册后创建的会话初始状态 |
LOGIN |
已登录 | 用户通过密码验证后切换到此状态 |
3.3 构造函数
cpp
session(uint64_t ssid)
: _ssid(ssid), _uid(0), _status(UNLOGIN), _timer_gen(0)
{
DLOG("SESSION %p 被创建!", this);
}
解析:
- 构造时只传入
ssid,其他字段使用默认值:_uid为 0(未关联用户),_status为UNLOGIN,_timer_gen从 0 开始。 _tp(定时器)未在初始化列表中出现,timer_ptr是智能指针类型,默认构造为空(不关联任何定时器),session 创建时默认永久存在。DLOG打印对象地址(%p+this),方便调试时追踪 session 的创建和释放配对情况。
3.4 定时器代管理方法
cpp
uint64_t timer_gen() { return _timer_gen; } // 获取当前代
void inc_timer_gen() { _timer_gen++; } // 递增代,使旧定时器失效
这是本模块的核心设计。_timer_gen 是一个单调递增的计数器,每次设置新定时器前调用 inc_timer_gen()。定时器回调绑定时捕获当前的代号,执行时与 session 的当前代比对------如果代不匹配,说明这个定时器已经"过时"了,回调应该跳过操作。详细机制见第七节"代校验机制"。
3.5 定时器访问方法
cpp
void set_timer(const wsserver_t::timer_ptr &tp) { _tp = tp; }
// 引用,返回的是本体,方便外部修改
wsserver_t::timer_ptr &get_timer() { return _tp; }
set_timer--- 将 session 与一个定时器关联,参数是常量引用,避免智能指针的额外拷贝。get_timer--- 返回_tp的引用 ,而不是拷贝。注释特意说明"返回的是本体,方便外部修改"。这样外部拿到引用后可以直接修改_tp,比如ssp->get_timer() = wsserver_t::timer_ptr()就能直接置空 session 的定时器,而不用先 get 再 set。
3.6 两个全局常量
cpp
#define SESSION_TIMEOUT 30000 // 30秒超时,HTTP 短连接场景使用
#define SESSION_FOREVER -1 // 永久存在,WebSocket 长连接场景使用
| 常量 | 值 | 含义 | 使用场景 |
|---|---|---|---|
SESSION_TIMEOUT |
30000 | 30 秒后定时器触发删除 session | HTTP 短连接:注册/登录后、断开 WS 后 |
SESSION_FOREVER |
-1 | session 永久存在 | WebSocket 长连接:进入大厅/房间后 |
四、会话管理器 --- session_manager
4.1 类结构总览
cpp
class session_manager{
private:
uint64_t _next_ssid; // 下一个可用的会话ID
std::recursive_mutex _mutex; // 递归互斥锁
std::unordered_map<uint64_t, session_ptr> _session; // ssid → session 的哈希表
wsserver_t *_server; // WebSocket 服务器指针(用于创建定时器)
};
| 字段 | 类型 | 说明 |
|---|---|---|
_next_ssid |
uint64_t |
自增计数器,保证每个 session 的 ssid 全局唯一 |
_mutex |
std::recursive_mutex |
递归互斥锁,保护哈希表的并发访问 |
_session |
unordered_map<uint64_t, session_ptr> |
会话存储,以 ssid 为键 |
_server |
wsserver_t * |
保存服务器指针,用于调用 set_timer 创建定时器 |
为什么用 recursive_mutex 而不是普通 mutex?
因为 set_session_expire_time 内部调用了 get_session_by_ssid,而后者也会加锁:
cpp
void set_session_expire_time(uint64_t ssid, int ms)
{
session_ptr ssp = get_session_by_ssid(ssid); // ← 这里会加锁
// ...
}
如果用普通 mutex,同一线程二次加锁会死锁。递归锁允许同一线程多次加锁,内部维护一个计数器,加几次就要解几次,不会阻塞自己。
这与 online.hpp 和 db.hpp 的锁策略不同:
online.hpp--- 用普通mutex,因为每个方法内部不会再调用本类的其他加锁方法db.hpp--- 用普通mutex,只保护mysql_exec + mysql_store_result这一对原子操作session.hpp--- 必须用recursive_mutex,因为存在方法间调用关系
4.2 创建会话 --- create_session
cpp
session_ptr create_session(uint64_t uid, ss_status status)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
session_ptr ssp(new session(_next_ssid)); // 用当前 _next_ssid 创建 session
ssp->set_user(uid); // 绑定用户ID
ssp->set_status(status); // 设置登录状态
_session[ssp->ssid()] = ssp; // 存入哈希表
_next_ssid++; // ssid 自增,为下一次分配准备
return ssp; // 返回智能指针,外部可使用
}
| 参数 | 类型 | 说明 |
|---|---|---|
uid |
uint64_t |
要绑定的用户 ID |
status |
ss_status |
登录状态,注册时传 UNLOGIN,登录时传 LOGIN |
| 返回值 | session_ptr |
新创建的会话智能指针,已绑定 uid 和 status |
解析:
session_ptr ssp(new session(_next_ssid))--- 用_next_ssid作为 ssid 创建 session 对象,用shared_ptr包装。_next_ssid从 1 开始,每次创建后自增,保证全局唯一。ssp->set_user(uid)/ssp->set_status(status)--- 将 session 与用户绑定。注册时先创建UNLOGIN状态的 session,登录成功后改为LOGIN;也有直接以LOGIN状态创建的场景。_session[ssp->ssid()] = ssp--- 以 ssid 为键存入哈希表。使用operator[]而非insert,如果 ssid 已存在会覆盖------但因为 ssid 是自增分配的,正常情况下不会重复。- 创建时不设置定时器 ,session 默认永久存在,需要后续调用
set_session_expire_time来设置超时。
调用时机:
- server.hpp 的
reg_handler(注册回调)--- 注册成功后创建UNLOGIN状态的 session - server.hpp 的
login_handler(登录回调)--- 登录成功后创建LOGIN状态的 session
4.3 查找会话 --- get_session_by_ssid
cpp
session_ptr get_session_by_ssid(uint64_t ssid)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
auto it = _session.find(ssid);
if(it == _session.end())
{
return session_ptr(); // 返回空智能指针,表示未找到
}
return it->second;
}
| 参数 | 类型 | 说明 |
|---|---|---|
ssid |
uint64_t |
要查找的会话 ID |
| 返回值 | session_ptr |
找到返回 session 智能指针,未找到返回空指针 |
解析:
- 通过 ssid 在哈希表中查找,O(1) 复杂度。
- 找不到时返回
session_ptr()(空的shared_ptr),上层通过ssp.get() == nullptr或直接if(ssp)判断是否查找到有效 session。 - 返回的是
it->second,即哈希表中存储的智能指针的拷贝 (引用计数 +1)。即使哈希表中的条目随后被其他线程删除,调用者持有的session_ptr副本仍然有效,session 对象不会被提前释放。
调用时机:
- server.hpp 的
get_session_by_cookie方法 --- 从 HTTP 请求的 Cookie 中提取 ssid,查找对应的 session set_session_expire_time内部 --- 先查找 session 再操作其定时器
4.4 追加会话 --- append_session
cpp
void append_session(const session_ptr &ssp)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
_session[ssp->ssid()] = ssp;
}
| 参数 | 类型 | 说明 |
|---|---|---|
ssp |
const session_ptr& |
要加入哈希表的 session 智能指针 |
将一个已存在的 session 指针加入(或重新加入)哈希表。与 create_session 的区别是:create_session 会分配新的 ssid 并创建新 session 对象,而 append_session 不创建,只是把已有的 session 放回哈希表。
使用场景: 定时器回调触发删除后,如果 session 被重新激活(比如在定时器回调执行前用户又建立了新连接),需要重新加入管理。目前本项目代码中未直接使用此方法,但作为管理器的完整接口保留。
4.5 带代校验的删除 --- remove_session_by_gen
cpp
void remove_session_by_gen(uint64_t ssid, uint64_t expected_gen)
{
std::unique_lock<std::recursive_mutex> lock(_mutex);
auto it = _session.find(ssid);
if(it == _session.end())
{
return; // session 已不存在,无需处理
}
if(it->second->timer_gen() != expected_gen)
{
return; // 代不匹配,说明定时器已被替换,不删除
}
_session.erase(ssid); // 代匹配,安全删除
}
| 参数 | 类型 | 说明 |
|---|---|---|
ssid |
uint64_t |
要删除的会话 ID |
expected_gen |
uint64_t |
创建定时器时绑定的代号,用于校验定时器是否过期 |
解析:
这个方法不由上层直接调用 ,而是作为定时器回调函数使用。它被绑定到 set_timer 的回调中,在定时器触发时由 asio 事件循环调用。
执行逻辑分为两步校验:
- session 是否存在 :
find查找 ssid,如果不存在(可能已被其他逻辑删除),直接返回。 - 代号是否匹配 :比较 session 当前的
timer_gen与创建定时器时绑定的expected_gen。如果代不匹配,说明定时器已经被 cancel 并替换过(用户重连或切换了状态),这个旧回调不应该执行删除。
只有两个校验都通过,才执行 _session.erase(ssid)。erase 后哈希表中存储的 session_ptr 被销毁,引用计数 -1,如果上层没有其他地方持有该 session,session 对象被自动释放。
详细机制见第七节"代校验机制"。
4.6 设置会话过期时间 --- set_session_expire_time
这是本模块最复杂的方法,处理四种定时器状态切换场景。
cpp
void set_session_expire_time(uint64_t ssid, int ms)
| 参数 | 类型 | 说明 |
|---|---|---|
ssid |
uint64_t |
要操作的会话 ID |
ms |
int |
超时毫秒数,SESSION_TIMEOUT(30000) 表示 30 秒超时,SESSION_FOREVER(-1) 表示永久 |
前置检查:
cpp
session_ptr ssp = get_session_by_ssid(ssid);
if(ssp.get() == nullptr) { return; } // session 不存在,直接返回
wsserver_t::timer_ptr tp = ssp->get_timer(); // 获取当前关联的定时器
- 先通过
get_session_by_ssid查找 session(这里利用了recursive_mutex,允许嵌套加锁)。 ssp.get() == nullptr--- 获取shared_ptr内部的原始指针,如果是空说明 session 不存在。tp = ssp->get_timer()--- 获取 session 当前关联的定时器。tp.get() == nullptr表示没有定时器(session 永久存在),tp.get() != nullptr表示有定时器(session 设置了超时)。
四种场景分析:
| 场景 | 当前定时器 | 目标设置 | 含义 | 触发场景 |
|---|---|---|---|---|
| 1 | 无(永久) | 永久 | 已经是永久,不需要操作 | 重复设置永久 |
| 2 | 无(永久) | 定时删除 | 从永久切换为定时删除 | 注册/登录后首次设置超时 |
| 3 | 有(定时) | 永久 | 从定时切换为永久 | 进入游戏大厅/房间 |
| 4 | 有(定时) | 定时删除 | 重置超时时间 | 退出大厅/房间,刷新 HTTP |
场景 1:永久 → 永久
cpp
if(tp.get() == nullptr && ms == SESSION_FOREVER)
{
return; // 已经是永久,无需操作
}
session 当前没有定时器(永久存在),目标也是永久------什么都不用做。这个分支看起来"没用",但它是完整状态机的必要组成部分,防止漏到后面的 else-if 分支。
场景 2:永久 → 定时删除
cpp
else if(tp.get() == nullptr && ms != SESSION_FOREVER)
{
ssp->inc_timer_gen(); // 递增代,防止与残留定时器冲突
wsserver_t::timer_ptr tmp_tp = _server->set_timer(ms,
std::bind(&session_manager::remove_session_by_gen, this, ssid, ssp->timer_gen()));
ssp->set_timer(tmp_tp); // 关联新定时器
}
解析:
-
ssp->inc_timer_gen()--- 递增代计数器。虽然此时没有旧定时器需要 cancel,但递增一代是统一的防御性编码,防止极端情况下与残留回调冲突。 -
_server->set_timer(ms, callback)--- 调用 WebSocket++ 服务器的set_timer,创建一个ms毫秒后触发的定时器。返回值是定时器的智能指针。 -
std::bind(&session_manager::remove_session_by_gen, this, ssid, ssp->timer_gen())--- 用std::bind绑定回调:&session_manager::remove_session_by_gen--- 要调用的成员函数this--- 成员函数必须通过对象调用,传入this指针ssid--- 要操作的 session IDssp->timer_gen()--- 当前递增后的代号,这个值在 bind 时被立即求值并存储,不是延迟求值
-
ssp->set_timer(tmp_tp)--- 将新创建的定时器关联到 session。之后 session 就有了超时机制,ms毫秒后定时器触发,调用remove_session_by_gen尝试删除。
场景 3:定时 → 永久
cpp
else if(tp.get() != nullptr && ms == SESSION_FOREVER)
{
ssp->inc_timer_gen(); // 递增代,让旧定时器回调失效
tp->cancel(); // 取消旧定时器
ssp->set_timer(wsserver_t::timer_ptr()); // 置空定时器
}
解析:
这个场景最关键,也是代校验机制存在的主要原因:
ssp->inc_timer_gen()--- 必须先递增代 。因为cancel不保证回调不执行。tp->cancel()--- 请求取消定时器。但 asio 的定时器在 cancel 后仍然可能触发回调------这取决于 asio 事件循环的调度时机。如果回调在 cancel 之前已经被放入事件队列,cancel 无法阻止它执行。ssp->set_timer(wsserver_t::timer_ptr())--- 将 session 的定时器置为空,表示"无定时器 = 永久存在"。
三步的顺序不能变:先递增代(使旧回调失效),再 cancel(请求取消),再置空(更新 session 状态)。如果先 cancel 再递增代,在 cancel 和递增之间旧回调可能触发并成功删除 session。
场景 4:定时 → 定时(重置)
cpp
else if(tp.get() != nullptr && ms != SESSION_FOREVER)
{
ssp->inc_timer_gen(); // 递增代,使旧定时器失效
tp->cancel(); // 取消旧定时器
ssp->set_timer(wsserver_t::timer_ptr()); // 先置空
// 创建新定时器
wsserver_t::timer_ptr tmp_tp = _server->set_timer(ms,
std::bind(&session_manager::remove_session_by_gen, this, ssid, ssp->timer_gen()));
ssp->set_timer(tmp_tp); // 关联新定时器
}
本质是场景 3 + 场景 2 的组合 :先像场景 3 一样取消旧定时器(递增代 + cancel + 置空),再像场景 2 一样创建新定时器。注意 inc_timer_gen 只调用一次,不是两次------因为从旧定时器到新定时器只是一次切换。
调用时机:
| 场景 | 调用位置 | 具体参数 |
|---|---|---|
| 2 | server.hpp reg_handler / login_handler |
set_expire_time(ssid, SESSION_TIMEOUT) |
| 3 | server.hpp wsopen_game_hall / wsopen_game_room |
set_expire_time(ssid, SESSION_FOREVER) |
| 4 | server.hpp wsclose_game_hall / wsclose_game_room |
set_expire_time(ssid, SESSION_TIMEOUT) |
五、实际使用场景
在整个项目的生命周期中,一个 session 会经历以下状态切换:
用户注册/登录
│
│ server.hpp: reg_handler / login_handler
│ create_session() → 创建 session
│ set_session_expire_time(SSID, 30000) → 场景2:设置30秒超时
│ 通过 HTTP 响应的 Set-Cookie 将 ssid 返回给客户端
│
▼
HTTP 短连接状态(场景2/4)
│ session 存活30秒,每次 HTTP 请求可以重置超时(场景4)
│ 客户端后续请求自动携带 Cookie,服务器通过 ssid 找到 session
│
│ 用户建立 WebSocket 连接进入游戏大厅
│ server.hpp: wsopen_game_hall 回调
│ set_session_expire_time(SSID, SESSION_FOREVER) → 场景3:切换为永久
│
▼
WebSocket 长连接状态(场景3/1)
│ session 永久存在,直到 WebSocket 断开
│ 期间用户可以正常下棋、聊天
│
│ 用户退出游戏大厅/房间(关闭页面或断开连接)
│ server.hpp: wsclose_game_hall / wsclose_game_room 回调
│ set_session_expire_time(SSID, 30000) → 场景4:重新设置30秒超时
│
▼
回到 HTTP 短连接状态(场景4)
│ 如果30秒内没有新连接,定时器触发 → remove_session_by_gen → 删除 session
│ 如果30秒内用户重新打开页面建立 WebSocket,又回到长连接状态
上层模块调用示例(server.hpp 中):
cpp
// 1. 登录成功后(server.hpp: login_handler)
session_ptr ssp = _session_mgr->create_session(user["id"].asUInt64(), LOGIN);
_session_mgr->set_session_expire_time(ssp->ssid(), SESSION_TIMEOUT);
// 将 ssid 写入 HTTP 响应的 Set-Cookie
// 2. WebSocket 连接建立时(server.hpp: wsopen_game_hall)
// 从连接的 Cookie 中解析出 ssid
session_ptr ssp = _session_mgr->get_session_by_ssid(ssid);
if(ssp.get() == nullptr || !ssp->is_login()) {
// session 不存在或未登录,拒绝连接
return;
}
_session_mgr->set_session_expire_time(ssid, SESSION_FOREVER);
// 3. WebSocket 连接断开时(server.hpp: wsclose_game_hall)
_session_mgr->set_session_expire_time(ssid, SESSION_TIMEOUT);
六、为什么用递归锁 + RAII 包装?
这一节回答两个面试高频追问:(1) 为什么用 std::unique_lock(RAII 包装)而不是手动 lock/unlock?(2) 为什么用 std::recursive_mutex 而不是普通 std::mutex?
6.1 为什么需要 RAII 包装(unique_lock)
如果手动加锁/解锁会怎样:
cpp
session_ptr get_session_by_ssid(uint64_t ssid) {
_mutex.lock();
auto it = _session.find(ssid);
if (it == _session.end()) {
_mutex.unlock(); // 提前返回,必须手动解
return session_ptr();
}
return it->second; // ← 拷贝 shared_ptr 可能抛 bad_alloc
_mutex.unlock(); // ← 异常时永远到不了这里 → 死锁
}
两个隐患:
- 多 return 分支漏解锁 :每个提前返回处都得记得
unlock(),方法越长越容易漏。 - 异常安全 :临界区内若抛异常(
unordered_map操作、shared_ptr拷贝都可能抛bad_alloc),栈展开直接跳到调用方 catch,unlock()永不执行,锁被永久占用,其他线程全部阻塞。
RAII 怎么解决:
std::unique_lock 在构造时加锁、析构时解锁------利用 C++ 析构函数确定性调用 (作用域结束一定析构)的特性。无论函数是正常 return、提前 return 还是抛异常展开栈,lock 对象的析构都会释放锁。
cpp
session_ptr get_session_by_ssid(uint64_t ssid) {
std::unique_lock<std::recursive_mutex> lock(_mutex); // 构造即加锁
auto it = _session.find(ssid);
if (it == _session.end()) {
return session_ptr(); // ← lock 析构解锁,异常也安全
}
return it->second;
} // ← 作用域结束 lock 析构解锁
这就是 RAII(Resource Acquisition Is Initialization)的核心思想:把资源的生命周期绑定到对象的生命周期 。锁是资源,文件描述符、堆内存、socket 连接同理------STL 里 shared_ptr 管内存、fstream 管文件、unique_lock 管锁,都是同一套思路。
unique_lock vs lock_guard 的取舍:
| 特性 | lock_guard |
unique_lock |
|---|---|---|
| 构造时立即加锁 | ✓ | ✓ |
延迟加锁 defer_lock |
✗ | ✓ |
中途手动 lock/unlock |
✗ | ✓ |
配合 condition_variable |
✗ | ✓ |
| 运行时开销 | 更小 | 稍大(维护锁状态标志) |
本项目所有加锁点都没有用到延迟加锁或条件变量,严格来说 lock_guard 就够了 ,作者统一用 unique_lock 是为保留未来改造空间(比如要配合条件变量,无需改动加锁代码)。面试时可以诚实说:"这里其实 lock_guard 更轻量,unique_lock 额外能力没用上。"
6.2 为什么需要递归锁
触发点:manager 的方法间互相调用。
set_session_expire_time 复用了 get_session_by_ssid 的查找逻辑:
cpp
void set_session_expire_time(uint64_t ssid, int ms) {
session_ptr ssp = get_session_by_ssid(ssid); // ← 内部对 _mutex 加锁再解锁
...
}
按当前代码,set_session_expire_time 自身没有再加一层锁,所以暂未真正发生二次加锁。但作者用 recursive_mutex 是防御性设计 ------后续若给 set_session_expire_time 补上锁(更严谨,因为里面操作了 _timer_gen 等共享状态),就会立刻触发 get_session_by_ssid 的嵌套加锁。用普通 mutex 时,同一线程 对它第二次 lock() 会永久阻塞(C++ 标准未定义;POSIX 通常是死锁),因为 mutex 不区分"持有者是不是自己"。
递归锁的原理:
recursive_mutex 内部除了所有权标志,还维护一个计数器 和持有者线程 ID:
| 操作 | 行为 |
|---|---|
同线程第一次 lock() |
计数 0→1,记录线程 ID |
同线程第二次 lock() |
识别持有者是自己,计数 1→2,不阻塞 |
unlock() 一次 |
计数 2→1,锁未真正释放 |
unlock() 到计数归 0 |
真正释放锁 |
"加几次就要解几次"------嵌套的 unique_lock 各自在自己的作用域析构、各自调一次 unlock(),计数自然平衡。
为什么其他模块用普通 mutex?
| 模块 | 锁类型 | 原因 |
|---|---|---|
session.hpp |
recursive_mutex |
方法间存在调用关系,预留并发改造空间 |
online.hpp |
mutex |
每个公开方法独立,不互相调用 |
db.hpp |
mutex |
只保护 mysql_exec + store_result 这一对原子操作 |
6.3 三种方案对比
| 方案 | 锁类型 | 优点 | 缺点 |
|---|---|---|---|
| 当前:递归锁 | recursive | 代码简洁,方法可自由互调 | 开销稍高,可能掩盖设计问题 |
| 拆分 locked / unlocked 两套内部接口 | 普通 mutex | 开销最小 | 接口翻倍,误调加锁版会死锁 |
外层加锁 + 内联查找(不调用 get_session) |
普通 mutex | 单锁,开销小 | 查找逻辑重复,破坏封装 |
本项目选递归锁:session_manager 方法数量少,会话管理不是性能热点,简洁性优先。
面试话术: "manager 的方法之间会互相复用------
set_session_expire_time调get_session_by_ssid。我用递归锁是为了让这种嵌套调用不会因为同线程二次加锁而死锁。代价是比普通锁多一次线程 ID 比对和计数维护,但会话管理不在热点路径上,这点开销可以接受。更严格的做法是拆分 locked/unlocked 两套内部接口,但本项目规模下递归锁更直观。"
七、异步定时器竞态修复(why & how)
这是本模块最有技术含量的一块,也是面试官最爱追问的点。核心解决一个问题:异步定时器的回调在被 cancel() 之后仍可能触发,如何防止它误删仍在使用的 session?
7.1 背景:WebSocket++ 定时器是异步的
本项目用 _server->set_timer(ms, callback) 创建定时器,底层是 boost::asio 的 deadline_timer,异步模型:
set_timer立即返回,定时器注册到 asio 的io_context事件循环。- 到期时,事件循环的某个工作线程把回调从队列里取出执行。
tp->cancel()只是向io_context投递一个取消请求,不阻塞等待。
所以调用方线程、定时器到期线程、回调执行线程可能是不同的,它们之间没有严格的 happens-before 同步。
7.2 问题:为什么 cancel 之后回调还会触发?
asio 异步模型的一个坑点,三个根本原因:
原因一:cancel 与"已派发的回调"竞争
asio 事件循环可能在 cancel() 调用之前,已经把回调取出、即将执行甚至正在执行。cancel() 只是请求 io_context 不再为这个定时器派发新的到期事件,无法撤销已经派发出去的回调。
asio worker 线程: [取出定时器A回调] ──► [执行回调: 删除session]
业务线程: [cancel定时器A] ← 来不及了,回调已被取走
原因二:cancel 返回值语义模糊
cancel() 返回 size_t,表示"成功取消了几个待执行的定时事件",但:
- 返回 1 ≠ "回调永远不会执行"(可能已经在别的线程跑起来)。
- 返回 0 ≠ "回调已经执行完了"(也可能定时器根本没注册)。
- 它是尽力而为的语义,不是同步保证。
原因三:竞态窗口客观存在
即使 cancel 成功,cancel 和回调入队之间也有交错窗口,多线程事件循环里这种交错很常见。
7.3 具体的事故场景(不修复会怎样)
t1: 用户登录成功
→ 创建 session
→ set_timer(30s) 创建定时器A
→ timer_gen = 0,回调绑定的 expected_gen = 0
t2: 第 29 秒,用户建立 WebSocket 连接进入大厅
→ set_session_expire_time(ssid, FOREVER)
→ tp->cancel() 取消定时器A
→ session 切换为永久存在
t3: 第 30 秒整,定时器A 回调被 asio worker 触发
(它在 t2 的 cancel 之前就已经被派发进队列了)
→ 回调执行:删除 session
→ 用户正在大厅里下棋,session 却被踢了!❌
不安全的写法(直接 erase、不校验):
cpp
// ❌ 错误写法
void remove_session(uint64_t ssid) {
std::unique_lock<std::recursive_mutex> lock(_mutex);
_session.erase(ssid); // 不校验,cancel 后照样删
}
7.4 解决方案:代(generation)计数器
核心思想: 不去阻止旧回调执行,而是让旧回调执行时自己能识别出"我已经过时了",主动跳过。三个要点:
- session 持有一个单调递增的
_timer_gen------每次替换定时器前先++。 - 创建定时器时,把当前 gen 值"烙印"到回调里 (
std::bind值捕获)。 - 回调执行时,拿烙印的 gen 跟 session 当前的 gen 比对,只有相等才删。
cpp
// 创建定时器:烙印当前代
ssp->inc_timer_gen(); // gen: N → N+1
_server->set_timer(ms,
std::bind(&session_manager::remove_session_by_gen,
this, ssid,
ssp->timer_gen())); // ← bind 时立即求值为 N+1,写入闭包
// 回调执行:校验代
void remove_session_by_gen(uint64_t ssid, uint64_t expected_gen) {
std::unique_lock<std::recursive_mutex> lock(_mutex);
auto it = _session.find(ssid);
if (it == _session.end()) return;
if (it->second->timer_gen() != expected_gen) // ← 代不匹配 → 跳过
return;
_session.erase(ssid);
}
修复后的事故场景:
t1: 创建定时器A,timer_gen = 0,回调烙印 expected_gen = 0
t2: 用户进入大厅
→ inc_timer_gen():gen 0 → 1
→ cancel 定时器A
→ session 设为永久
t3: 定时器A 回调触发(带着 expected_gen = 0)
→ 读 session 当前 gen = 1
→ 0 ≠ 1,代不匹配,跳过删除 ✓
7.5 关键细节:为什么必须"先 inc 再 cancel"
顺序不能反。如果先 cancel 再 inc:
t2.1 cancel 定时器A
t2.2 ← 此刻 asio worker 正好触发回调,读 gen=0,expected=0,相等 → 删了!
t2.3 inc_timer_gen() // 太晚了,session 已经没了
先 inc 让 gen 提前变化,这样 cancel 前后任何时刻旧回调触发,看到的 gen 都已经不匹配------校验永远生效。
7.6 一个容易踩的坑:bind 的求值时机
cpp
std::bind(&session_manager::remove_session_by_gen,
this, ssid,
ssp->timer_gen()); // ← 立即求值,不是延迟
ssp->timer_gen() 在 bind 调用的那一刻就求值 ,结果被值拷贝 进闭包。不是在回调触发时才去读 session 的 gen。
如果误写成"把 ssp 传进去、回调里再读 gen",那回调读到的就是触发时刻的最新 gen,永远等于当前值,校验形同虚设。值捕获(烙印快照)是这套机制能成立的关键。
7.7 为什么不用其他方案?
| 方案 | 评价 |
|---|---|
cancel() 返回值判断 |
语义模糊,且 cancel 后回调仍可能执行------不可靠 |
回调里比较 tp == 当前定时器 |
同样需要同步访问 session 的 timer 字段,竞态依然存在 |
weak_ptr<session> + lock() |
让回调持弱引用,session 释放后 lock 失败。但本项目 session 生命周期由 manager 管理,不靠引用计数回收,弱引用不直接适用 |
| 原子 bool 标志 | 能做,但本质就是这里的"代"。代计数器能区分多代定时器,比单一 bool 更严谨 |
| 重新设计为同步取消 | 牺牲异步模型的性能优势,asio 本身也不提供 |
代计数器的优势:
- 轻量:一个
uint64_t字段,读写都在 mutex 保护下 - 单调递增:能区分任意代次的定时器
- 对 cancel 时机零依赖:纯应用层校验,cancel 成不成功都安全
- 思路通用:本质是 CAS 的"版本号"模式,数据库乐观锁、缓存一致性、协程取消令牌里都能看到
7.8 流程图
inc_timer_gen() 创建新定时器
(代号 N→N+1) (绑定 N+1)
set_expire() ──────────────► cancel旧定时器 ──────────────► set_timer(新)
│
▼
旧回调触发?(绑定代号 N)
│
┌───────┴───────┐
│ │
N ≠ 当前代 N == 当前代
│ │
跳过删除 执行删除
(安全✓) (正确✓)
7.9 一句话总结
cancel()是请求 而非保证,异步回调可能在 cancel 后仍触发。我们不去阻止回调执行,而是让每个回调带着自己被创建时的"版本号",执行时和当前版本号比对,过时的回调主动放弃操作。这是异步系统处理"过期事件"的经典模式。
八、设计总结
| 设计点 | 实现方式 |
|---|---|
| 会话标识 | _next_ssid 自增分配,全局唯一 |
| 线程安全 | recursive_mutex 递归锁,允许 set_expire_time 内部调用 get_session |
| 定时器生命周期 | WebSocket++ 的 set_timer 创建定时器,cancel 取消 |
| 防 cancel 后回调误删 | _timer_gen 代计数器 + remove_session_by_gen 代校验 |
| 智能指针管理 | session_ptr = shared_ptr<session>,自动释放内存 |
| 会话存储 | unordered_map 哈希表,O(1) 查找 |
| 状态切换 | 四种场景覆盖:永久↔永久、永久→定时、定时→永久、定时→定时 |
| get_timer 返回引用 | 方便外部直接修改 session 的定时器字段,少一次 set 调用 |
| Cookie 传递 ssid | session 创建后 ssid 写入 Cookie,客户端后续请求自动携带 |