Linux多线程服务器框架-lynx

目录


项目由来

服务器项目的想法源于 6 月初,当时毕设答辩完,准备暑假找个实习,但是手上好像没有像样的项目,所以准备做一个。虽然后来证明因为错过招聘时期,而且是研 0,投了简历均无回复。

在看了陈硕《Linux 多线程服务端编程》之后,准备照着写一个。但是由于有学上,不想急功近利为了实习而写个项目,中途放弃好几次。毕业离校回家后待家里实在无聊,就重新捡起这个项目。从一开始的纯抄写代码,到后面结合书和大语言模型理解代码和相关设计思路,再到“缝合”其他项目融入我的框架中,为其增加新功能。正好趁着暑假有时间边玩边做,形成一个完整的项目。

代码已开源:GitHub: wlonestar/lynx

项目模块

本项目设计并实现了一个针对 Linux 系统的高性能、可扩展的多线程服务器框架,具有模块化设计和高效的网络处理能力等特点。

主要包括:

  • 网络库:基于 Reactor 模式,参考 muduo 网络库设计,实现了非阻塞 I/O 和事件驱动编程机制,提升了网络请求的并发处理能力
  • HTTP 服务器:集成 Ragel 状态机编译器,负责高效解析 HTTP 请求,确保请求的准确解析和快速响应
  • ORM 框架:利用 C++ 的宏和模板元编程技术实现了编译期反射机制,并封装了 ORM 框架

线程库

lynx 是一个多线程模型,通过 std::sthread 来封装线程类,在此基础上实现线程池。线程池具有固定数量的线程,维护一个任务队列,将任务交由空闲线程执行。

Linux 上的线程标识符

使用 gettid 系统调用的返回值作为线程 id。

  • 类型是 pid_t,其值通常是一个小整数,便于在日志中输出。
  • 在 Linux 中,直接表示内核的任务调度 id,在 /proc 文件系统中可以轻易找到对应项。
  • 在其他系统工具中也容易定位到具体某一线程,如 top。
  • 任何时刻都是全局唯一的,短时间内启动的多个线程也会具有不同的线程 id。
  • 0 是非法值,操作系统第一个进程的 pid 是 1。

通过 thread_local 变量缓存的 gettid 的返回值,只在本线程第一次调用的时候才进行系统调用,以后都是从 thread_local 缓存的线程 id 拿到结果。

线程的创建与销毁原则

  • 程序库不应该在未提前告知的情况下创建自己的“背景线程”。
  • 尽量用相同的方式创建线程,如 lynx::Thread
  • 在进入 main() 函数之前不应该启动线程。
  • 程序中线程的创建最好能在初始化阶段全部完成。

在多线程程序中,使用 signal 的第一原则是不要使用 signal

class IgnoreSigPipe {
public:
  IgnoreSigPipe() { ::signal(SIGPIPE, SIG_IGN); }
};

IgnoreSigPipe init_obj;

线程安全的单例模式:std::once_flag

template <typename T> class Singleton : lynx::Noncopyable {
public:
  Singleton() = delete;
  ~Singleton() = delete;

  static T &instance() {
    std::call_once(once, &Singleton::init);
    return *value;
  }

private:
  static void init() { value = new T(); }

  static std::once_flag once;
  static T *value;
};

template <typename T> std::once_flag Singleton<T>::once;
template <typename T> T *Singleton<T>::value = nullptr;

日志库

作为供人阅读的日志,通常用于故障诊断和追踪,也可用于性能分析。在服务端编程中,日志是必不可少的。

结构

  • 前端:供应用程序使用的接口,并生成日志消息
  • 后端:负责把日志消息写到目的地
  • 两部分的接口可能简单到只有一个回调函数:void output(const char *message, size_t len);

在多线程程序中,前端和后端都与单线程程序无区别,每个线程有自己的前端,整个程序共用一个后端。难点在于将日志数据从多个前端高效地传输到后端,是一个多生产者-单消费者问题。对于前端而言,要尽量做到低延迟、低 CPU 开销、无阻塞;对于后端而言,要做到足够大的吞吐量,并占用较少资源。

日志记录的信息

  1. 收到的每条内部消息的 id;
  2. 收到的每条外部消息的全文;
  3. 发出的每条消息的全文,每条消息都有全局唯一的 id;
  4. 关键内部状态的变更。

功能

  1. 控制日志消息的输出级别(TRACE, DEBUG, INFO, WARN, ERROR, FATAL)
  2. 日志文件的滚动归档(按照文件大小和时间)

