#定位
coost 是一个轻量的跨平台 C++ 基础库,提供协程、网络、RPC、日志、配置、JSON 等组件,风格接近 golang,追求极简,同时保持高性能,不依赖 Boost、folly 等三方库。
适合如下场景:
- 需要轻量协程;
- 需要基于协程的网络框架;
- 不想引入较重或较多的三方库;
- 希望配置、日志、内存管理等有一致的风格。
#设计原则
coost 的核心设计原则是 keep simple,同时保持高性能。
具体来说:
- 单个组件保持极简,只提供常用功能;
- 简单组件组合起来,覆盖大部分应用场景;
- 在保持简单的同时追求性能,兼顾易用性与性能。
基于上述原则:
- coost 通常会提供简短、语义清晰的命名;
- coost 一般不会为了支持一些较少使用的功能,而增加代码的复杂度、维护难度;
- coost 内部不使用异常,用户不需要
try / catch。
#它解决什么问题
C++ 项目开发中,通常会遇到下面问题:
- 并发模型复杂:多线程 + 回调 + 锁,逻辑容易写乱,异步代码可读性差、难以调试。
- 依赖多:项目可能引入多个三方库,代码风格、内存管理方式等不统一,集成、调试和优化成本高。
- 依赖重:项目可能引入 Boost、folly 等大型三方库,编译慢、上手成本高。
- 全局对象管理混乱:不同 .cc 文件的静态对象,初始化与析构顺序未定义,程序可能因访问未初始化或已析构的静态对象而崩溃。
#coost 的解决方案
- 用协程简化并发模型,用同步写法获得异步性能。
- 用 coost 一个库覆盖配置、日志、协程、网络、RPC、JSON、单元测试、基准测试等常用基础组件,减少三方依赖。
- 核心组件自研,不依赖 Boost、folly 等大型三方库。
- 用
co::make_static / co::make_rootic接管静态对象,借助 nifty counter 技巧保证被依赖的对象先构造、后析构。
此外,coost 还统一了:
- 配置方式:所有组件通过 flag 配置,支持命令行和配置文件。
- 输出方式:终端输出、日志都基于
co::string流式接口,自定义类型只需实现operator<<(co::string&, const T&)。 - 内存分配:所有组件统一使用 coost 内存分配器。
#核心组件与设计选择
#协程
coost 协程与 golang 中 goroutine 类似:
- 使用
go()创建协程; - 支持多线程调度,默认调度线程数为系统 CPU 核数;
区别于 C++20 无栈协程,coost 协程没有「代码污染」问题:不需要 co_await / co_yield / co_return,不需要引入 task<T> / promise_type等类型,不需要改函数签名。
#共享栈
coost 协程采用共享栈设计,同一调度器中的协程共享固定数量的栈,每个协程的栈内存开销极低,单机支持千万级协程并发。
需要特别注意,由于协程的栈不是独占的,通常不能通过指针、引用跨协程访问协程栈上的对象。
#go 的参数传递
go() 接受的参数与 std::thread 构造函数类似,可以是普通函数、成员函数、lambda、函数对象,以及任意数量和类型的参数。
go 函数大致长这样:go(f, args...),args 传引用是安全的;但如果 f 是 lambda,则一般不能按引用捕获协程栈上的对象,因为协程的栈是共享的,栈上数据可能被其他协程覆盖。
#同步机制
coost 协程提供 co::mutex / co::event / co::wait_group 三种同步机制。
golang 是全协程环境,而 coost 需要同时面对协程与非协程环境。coost 将上述同步机制设计为:既支持协程,也支持非协程。实现成本更高,但方便用户在混合环境下使用。
另外,由于是共享栈,coost 需要像 golang 一样,在协程中贯彻按值传递参数的语义。因此上述同步机制均采用基于引用计数的设计,拷贝操作只会增加引用计数。
#内存分配器
coost 自研了一套内存分配器,所有组件统一使用,便于内存管理 、统计、调试和优化。
分配器将内存分为三类:
- 小内存:
< 4k,16 字节对齐; - 中等内存:
<= 128k,4k 字节对齐; - 大内存:
> 128k,直接 mmap / VirtualAlloc,页对齐。
与一般分配器不同,coost 内存分配器不保存所分配内存的大小,free 时需要传大小。这样做的好处是:
- 分配器不需要在内存头部写元数据,用户拿到的内存布局紧凑,没有额外开销;
- free 时根据大小直接判断属于哪一级,不需要读元数据,路径简单;
- 缓存友好,分配和释放更快。
#容器低成本迁移
co/stl.h 提供使用 coost 内存分配器的 STL 容器别名。用户只需要将 std::vector / std::map / std::unordered_map 等替换成 co::vector / co::map / co::hash_map 等,就能带来性能上的提升,不需要修改代码结构。
#静态对象管理
C++ 不同 .cc 文件中的静态对象,初始化与析构顺序未定义,程序可能因访问未初始化或已析构的静态对象而崩溃。coost 给出的解决方案是:
- 不直接定义静态对象,而是定义全局指针,由 coost 接管;
- 通过 nifty counter 技巧,调用
co::make_static<T>(args...)创建静态对象,并用其返回值初始化全局指针。
nifty counter 天然保证被依赖的对象先构造完成,coost 则在程序退出时,按照先构造、后析构的顺序,析构接管的对象。
另外,coost 也提供 co::make_rootic<T>(args...),用于创建无依赖的静态对象,coost 保证 make_rootic 构建的对象总是在最后析构。make_rootic 一般用于无法使用 nifty counter 技巧的场景,如无依赖的线程局部对象的初始化。
#写内存友好的代码
co/def.h 定义了 co::cache_line_size,以及 __cacheline_aligned 宏。定义结构体时可以写 struct __cacheline_aligned S,语义更明确。
co::alloc(n, align) 分配指定对齐的内存,align 最大是 256(co::cache_line_size 的最大可能值)。用户可以用 co::alloc(n, co::cache_line_size) 分配缓存行对齐的内存。
#flag / log / unitest / benchmark
coost 提供 flag、log、unitest、benchmark 四个基础组件,分别对应 gflags、glog、gtest、google benchmark 的常用场景。
- flag 提供更强的功能,支持 flag 别名、自动生成配置文件,整型 flag 值可以带单位(k,m,g,t,p),用起来更方便。
- log 性能比 glog 更好,通常有 1 到 2 个数量级的提升。业务代码中打印大量日志可能影响处理性能,所以 log 在设计上特别重视性能的提升。
- unitest 写单元测试更简单,
DEF_test定义的测试单元实际是一个函数,DEF_case只是其中的代码块,用户可以在函数中自由添加代码,如各测试用例共用的初始化代码。 - benchmark 设计上与 unitest 类似,
BM_group定义的基准测试组也是一个函数,BM_add只是其中的代码块,用起来更方便。
#一个最小示例
#include "co/co.h"
#include "co/flag.h"
#include "co/log.h"
#include "co/print.h"
int main(int argc, char** argv) {
flag::parse(argc, argv);
co::wait_group wg(2);
go([wg](){
co::println("hello world");
wg.done();
});
go([wg](){
log::info("hello again");
wg.done();
});
wg.wait();
return 0;
}
这个示例展示了 coost 的基本用法:
flag::parse解析命令行参数,没有这一行,日志线程、协程调度线程不会启动;co::wait_group wg(2)创建一个计数器为 2 的等待组;go启动两个协程,lambda 按值捕获wg,因为协程是共享栈,不能按引用捕获协程栈上的对象(虽然示例中 wg 并不在协程栈上,按引用捕获也是可以的,但为安全起见,建议统一按值捕获);co::println立即输出到终端,log::info默认写文件,不输出终端,想在终端看日志内容可以在命令行加-also_log2console=true;wg.wait()在主线程等待两个协程执行完,然后退出。
#编译与运行
coost 支持的编译器有 gcc、clang、MSVC,需要编译器支持 C++17。coost 支持用 xmake 或 cmake 构建,推荐用 xmake,用起来更方便。
常用命令(在 coost 根目录下执行):
# 默认编译 libco
xmake
# 编译及运行单元测试代码
xmake b unitest
xmake r unitest
xmake r unitest -os
# 编译及运行性能测试代码
xmake b benchmark
xmake r benchmark
xmake r benchmark -mem
# 编译及运行 test 目录下的测试代码
xmake b xx
xmake r xx
用户可以在 test 目录添加自己的 xxx.cc 文件,直接 xmake b xxx 构建、xmake r xxx 运行。
#和同类库的比较
| 维度 | coost | Boost | folly |
|---|---|---|---|
| 定位 | 轻量级基础库集合 | 大型通用库集合 | Facebook 内部基础库集合 |
| 协程 | 有,共享栈,非 C++20 协程 | C++20 协程 | 有 |
| 网络 | TCP/UDP/RPC | Asio(TCP/UDP) | 多种 |
| RPC | 内置 | 无 | 有 |
| JSON | 内置 | 有 | 有 |
| flag / log | 内置 | 无(需 gflags/glog) | 有 |
| 单元 / 基准测试 | 内置 | Boost.Test | 有 |
| 内存分配器 | 自研 | 标准 | 自研 |
| 配置 | 统一使用 flag | 各组件独立 | 各组件独立 |
| 依赖 | 无 | 各组件依赖不同 | 大量 |
| 上手难度 | 中 | 高 | 高 |
| 体量 | 小 | 大 | 大 |
coost 并不完全对标 Boost 或 folly。coost 的定位是「轻量级基础库集合」,覆盖面比 Boost 小,但组件之间风格统一,不依赖第三方库,上手成本更低。上表只从部分维度做对照,不代表功能完全等价。
#并发模型
#服务端
服务端通常需要支持高并发。coost 采用「一个连接一个协程」的基本模型:
- accept 单独一个协程,负责打开监听 socket、循环 accept、关闭监听 socket;
- 每 accept 一个新连接,单独开一个协程,负责该连接上的 recv / send / close。
连接的生命周期与协程的生命周期一致。这种模型下,每个连接的处理逻辑是独立的、顺序的,用同步的写法即可,不需要回调,也不需要手动管理状态机。
#客户端
客户端需要考虑的是连接复用,而不是每个协程都建立新的连接。coost 提供 co::pool 解决这个问题:
co::pool内部每个调度线程都有自己的池子,池中的元素不会跨线程共享,因此使用 co::pool 不需要加锁;- 客户端协程需要的时候,从 co::pool 中取出一个连接,用完立即放回;
使用上述模型,大量的客户端协程,通常可以共用 co::pool 中的少量连接,而不用为每个协程创建一个连接。
有了 co::pool,coost 中 co::tcp_client、co::rpc_client 的设计就很简单,一个 client 在同一时刻只允许一个协程使用,用户可以将 client 放入 co::pool,达到复用的目的。简言之,co::pool 提供了在协程环境复用客户端连接的通用方法。
#推荐阅读顺序
- flag:命令行与配置文件解析;
- log:日志;
- print:终端输出;
- string:字符串;
- mem:内存分配器;
- co:协程;
- sock:socket;
- tcp:TCP;
- json:JSON;
- rpc:RPC;
- gen:RPC 代码生成。