【C++ 在线五子棋对战】- 会话管理模块实现


大家好,我是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 登录状态枚举,UNLOGINLOGIN
_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(未关联用户),_statusUNLOGIN_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.hppdb.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 事件循环调用。

执行逻辑分为两步校验:

  1. session 是否存在find 查找 ssid,如果不存在(可能已被其他逻辑删除),直接返回。
  2. 代号是否匹配 :比较 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 ID
    • ssp->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();          // ← 异常时永远到不了这里 → 死锁
}

两个隐患:

  1. 多 return 分支漏解锁 :每个提前返回处都得记得 unlock(),方法越长越容易漏。
  2. 异常安全 :临界区内若抛异常(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_timeget_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)计数器

核心思想: 不去阻止旧回调执行,而是让旧回调执行时自己能识别出"我已经过时了",主动跳过。三个要点:

  1. session 持有一个单调递增的 _timer_gen ------每次替换定时器前先 ++
  2. 创建定时器时,把当前 gen 值"烙印"到回调里std::bind 值捕获)。
  3. 回调执行时,拿烙印的 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"

顺序不能反。如果先 cancelinc

复制代码
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,客户端后续请求自动携带

相关推荐
有点。1 小时前
C++深度优先搜索(DFS)的概念(一)
开发语言·c++·深度优先
旖-旎1 小时前
《LeetCode 978 最长湍流子数组 || LeetCode 139 单词拆分》
c++·算法·leetcode·动态规划
时间的拾荒人1 小时前
C语言编译与链接:从源码到可执行程序的完整解析
c语言·开发语言
人道领域1 小时前
【0-1的agent进阶篇】Prompt 与上下文工程
java·开发语言·prompt·mcp
aaPIXa6221 小时前
C++大型项目模块化拆分实战记录
开发语言·c++
z落落1 小时前
C# WinForm 线程池与事件等待+进度条暂停恢复实战案例
开发语言·c#
石山代码1 小时前
C++23 新特性在 CLion 中的实战体验
开发语言·c++·c++23
浮江雾1 小时前
Flutter第四节------核心概念学习笔记:从基础语法到异步编程
linux·服务器·开发语言·笔记·flutter·入门·基础
在线OJ的阿川2 小时前
仿muduo库实现高并发服务器
linux·服务器·c++