日志文件名:log_file_test.20240719-141503.hostname.30621.log

  • 进程名:通常由 main() 函数参数中的 argv[0]basename
  • 文件的创建时间:当地时区
  • 机器名称
  • 进程 id
  • 后缀名 .log

日志消息格式:<日期> <时间>.<微秒> <线程id> <级别> <函数名> <正文> - <源文件名>:<行号>

20240719 14:18:40.310161  2674  INFO logInThread logInThread - logging_unittest.cpp:46
20240719 14:18:40.311660  2669  INFO main Hello - logging_unittest.cpp:73
20240719 14:18:40.311767  2669  WARN main World - logging_unittest.cpp:74
20240719 14:18:40.311825  2669 ERROR main Error - logging_unittest.cpp:75
20240719 14:18:40.311841  2669  INFO main 4056 - logging_unittest.cpp:76
20240719 14:18:40.311896  2669  INFO main 4024 - logging_unittest.cpp:77
20240719 14:18:40.311963  2669  INFO main 4016 - logging_unittest.cpp:78

每条日志尽量一行,前四个字段的宽度是固定的,以空格分隔,便于用脚本解析。避免在日志格式中出现正则表达式的元字符,便于查找字符串。

多线程异步日志

双缓存技术:准备两块 buffer:A 和 B,前端负责往 buffer A 填数据,后端负责将 buffer B 的数据写入文件。当 buffer A 写满之后,交换 A 和 B,让后端将 buffer A 的数据写入文件,而前端则往 buffer B 填入新的日志消息,如此往复。

网络库

基于 Reactor 模式的网络库,核心是个事件循环 EventLoop,用于响应计时器和 IO 事件。

lynx 采用基于对象(object-based)而非面向对象(object-oriented)的设计风格,事件回调接口多以 std::function + lambda 表达。

通过使用前向声明(forward declaration)简化了头文件之间的依赖关系,避免将内部类暴露给用户。

公开接口

  • Buffer: 数据的读写通过 buffer 进行。用户代码不需要调用 read/write,只需要处理收到的数据和准备好要发送的数据。
  • InetAddress: 封装 IPv4/IPv6 地址,,不能解析域名,只认 IP 地址。
  • EventLoop: 事件循环(反应器 Reactor),每个线程只能有一个 EventLoop 实体,负责 IO 和定时器事件的分派。用 eventfd 来异步唤醒,有别于传统的用一对 pipe 的办法,用 TimerQueue 作为计时器管理,用 Epoller 作为 IO 多路复用。
  • EventLoopThread: 启动一个线程,在其中运行 EventLoop::loop()。
  • TcpConnection: 整个网络库的核心,封装一次 TCP 连接,不能发起连接。
  • TcpClient: 用于编写网络客户端,能发起连接,有重试功能。
  • TcpServer: 用于编写网络服务器,接受客户的连接。

TcpConnection 的生命期依靠 shared_ptr 管理(用户和库共同控制), Buffer 的生命期由 TcpConnection 控制。其余类的生命期由用户控制。

EventLoop

one loop per thread 意味着每个线程只能有一个 EventLoop 对象,因此构造函数会检查当前线程是否已经创建了其他 EventLoop 对象。创建了 EventLoop 对象的线程是 IO 线程,主要功能是运行事件循环 EventLoop::loop()

Channel

每个 Channel 对象自始至终只属于一个 EventLoop,因此每个 Channel 对象只属于某一个 IO 线程,自始至终只负责一个文件描述符(fd)的 IO 事件分发。Channel 把不同的 IO 事件分发为不同的回调,回调基于 std::function 表示。

Epoller

Epoller 是 IO 多路复用的封装,支持 epoll IO 多路复用机制。Epoller 是 EventLoop 的间接成员,只供其 owner EventLoop 在 IO 线程调用,因此无需加锁,其生命期与 EventLoop 相等。

内部实现

  • Channel: selectable IO channel,负责注册与响应 IO 事件,不拥有 file descriptor。
  • Socket: RAIIhandle,封装一个 file descriptot,并在析构时关闭 fd。
  • SocketsOps: 封装各种 socket 系统调用。
  • Epoller: 封装 epoll IO 多路复用后端。
  • Connector: 用于发起 TCP 连接。
  • Acceptor:用于接受 TCP 连接。
  • TimerQueue: 用 timerfd 实现定时。
  • EventLoopThreadPool: 用于创建 IO 线程池,把 TcpConnection 分派到某个 EventLoop 线程上。

线程模型

one loop per thread + thread pool 模型。每个线程最多有一个 EventLoop,每个 TcpConnection 必须归某个 EventLoop 管理,所有的 IO 会转移到这个线程——一个 fd 只能由一个线程读写。

TcpServer 支持多线程,有两种模式:

  • 单线程, accept() 与 TcpConnection 用同一个线程做 IO。
  • 多线程, accept() 与 EventLoop 在同一个线程,另外创建一个 EventLoopThreadPool,新到的连接会按 round-robin 方式分配到线程池中。

TCP 网络编程

基于事件的非阻塞网络编程是编写高性能并发网络服务程序的主流模式。

主动调用 recv 来接收数据 –> 注册一个收数据的回调,网络库收到数据会调用我,直接把数据提供给我,供我消费

主动调用 accept 来接受新连接 –> 注册一个接受连接的回调,网络库接受了新连接会回调我,直接把新的连接对象传给我,供我使用

主动调用 send 来发送数据 –> 需要发送数据的时候,只管往连接中写,网络库会负责无阻塞地发送

HTTP 协议实现

实现 HTTP 服务端部分请求和响应,使用 Ragel 状态机解析 HTTP/1.1 协议,接收用户发送的请求封装成 HTTP Request,通过注册一个 HTTP 请求处理的回调函数,交给用户来处理不同的请求所对应的响应,设置 HTTP Response,再将响应发送到客户端。

ORM 框架

利用 C++ 宏魔法和模板元编程实现编译期反射,再借助 nlohmann/json 实现 JSON 序列化和反序列化。对需要反射的类对象使用宏,从而获取数据库表对应的 C++ 类结构的各成员变量名称及其类型和值,使得 CRUD 的 SQL 语句可以自动生成。

struct Student {
  uint64_t id;       // NOLINT
  std::string name;  // NOLINT
  int entry_year;    // NOLINT
  std::string major; // NOLINT
  double gpa;        // NOLINT
} __attribute__((packed));

REFLECTION_TEMPLATE_WITH_NAME(Student, "student", id, name, entry_year, major, gpa)
REGISTER_AUTO_KEY(Student, id)

使用 libpq 库将 PostgreSQL API 封装成类,分别针对插入、查询、修改、删除等操作编写 SQL 代码生成函数以实现链式查询操作。

auto result = conn.query<Student, uint64_t>()
                 .where(VALUE(Student::entry_year) == 2024 &&
                        VALUE(Student::major) == "CS")
                 .orderBy(VALUE(Student::gpa))
                 .toVector();
for (auto &res : result) {
  std::cout << lynx::serialize(res) << "\n";
}

数据库连接池

管理一个数据库连接的队列,提供了一系列方法来启动和停止连接池,从池中获取连接,以及将连接回收回池中。

使用两个额外线程分别生产和回收数据库连接:不断检查队列中连接的数量是否小于最大连接数,若是则创建新连接到连接队列中;每隔 0.5s 检查是否有连接空置事件大于最大空置时间,若是则将其从连接队列移除。

获取数据库连接时返回 shared_ptr,自定义 Deleter,这样在连接释放时刷新其存活时间并重新插入连接队列,避免频繁创建销毁连接。

Web 框架

参考 Spring Boot Repository,利用基类 BaseRepository 提供更高层次封装。

template <typename T, typename ID> class BaseRepository {
public:
  explicit BaseRepository(lynx::ConnectionPool &pool);

  virtual bool updateById(ID id, T &&t);
  ...

protected:
  lynx::ConnectionPool &pool_;
};

参考 Spring Boot Rest Controller,在基类 BaseController 中编写不同类型 HTTP 请求的解析映射,实现 HTTP Handler 的自动注册。

class StudentController : public lynx::BaseController {
public:
  static void init(lynx::ConnectionPool &pool) {
    service = std::make_unique<StudentService>(StudentRepository(pool));
  }

  explicit StudentController() {
    requestMapping("PUT", R"(/student/(\d+))", updateById,
                   lynx::PathVariable<uint64_t>(),
                   lynx::RequestBody<Student>());
  }

  /// "PUT" "/student/(\d+)"
  static lynx::json updateById(uint64_t id, Student &student);

private:
  static std::unique_ptr<StudentService> service;
};

应用服务器(class Application)维护一个路由表,路由表记录着请求方法、请求路径及其对应的 Handler,通过匹配请求方法和请求路径调用对应的 Handler。

应用服务器通过 yaml 格式文件读取 HTTP 服务器和数据库连接池等相关配置信息。

后续安排

  • 分模块编写文档讲述相关部分设计要点和思路
  • 完善 HTTP 和 ConnectionPool 部分代码设计
  • 编写上手教程

以上。


comments powered by Disqus