C++协程(Coroutines)
C++协程(Coroutines)
1. 简单介绍协程
协程可以简单的理解为,它是一个可以随时“中断”,并再次恢复执行的函数。
C++协程框架的特点:
-
无栈协程
-
非对称设计(开发者可以自行设计协程调度器,做成对称设计)
2. 协程和函数的区别
函数:函数调用是线性、同步、一次性的执行模式,调用者必须等待被调用方法执行完成后返回。
协程:协程可以异步执行,调用者不需要等待协程完成,可以在协程挂起时继续做其他事情。在执行过程中通过特定的语法(co__yield_和_co__await)暂停执行,并在之后的某个时刻恢复执行。
2.1 普通函数的执行过程
一个普通函数在执行的时候,主要包含两个操作,分别是调用(call)和返回(return)。
-
当一个普通函数执行**调用(call)**的时候,将会创建一个新的栈帧(Stack Frame),停止调用者的执行,并转移到函数上执行。
- 栈帧(Stack Frame)是一块用于存储当前函数执行状态的内存块,包含了传入参数(parameters)、局部变量(local variables)、返回地址(return-address)。
-
当发生**返回(return)**的时候,该方法将会返回一个值给调用者(如果有的话),并且销毁当前使用的激活帧(Activation Frame),调用者将会按照原先的逻辑继续执行。
-
当返回时,将会销毁激活帧
-
销毁所有栈帧中的局部变量(local variables)
-
销毁所有的传入参数(parameters)
-
释放栈帧使用的空间
-
-
最后,恢复调用者执行
-
恢复调用者栈帧的状态(寄存器信息)
-
跳转到调用栈执行’call’的恢复点继续执行
-
-
函数调用的栈帧结构,最上面的是激活栈
可以根据sp寄存器,计算栈帧的分配情况
2.2 协程的执行过程
协程在调用(call)和返回(return)的基础上,额外增加了暂停(Suspend)、**恢复(Resume)**操作。
-
暂停(Suspend):从暂停点停止运行,并将执行流转移给协程调用者、或者调度器。协程暂停后,“协程栈帧”中所有对象都不会被销毁,保持“存活”状态。因此一般“协程栈帧”可能会分配在堆上,如果编译器可以识别协程运行的生命周期,都处于调用者栈帧之内,那么编译器在编译时会放在栈上分配“协程栈帧”。
-
协程的激活帧一般包含两部分:
-
协程帧:保存一部分协程激活帧内容,协程挂起后依旧处于存活状态
-
栈帧:当协程运行时才存在,协程挂起的时候会释放
-
-
-
恢复(Resume):让协程从暂停点恢复运行
-
销毁(Destroy):销毁协程激活帧,协程帧存储的所有数据被释放
3. 协程和线程的区别
- 线程
-
线程是操作系统最小的调度单位,由内核使用CFS(完全公平调度器)进行调度,通常为每个可运行线程分配相等的CPU时间(例如交互敏感系统中,默认调度时间为3ms)。
-
以骁龙8750大核为例,3ms调度时间最大可运行约97,590条指令(4089600Hz × 0.003s × 7.9544),系统SystemServer在64.64%调度时,主动放弃了CPU时间。
- 协程
-
协程是运行在线程上的一种轻量级组件,由用户态调度器调度,切换时无需进入内核态,开销极低。
-
协程充分利用线程单次调度时间,通过任务间的快速切换避免CPU空闲。例如,线程中的任务A完成后,协程可立即切换到任务B,充分利用剩余调度时间。
-
协程的上下文切换发生在用户态,开销远低于线程切换。
- 线程与协程的区别
-
调度:线程由内核调度,协程由用户态调度器调度。
-
上下文切换:线程切换需要保存和恢复CPU寄存器、栈等信息,协程切换只需保存部分寄存器信息。
-
资源占用:线程需要独立的栈内存和线程控制块(TCB),协程运行在线程中,无需额外栈
-
并发模型:线程适合多核并行,协程适合单线程高并发任务
协程可以用于实现用户态轻量级线程,由用户态调度器来调度这些轻量级线程,而无需经过内核线程调度器调度。
协程调度器可以参照Golang语言,运行的都是Goroutine,协程调度器自动为申请线程,并为线程分配合理数量的Goroutine,类似M:N模型(线程:协程(轻量级线程))。这样协程调度器可以申请和当前设备CPU核心数量相同的线程数,让所有协程在这些线程上调度运行,最大化使用内核调度器分配的CPU时间。
线程栈信息
4. 通过示例初步了解协程
这个示例代码展示了一个简单的C++协程实现,用于猜测数字的游戏。在这个游戏中,协程Guess
负责生成一个随机数,并与用户输入的猜测值进行比较。
协程函数 Guess
Guess
协程函数接受一个 CoRet::Note& note
参数,用于接收用户输入的猜测值。
主函数 main
在 main
函数中,我们创建了一个 CoRet::Note
实例 note
用于与协程通信,并启动了协程 Guess
。
// full code: https://github.com/luoqiangwei/CXX_Simple_Coroutine_Example/blob/main/GuessCoroutine/main.cpp
struct CoRet {
struct Note {
int guess;
};
struct promise_type {
int _out; // 存储co_yield中间结果
int _res; // 存储co_return最终结果
...
}
...
};
// Guess是一个协程实现
CoRet Guess(CoRet::Note& note) {
int res = (rand() % 30) + 1;
CoRet::Input input{ note };
while (true) {
int g = co_await input; // 挂起协程,等待调用者更新input信息
cout << "coroutine: You guess " << g << ", res: " << res << endl;
int result = res < g ? 1 : (res == g ? 0 : -1);
co_yield result; // 中断协程执行,并且返回一个值
if (result == 0) break;
}
co_return res;
}
int main(int argc, const char * argv[]) {
CoRet::Note note = {};
auto ret = Guess(note);
while (true) {
std::cin >> note.guess;
cout << "main: You input: " << note.guess << endl;
ret._h.resume(); // 继续执行协程,从co_await恢复运行
ret._h.resume(); // 继续执行协程,从co_yield恢复运行
cout << "main: result is " <<
((ret._h.promise()._out == 1) ? "larger" :
((ret._h.promise()._out == 0) ? "the same" : "smaller")) << endl;
if (ret._h.promise()._out == 0) break;
}
if (ret._h.done()) {
cout << "main: the result is " << ret._h.promise()._res << endl;
ret._h.destroy();
}
return 0;
}
协程的能力
-
异步执行:
co_await
关键字使得协程能够在等待输入时挂起,而不是阻塞线程。 -
值传递:
co_yield
允许协程在挂起时返回一个值给调用者。 -
控制流:
co_return
用于结束协程并返回最终结果。 -
状态管理:promise用于在协程挂起和恢复之间管理状态和值。
5. 协程开发
5.1 对co_yield
和co_return
进行编程
要实现对co_yield
和co_return
的逻辑控制,需要实现promise_type
接口:
// 这个接口是协程自身的上下文和逻辑控制,存储一些状态信息(类似于线程的上下文信息,但是线程的对开发者透明)
// Promise接口,指定一系列方法控制协程自身行为。
// - 协程被调用时的行为
// - 协程返回时的行为(包含异常时返回的行为)
// - 自定义co_return或co_yield表达式的对应行为
// - suspend_never,在co_return或者co_yield运算的时候不停止协程运行
// - suspend_always,在co_return或者co_yield运算的时候停止协程运行
struct CoRet {
// 协程的Promise接口必须叫promise_type
struct promise_type {
// 存储co_yield产生的中间临时数据值。
int _out;
// 存储co_return最终的结果
int _res;
// 存储协程中的异常
std::exception_ptr exception_;
// 初次执行协程体时执行(在初始化协程并执行的时候调用,此函数可用于初始化
// 用户自定义协程状态信息,同时控制协程是否继续执行)
// 其他协程或线程再次执行resume恢复协程执行
suspend_always initial_suspend() { return {}; }
// 在协程体全部执行结束后执行(即将销毁时调用,此时可以销毁用户自定义协程
// 状态信息,同时可以暂停协程销毁,让其他协程或线程获取到最终结果)
// 其他协程或线程再次执行resume恢复协程执行后,销毁
//
// 最后必须得suspend,不暂停的话,结构会被销毁,无法拿到最后的返回值
suspend_always final_suspend() noexcept { return {}; }
// 协程未捕获异常
void unhandled_exception() {
exception_ = std::current_exception();
}
// 创建协程时,返回Promise接口实例,给这个外部结构体对应的成员
CoRet get_return_object() {
return {
coroutine_handle<promise_type>::from_promise(*this)
};
}
// 控制co_yield逻辑
suspend_never yield_value(int r) {
_out = r;
return {};
}
// 控制co_return逻辑,用于协程最终return的结果
void return_value(int r) {
_res = r;
cout << "coroutine: set res " << r << endl;
}
};
// 构造时,调用get_return_object赋值,coroutine_handle<promise_type>只能有一个这样的成员,
// 如果有两个这种类型的成员,会只赋值给结构体中第一个,第二个不会被初始化!
coroutine_handle<promise_type> _h; // _h.resume(), _h()
};
5.2 对co_await
进行编程
要实现对Awaitable
接口(只要结构体中实现了await_ready
、await_suspend
、await_resume
,它就是一个Awaitable
接口):
// Awaitable接口,指定一系列方法控制co_await表达式的语义。
// 当一个值被co_await时,代码会被转换成awaitable对象的一系列方法的调用。
// - 可以控制是否挂起当前协程。(await_ready,true是不挂起,false是挂起)
// - 协程恢复时,返回的值(await_resume)
struct CoRet {
Note& _in;
// 控制co_await的时候是否暂停并暂时返回,下一次执行时将会继续从暂停位置继续
// true是不返回,直接执行
// await_ready()在执行co_await表达式求值时先被调用
// - 返回值表示异步操作是否完成,完成即可继续执行,否则协程被挂起
// - 返回true,不会挂起协程,其他两个函数不被执行
// - 返回false,表示异步操作未结束,协程被挂起
bool await_ready() { return false; }
// await_suspend(coroutine_handle<CoRet::promise_type> h)
// 在await_ready()返回false的时候被调用
// - 用于挂起协程,暂停执行,并传递coroutine_handle,用于在异步操作完成,协程
// 恢复时执行使用。协程状态会被保存,之后从当前点恢复执行
// - await_suspend()用于设置一些在异步操作完成后,需要检查或清理的资源,例如异步
// 操作完成的回调函数
void await_suspend(coroutine_handle<CoRet::promise_type> h) {}
// await_resume()异步操作完成,并且await_suspend()保存协程状态后被调用
// - 用于恢复协程执行,并返回异步操作的结果(需要释放await_suspend使用的资源等)
int await_resume() { return _in.guess; }
};
5.3 开发示例
我们进一步将前面的猜拳游戏进行改进,编程co_await
,让I/O异步进行,不再占用主线程的运行时间。
// full source: https://github.com/luoqiangwei/CXX_Simple_Coroutine_Example/blob/main/GuessCoroutineRunWithSelf/main.cpp
struct CoRet {
struct promise_type {
int _out;
int _res;
std::exception_ptr exception_;
suspend_always initial_suspend() { return {}; }
suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() {
exception_ = std::current_exception();
}
CoRet get_return_object() {
return {
coroutine_handle<promise_type>::from_promise(*this)
};
}
suspend_never yield_value(int r) {
_out = r;
return {};
}
void return_value(int r) {
_res = r;
cout << "coroutine: set res " << r << endl;
}
};
struct Note {
int guess;
};
struct Input {
Note& _in;
bool await_ready() { return false; }
void await_suspend(coroutine_handle<CoRet::promise_type> h) {
// 更好的做法,是将耗时任务在co_await的时候扔到线程池里,然后这个co_await的调用者线程可以继续做别的事情
// 但是这里还有一个问题,如果在协程调度器里把这个任务submit给线程池了,那么线程池完成任务后如何还给原来的线程调度器呢
std::thread([this, h]() {
std::cin >> _in.guess;
cout << "suspend finish: You input: " << _in.guess << endl;
h.resume();
}).detach(); // detach是为了让线程可以活着离开作用域,否则触发ABORT
}
int await_resume() { return _in.guess; }
};
coroutine_handle<promise_type> _h;
};
// Guess是一个协程实现
CoRet Guess() {
int res = (rand() % 30) + 1;
CoRet::Note note = {};
CoRet::Input input{ note };
cout << "coroutine: Init Finish" << endl;
while (true) {
int g = co_await input;
cout << "coroutine: You guess " << g << ", res: " << res << endl;
int result = res < g ? 1 : (res == g ? 0 : -1);
cout << "coroutine: result is " <<
((result == 1) ? "larger" :
((result == 0) ? "the same" : "smaller")) << endl;
if (result == 0) {
break;
}
}
cout << "coroutine: the game is finish" << endl;
co_return res;
}
int main(int argc, const char * argv[]) {
srand((uint)time(nullptr));
auto coroutine = Guess();
cout << "main: make a guess ..." << endl;
// Start coroutine...
coroutine._h.resume();
int count = 0;
while (true) {
if (coroutine._h.done()) {
cout << "main: the coroutine result is " << coroutine._h.promise()._res << endl;
coroutine._h.destroy();
break;
}
count += 5;
sleep(5);
cout << "main: sleep wait coroutine " << count << " s" << endl;
}
return 0;
}
6. 为什么使用协程?
-
充分利用线程调度的CPU时间
- 以骁龙8750大核为例,3ms调度时间最大可运行约97,590条指令,同时SystemServer在64.64%的调度中,主动放弃了CPU时间
int copy(Stream streamR, Stream streamW) {
char buf[512];
int cnt = 0;
int total = 0;
do {
cnt = streamR.read(sizeof(buf), buf);
if (cnt == 0) break;
cnt = streamW.write(cnt, buf);
total += count;
} while (cnt > 0);
return total;
}
future<int> copy(Stream streamR, Stream streamW) {
char buf[512];
int cnt = 0;
int total = 0;
do {
cnt = co_await streamR.read(sizeof(buf), buf);
if (cnt == 0) break;
cnt = co_await streamW.write(cnt, buf);
total += count;
} while (cnt > 0);
co_return total;
}
-
提高并发能力
-
例如让多个I/O任务并发执行,减少“饥饿”现象
-
让更多任务在一个线程上并行执行
-
-
提高代码可读性
- 解决回调“地狱”
void fetch_user_data(std::function<void(std::string)> callback) {
// 模拟异步操作
std::thread([callback]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
callback("user123");
}).detach();
}
void fetch_user_permissions(const std::string& user, std::function<void(std::string)> callback) {
std::thread([callback]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
callback("admin");
}).detach();
}
void process_permissions(const std::string& permissions) {
std::cout << "Processing permissions: " << permissions << std::endl;
}
// 嵌套回调实现
void perform_task() {
fetch_user_data([](const std::string& user) {
fetch_user_permissions(user, [](const std::string& permissions) {
process_permissions(permissions);
});
});
}
std::future<std::string> fetch_user_data() {
return std::async(std::launch::async, []() -> std::string {
std::this_thread::sleep_for(std::chrono::seconds(1));
return "user123";
});
}
std::future<std::string> fetch_user_permissions(const std::string& user) {
return std::async(std::launch::async, [user]() -> std::string {
std::this_thread::sleep_for(std::chrono::seconds(1));
return "admin";
});
}
void process_permissions(const std::string& permissions) {
std::cout << "Processing permissions: " << permissions << std::endl;
}
// 协程实现任务
std::future<void> perform_task() {
co_await std::async(std::launch::async, []() {});
auto user = co_await fetch_user_data();
auto permissions = co_await fetch_user_permissions(user);
process_permissions(permissions);
}
7. 协程实现
C++协程标准 ref:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4680.pdf
llvm实现 ref:https://llvm.org/docs/Coroutines.html
目前C++协程功能在编译器的两部分实现:
-
Clang:协程语义分析
-
LLVM middle-end:实际协程的构建与优化
由于这个原因,C++没有足够的协程调试信息,协程帧的实现都是LLVM middle-end实现的。
7.1 简介
协程在编译器(或汇编角度)的角度来看,是一个拥有一个或多个**暂停点(suspend points)**的方法。当它运行到暂停点后,协程将会停止运行,并且跳回到调用者。一个暂停的协程可以继续执行,也可以直接被销毁。
- 下面这个例子中,我们调用了函数
f
,它将会返回一个协程句柄,main函数可以使用这个句柄恢复协程运行,或者销毁它:
int main() {
void* co_hdl = f();
co_hdl.resume();
co_hdl.resume();
co_hdl.destory();
return 0;
}
当协程运行时,除了和普通函数调用一样会产生新的函数栈帧,它还会使用额外的存储区域来保存协程的状态,让协程在暂停执行后,自身的“上下文”信息将能够保留,这块空间被称为协程帧(coroutine frame)。协程帧在协程第一次被调用时创建,在协程运行结束或调用者销毁时被销毁。
协程编译产生过程:
-
**初始状态:一开始,编译器将会将协程当作一个普通的函数,这个函数中通过LLMV协程固有函数(coroutine intrinsics)**来定义协程的结构和行为。
-
**协程重写:**LLVM会在将协程实现降级(coroutine lowering)到中间代码表示的过程中,将开始的协程函数重写为两部分:
-
-
它是协程的初始入口点(entrypoint)
-
从协程运行开始,直到执行到一个**暂停点(suspend points)**为止。
-
-
- 其余的协程函数逻辑将会被拆分成若干个恢复函数,用于在协程挂起(暂停)后恢复执行。
-
-
**协程状态管理:**所有需要在暂停协程执行时保存的状态信息,必须持久化保存在协程帧(coroutine frame)中。
-
局部变量
-
协程当前执行为止
-
恢复、销毁协程的函数指针
-
-
**恢复函数:**必须能够正常恢复协程,以及处理恢复失败的情况。
-
正常恢复**(normal resumption)**:
- 从协程的暂停点恢复,继续执行之后的逻辑
-
恢复异常**(abnormal resumption)**:
- 如果协程恢复出现异常,必须进行清理(包含销毁协程帧等),而不是暂停协程。
-
7.1.1 协程帧
C++标准使用协程状态描述协程帧分配的空间,而在编译器的视角,协程帧用来描述一个协程所必需的数据结构,它被定义成:
struct {
void (*__r)(); // function pointer to the `resume` function
void (*__d)(); // function pointer to the `destroy` function
promise_type; // the corresponding `promise_type`
... // Any other needed information
}
7.1.2 协程执行
Impl Ref:https://llvm.org/docs/Coroutines.html
Image Ref: https://lewissbaker.github.io/2017/09/25/coroutine-theory#an-illustration
用一个简单的例子来描述协程的运行过程。
现在假设我们在执行main()
函数,它将会调用一个协程f(int a)
在调用之前的状态:
当协程f(5)
被调用时,它会先创建一个堆栈架构f()
,和普通的函数调用一样
当协程f()
在堆上为协程帧分配了内存,并将参数值复制到协程栈中,将会得到下面的结果:
(下面指向Heap中的协程帧的寄存器是帧指针寄存器,aarch64用的是x29,IA64是rbp)
如果协程f()
调用另一个函数g()
,堆栈视图看起来将会是:
当函数g()
返回时(假设次数传回了返回值b,并且被协程保存在了协程帧中),对应的栈帧被销毁,并恢复f()
的激活帧:
如果此时协程f()
到达了暂停点,并且不需要销毁协程帧,执行流将返回main()
。
此时协程f()
的栈帧将会从栈中弹出,同时将暂停点(也可以称为还原点)的信息保存在堆内存里的协程栈中。协程第一次暂停执行的时候,会向调用者返回包含协程栈句柄的数据结构,后续调用者可以通过协程句柄来恢复协程运行。
这个协程句柄可以在函数之间,甚至线程之间移动,后续可以在一个合适的时机恢复这个协程的运行。
当后续某个函数(比如h()
)恢复协程运行的会后,堆栈视图类似下面这个样子:
C++协程的激活帧
C++协程在堆上的栈
7.2 LLVM将协程向低级代码转化(Lowering)的三种模式
从LLVM IR转换底层实际实现(LLVM C++后端实现的)
降级模式 | 主要特点 | 使用场景 |
---|---|---|
Switched-Resume Lowering | 使用状态变量实现状态机恢复 | 普通同步协程,挂起点较多 |
Returned-Continuation Lowering | 返回恢复句柄或函数以继续协程的执行 | 高性能协程或恢复管理严格场景 |
Async Lowering | 挂起点与异步任务绑定,恢复由调度器驱动 | 异步编程模型,如 I/O 操作 |
7.2.1 Switched-Resume Lowering (C++语言使用的协程低级实现模式)
Switched-Resume Lowering 是一种将协程降级为通过显式状态机进行恢复的机制。
7.2.1.1 特点:
-
状态机的核心思想:每次恢复协程时,会检查协程帧中的状态变量,决定协程恢复后从哪里继续执行。
-
适用场景:适用于需要精确控制协程状态转移的场景,比如有多个挂起点和复杂逻辑的协程。
-
实现细节:
-
在协程的降级过程中,会生成一个状态变量(通常是整数)来表示协程的当前状态。
-
挂起点处将更新状态变量。
-
在恢复协程时,通过检查状态变量的值,执行对应的分支代码(即状态机的具体实现)。
-
状态变量和其他持久化数据都存储在协程帧中。
-
7.2.1.2 实现:
核心在于如何通过协程对象和几个关键函数(ramp function、resume function、destroy function)来管理其生命周期和恢复机制。这个模式通过在协程对象中存储挂起点的索引,确保在恢复和销毁时能够准确地跳转到正确的位置,并通过一套标准的 ABI 来提供协程对象的统一访问接口。
- Coroutine Object (协程对象)
在 LLVM 中,协程被视为一个“协程对象”(coroutine object),它表示对特定协程实例的一个句柄。这个协程对象包含了协程的状态信息,比如协程是否已经完成,如何恢复协程,如何销毁协程等。
协程对象的功能:
-
llvm.coro.done
:可以查询协程对象是否已经完成。返回布尔值,表示协程是否执行完毕。 -
llvm.coro.resume
:如果协程尚未完成,可以使用这个指令恢复协程的执行,继续从上次挂起的地方执行。 -
llvm.coro.destroy
:销毁协程对象,即使协程已经完成,销毁操作仍需显式调用。这表示协程资源的清理,避免内存泄漏。 -
llvm.coro.promise
:通过这个指令,可以提取协程对象中的 promise 存储。promise 是一个固定大小和对齐方式的数据结构,它代表协程在执行时所承诺的结果。这个结构的大小和对齐方式必须与协程实现中的承诺一致。
重要提示:在协程运行过程中与协程对象交互可能会导致未定义行为。也就是说,一旦协程开始执行,就不应该随意改变协程对象的状态,除非使用了合适的内建指令。
- 三个协程组成函数(Ramp Function、Resume Function、Destroy Function)
LLVM 中的协程被分为三个函数,分别代表不同的控制流进入方式:
-
Ramp Function:
-
这是协程的初始入口函数,最初由前端代码调用。它可以接收任意的参数,并返回一个指向协程对象的指针。
-
功能:该函数负责初始化协程并返回一个指向协程对象的指针。
-
-
-
当协程被恢复时(即调用
llvm.coro.resume
),会调用这个函数。它接收一个指向协程对象的指针,并返回void
。 -
功能:恢复协程的执行,继续从上次挂起的地方执行。
-
-
-
当协程被销毁时(即调用
llvm.coro.destroy
),会调用这个函数,接收一个指向协程对象的指针,并返回void
。 -
功能:清理协程相关的资源,销毁协程对象。
-
这三种函数提供了协程的基本操作:启动、恢复和销毁。
- Suspend Points和Index机制
-
Suspend Points:每个协程中的“挂起点”(suspend points)是协程执行过程中的暂停位置。当协程执行到某个挂起点时,它的状态会被保存,并且控制权会返回给调用者。
-
Active Suspend Index:每个协程对象都保存一个指示当前活跃挂起点的索引。每个挂起点都有一个唯一的索引值,用于指示协程在哪个挂起点处暂停。
-
Resume/Destroy Functions:
resume
和destroy
函数需要知道当前挂起点的索引,以便在恢复或销毁时能够准确地跳转到协程的正确位置。因此,resume
和destroy
函数的实现需要根据该索引来确定控制流跳转到哪个挂起点。
Switched-Resume名字的由来:因为这些函数需要根据挂起点的索引切换(switch),所以这种机制被称为 “Switched-Resume Lowering”。
- 协程对象的固定格式
-
协程对象包含了几个固定的指针,指向
resume
和destroy
函数。LLVM 将这些函数指针存储在协程对象的已知偏移位置。所有协程共享相同的 ABI(应用二进制接口),使得不同的协程可以使用相同的机制来恢复或销毁。 -
完成的协程:如果一个协程已经完成,协程对象中的
resume
指针将会是null
,表示该协程已经不再可恢复。
- 协程对象的内存分配和释放
LLVM 使用了一个稍微复杂的协议来分配和释放协程对象。这个协议允许通过内联来消除协程对象的分配,从而提高性能。这意味着,协程对象的内存分配可能在编译时被优化掉,以减少运行时的开销。
- 前端生成的协程代码
-
在前端代码生成过程中,可能会直接调用协程函数。这个调用实际上会转化为对 “ramp function” 的调用,并返回协程对象的指针。
-
重要信息:前端应该始终使用适当的内建指令来恢复或销毁协程。例如,应该使用
llvm.coro.resume
来恢复协程,而不是直接修改协程对象。
7.2.1.3 示例:
协程的暂停和恢复基于挂起点的索引伪代码逻辑:
struct CoroutineFrame {
int suspend_point_index; // 当前挂起点索引
... // 持久化的局部变量
};
void ramp_function(CoroutineFrame* frame, ...args) {
frame->suspend_point_index = 0;
// 协程的初始化逻辑
if (/* 挂起条件 */) {
frame->suspend_point_index = 1;
return; // 挂起
}
// 后续代码
}
void resume_function(CoroutineFrame* frame) {
switch (frame->suspend_point_index) {
case 0:
ramp_function(frame); // 恢复到初始点
break;
case 1:
// 从挂起点 1 恢复
break;
...
}
}
void destroy_function(CoroutineFrame* frame) {
// 清理资源
delete frame;
}
调用示例:
CoroutineFrame* frame = new CoroutineFrame();
ramp_function(frame, ...args); // 调用 ramp function
resume_function(frame); // 恢复执行
destroy_function(frame); // 销毁协程
7.2.2 Returned-Continuation Lowering
Returned-Continuation Lowering 是一种将协程降级为通过返回值(继续函数指针和上下文状态)表示下一个操作的模式。
7.2.2.1 特点:
-
恢复通过返回值:每次挂起后,协程的返回值包含恢复协程所需的信息(通常是一个继续函数的指针或句柄)。
-
适用场景:适用于需要显式管理恢复逻辑的高性能场景,常见于编译器生成异步代码的实现。
-
实现细节:
-
协程挂起时,会返回一个特殊的对象(如函数指针或封装句柄),表示恢复逻辑。
-
恢复协程时,调用返回的句柄进行恢复。
-
这种方法避免了状态机的显式实现,更多依赖函数调用来组织逻辑。
-
7.2.2.2 实现:
Returned-Continuation Lowering 是 LLVM 中的协程优化方法之一,它通过引入“继续执行函数”(continuation functions)来管理协程的恢复。在这种实现方式中,协程在每个挂起点(suspend point)处会返回一系列“yielded values”(产出的值)和一个函数指针——即继续执行的函数(continuation function)。然后,通过调用这个函数,协程就可以恢复执行。
这种方法的关键在于:每个挂起点都会有一个对应的“继续执行函数”,当协程被恢复时,直接调用这个继续执行函数来继续协程的执行。
- 协程对象结构
在 Returned-Continuation Lowering 中,协程的实现和其他类型的 Lowering 方法不同。这里的重点是使用一个 继续执行函数指针 和 返回的值 来恢复协程的状态。
-
继续执行函数(Continuation Function):
-
每个挂起点对应一个继续执行函数,当协程被恢复时,执行相应的继续执行函数。
-
该函数返回一个新的继续执行函数指针以及一个“yielded values”列表。
-
通过这种方式,协程的每次恢复都通过调用该函数来进行。
-
-
返回值(Yielded Values):
- 每个挂起点不仅需要返回一个继续执行函数指针,还会返回一个列表,包含该挂起点产生的“yielded values”。这些值将返回给调用者。
- 返回继续函数的两种变体
LLVM 中有两种类型的 Returned-Continuation Lowering:
-
正常的 Returned-Continuation Lowering:
-
在这种方式下,协程可能会多次挂起并恢复。因此,每个挂起点的继续执行函数会返回另一个继续执行函数指针,并且返回一组新的“yielded values”。
-
协程最终通过返回
null
作为继续执行函数指针来表示自己已经完成执行。此时,协程就不再会继续执行。 -
关键点:继续执行函数本身可以递归地返回另一个继续执行函数指针和一个新的值列表。
-
-
Yield-Once Returned-Continuation Lowering:
-
这种方式要求协程只能挂起一次(或者抛出异常)。在执行时,协程将会产生一个继续执行函数和一组“yielded values”。
-
一旦协程完成执行,继续执行函数可以选择返回普通的结果,表示协程已完全执行。
-
关键点:协程只会挂起一次或抛出异常,因此它只会有一个继续执行函数和一组“yielded values”。这种方式通常用于需要一次性挂起的协程。
-
-
协程帧(Coroutine Frame)
协程帧是协程中用来存储和管理其状态的结构体。在 Returned-Continuation Lowering 中,协程帧有以下特点:
-
固定大小的缓冲区:协程帧通常是一个固定大小的缓冲区,传递给
llvm.coro.id
intrinsic 来保证正确的大小和对齐方式。这个缓冲区用于存储协程的状态数据。 -
缓冲区传递:每个继续执行函数都必须接收一个指向该缓冲区的指针,并使用这个缓冲区来管理协程的状态。
-
内存分配:如果协程的缓冲区不足,协程会动态分配更多内存,确保缓冲区至少足够大以存储指针。
缓冲区大小要求:缓冲区必须至少是指针大小,以确保它能够存储指向协程对象的指针。在每次挂起点处,协程可能会修改缓冲区的内容,因此必须保证缓冲区在所有挂起点处的一致性。
- 恢复和销毁的协议
继续执行的参数:每个继续执行函数除了接受协程帧的指针外,还需要一个额外的参数,指示协程是“正常恢复”(值为 0)还是“异常恢复”(非零值)。
-
正常恢复:指协程从挂起点恢复并继续执行。
-
异常恢复:指协程由于异常等原因恢复执行,这种恢复可能需要执行清理操作或跳过一些状态恢复。
- 前端与底层的协议
在这个优化过程中,前端代码必须显式地处理一些 ABI(应用二进制接口)相关的内容,尤其是在如何管理协程状态和继续执行函数指针方面。
-
前端生成的代码:前端需要生成适当的代码来操作协程对象,保证每个挂起点都有一个对应的继续执行函数,并正确处理函数指针和返回值。
-
低级实现:LLVM 提供的
llvm.coro.id.retcon
和llvm.coro.id.retcon.once
是用于实现这种返回继续函数机制的内建指令,它们负责生成协程的 ABI,管理继续执行函数以及返回值的传递。
- 内存分配和内联问题
在进行 Returned-Continuation Lowering 时,LLVM 可能无法在完全内联协程之后有效地消除内存分配操作。如果协程函数被完全内联,LLVM 可能仍然无法去除分配操作,这是因为内联之后仍然会有协程对象的创建和销毁操作。
- 内存分配:对于协程来说,可能需要动态分配内存,尤其是在返回继续执行函数时。如果协程对象的大小和内存管理没有得到优化,可能会引入额外的内存开销。
- 总结
Returned-Continuation Lowering 是将协程转换为更低级的控制流方式的一种方法。它通过在协程的每个挂起点处返回一个 继续执行函数指针 和 yielded values 来实现协程的恢复。这种方法相比传统的协程机制更为明确地控制了协程如何在不同的挂起点恢复执行。协程帧用于存储协程的状态信息,协程通过函数指针来恢复执行,前端和底层的协程内存管理要求非常严格,但这种方法对于那些需要精确控制协程挂起点的场景非常有效。
7.2.2.3 示例:
每个挂起点返回一个继续函数和返回值伪代码逻辑:
struct Continuation {
void* function_pointer; // 下一步的函数指针
... // 持久化的局部变量
};
Continuation* ramp_function(...args) {
Continuation* cont = allocate_continuation();
cont->function_pointer = suspend_point_1;
return cont;
}
Continuation* suspend_point_1(Continuation* cont) {
// 恢复挂起点 1 的逻辑
if (/* 挂起条件 */) {
cont->function_pointer = suspend_point_2;
return cont; // 返回下一个 continuation
}
return nullptr; // 协程结束
}
Continuation* suspend_point_2(Continuation* cont) {
// 恢复挂起点 2 的逻辑
return nullptr; // 协程结束
}
调用示例:
Continuation* cont = ramp_function(...args);
while (cont) {
cont = ((ContinuationFunction)cont->function_pointer)(cont);
}
7.2.3 Async Lowering
Async Lowering 是一种专门为异步协程设计的降级模式,主要适用于异步编程模型。
7.2.3.1 特点:
-
异步挂起点处理:每次挂起点可能对应一个异步操作(如 I/O、网络请求)。
-
依赖事件循环:协程恢复由事件循环(event loop)或调度器触发。
-
适用场景:用于现代异步编程框架(如 C++
async/await
、JavaScriptPromise
)。
7.2.3.2 核心概念:
- 异步上下文(Async Context)
-
定义:异步上下文是协程运行时的关键数据结构,用于管理协程的状态、调用链和参数传递。
-
结构:上下文中包含对调用者的上下文的引用(如
caller_context
),用于在协程间传递状态。
struct async_context {
struct async_context *caller_context;
...
}
-
作用:协程通过异步上下文来:
-
保存挂起点处需要跨点存活的值。
-
在协程的恢复函数中通过投影函数(context projection function)恢复调用者的上下文。
-
- 协程帧(Coroutine Frame)
-
协程帧保存协程执行状态及挂起点需要保留的变量。
-
存储位置:协程帧作为异步上下文的尾部(tail)存储。
-
分配:前端负责根据
llvm.coro.id.async
提供的大小和对齐信息分配协程帧。
- 投影函数(Context Projection Function)
-
定义:用于在上下文中定位恢复函数需要的上下文。
-
示例:在下面代码中,
context_projection_function
从被调用者上下文中提取调用者上下文。
char *context_projection_function(struct async_context *callee_ctxt) {
return callee_ctxt->caller_context;
}
- 恢复函数(Resume Function)
-
每个挂起点都有一个与之关联的恢复函数,用于恢复协程执行。
-
恢复函数是由
llvm.coro.async.resume
生成的函数指针,用于指示协程的恢复点。
%resume_func_ptr = call ptr @llvm.coro.async.resume()
- 挂起函数(Suspend Function)
-
协程的挂起点需要一个挂起函数,其签名和调用约定与协程一致。
-
挂起函数由
llvm.coro.suspend.async
调用,负责转移控制权到其他函数。
7.2.3.3 核心流程:
- 协程的分解
-
在 Async Lowering 中,协程会被分解为:
-
Ramp Function:负责协程的初始化。
-
Resume Functions:每个挂起点对应一个恢复函数,管理挂起后的恢复执行。
-
- 挂起点的执行
-
每个挂起点通过调用
llvm.coro.suspend.async
实现挂起。 -
挂起点调用的签名如下:
call {ptr, ptr, ptr} (ptr, ptr, ...) @llvm.coro.suspend.async( ptr %resume_func_ptr, ptr %context_projection_function, ptr %suspend_function, ptr %arg1, ptr %arg2, i8 %arg3)
-
参数说明:
-
%resume_func_ptr
:恢复函数指针。 -
%context_projection_function
:上下文投影函数,用于从上下文中提取调用者上下文。 -
%suspend_function
:挂起函数,表示挂起后转移到的函数。 -
%arg1
,%arg2
,%arg3
:挂起函数的参数。
-
-
- 上下文和帧管理
- 异步协程的上下文初始大小和对齐要求由
llvm.coro.id.async
提供。
call {ptr, ptr, ptr} @llvm.coro.id.async(
ptr %async.ctxt,
uint32_t %context_size,
uint32_t %alignment)
- 控制流
挂起:通过 llvm.coro.suspend.async
,协程将控制权转移给挂起函数。
恢复:通过恢复函数指针 %resume_func_ptr
恢复协程执行。
上下文传递:通过上下文投影函数确保正确的异步上下文传递。
7.2.3.4 Async Function Pointer Struct:
LLVM 的 llvm.coro.id.async
要求前端提供一个与每个异步协程相关联的结构体,用于描述异步协程的信息:
struct async_function_pointer {
uint32_t relative_function_pointer_to_async_impl;
uint32_t context_size;
};
-
relative_function_pointer_to_async_impl:异步协程实现的相对函数指针。
-
context_size:协程上下文的大小。
前端可以使用这个结构体来确定协程所需的上下文大小和其他元数据。
7.2.3.5 前端责任:
LLVM 的 Async Lowering 强调前端对协程控制流的精细管理,包括:
-
内存分配:前端需要为协程上下文分配内存,并根据 Lowering 的需求更新大小和对齐信息。
-
控制流管理:挂起点和恢复点的逻辑由前端显式实现,包括:
-
提供恢复函数指针和挂起函数。
-
通过上下文投影函数实现上下文的切换。
-
-
参数传递:前端需要为每个挂起点提供正确的参数列表,并管理协程帧中变量的存活时间。
7.2.3.6 示例:
协程依赖异步上下文的传递与恢复伪代码逻辑:
struct AsyncContext {
AsyncContext* caller_context; // 调用者上下文
... // 异步调用链的数据
};
struct CoroutineFrame {
AsyncContext* context;
... // 协程帧的持久化数据
};
void ramp_function(AsyncContext* context, ...args) {
CoroutineFrame* frame = new CoroutineFrame();
frame->context = context;
// 协程的初始化逻辑
if (/* 挂起条件 */) {
// 保存挂起点
context->caller_context = get_current_async_context();
suspend_function(async_resume_function, frame);
return;
}
// 协程完成
}
void async_resume_function(AsyncContext* context) {
CoroutineFrame* frame = get_frame_from_context(context);
// 恢复协程逻辑
}
void suspend_function(void (*resume_func)(AsyncContext*), CoroutineFrame* frame) {
// 保存 resume 函数,并进入异步挂起
save_async_context(resume_func, frame->context);
}
调用示例:
AsyncContext* root_context = create_async_context();
ramp_function(root_context, ...args); // 触发协程
7.3 完整协程实现步骤(将IR低级化转换过程)
LLVM IR协程向底层代码转换:https://github.com/llvm/llvm-project/tree/700d9ac9ef82fa5aa6b2972e8656ab5055a90d15/llvm/lib/Transforms/Coroutines
LLVM IR的不同协程模式的实现(内部测试用):https://github.com/llvm/llvm-project/tree/700d9ac9ef82fa5aa6b2972e8656ab5055a90d15/llvm/test/Transforms/Coroutines
**完整的IR代码:**https://github.com/luoqiangwei/CXX_Simple_Coroutine_Example/blob/main/GuessCoroutine/main_llvm_ir.ll
7.3.1 CoroEarly
CoroEarly
转换步骤的作用是将协程帧细节相关的协程内建函数转换为低级化实现(底层实现),暴露协程帧的实际结构用于后续处理步骤,这个步骤结束后,这些相关的内建函数就可以删除不再保留了。这个转换阶段会将coro.frame
、coro.done
和coro.promise
这些内建函数转换为具体的低级实现。
7.3.2 CoroSplit
CoroSplit
转换步骤的作用是构建协程帧,并将协程的恢复(resume)和销毁(destroy)部分分割提取到单独的函数中。这个阶段同时还会将coro.await.suspend.void
、coro.await.suspend.bool
和coro.await.suspend.handle
这些内建函数转换为具体的低级实现。
7.3.3 CoroAnnotationElide
CoroAnnotationElide
转换步骤会找到所有生命周期明确且协程帧可以省略(“must elide”)的协程调用,并将 coro.begin
内建函数替换为直接放置在调用者栈上的协程帧地址。同时将 coro.alloc
和 coro.free
内建函数分别替换为 false
和 null
,从而消除不必要的内存释放代码。
7.3.4 CoroElide
CoroElide
转换步骤会检查内联协程是否适合进行堆分配消除优化。如果适合,它会将 coro.begin
内建函数替换为指向协程帧的地址,该协程帧被放置在调用者的栈上;同时,它会将coro.alloc
和coro.free
内建函数分别替换为false
和null
,从而消除内存释放的代码。这个阶段还会尽可能将 coro.resume
和 coro.destroy
内建函数替换为直接调用特定协程的恢复(resume)和销毁(destroy)函数。
7.3.5 CoroCleanup
CoroCleanup
转换步骤在最后执行,它将其他未被转换(和低级化)的内建函数转换为具体的低级实现。
8. 协程实践 - 协程调度器+文件拷贝协程任务
https://github.com/luoqiangwei/CXX_Simple_Coroutine_Example/blob/main/CoroutineScheduler/main.cpp
9. 协程应用
- App框架。UI线程将耗时任务封装成协程,分发给协程调度器调度(可参照Android Kotlin的协程)。
Img Ref:https://www.howtodoandroid.com/kotlin-coroutines-android/
-
异步I/O。I/O密集型的任务,封装到协程中,由协程调度器并发进行异步I/O。
-
用户态轻量级线程调度器,以及配套开发框架。最大化利用内核调度器分配的CPU时间(可参照Golang的实现)。
10. 附录
10.1 测试高通SM8750大核最大频率每条指令所需时间周期
让大核运行在最大频率,然后测试程序在大核上的MIPS
# 查看CPU最大频率
cat /sys/devices/system/cpu/cpu7/cpufreq/scaling_available_frequencies
# 4089600Hz根本无法提上去,3014400Hz总是会回落到2784000Hz(反复横跳),
# 所以最后用2784000Hz来测试!
echo 2784000> /sys/devices/system/cpu/cpu7/cpufreq/scaling_max_freq
echo 2784000> /sys/devices/system/cpu/cpu7/cpufreq/scaling_min_freq
# 2784000Hz就稳定了 😓
cat /sys/devices/system/cpu/cpu7/cpufreq/scaling_cur_freq
# 运行大概5分钟就可以Ctrl + C了
CPU_AFFINITY=7 simpleperf stat ./测试程序
10.1.1 测试程序(加法+循环)
int main() {
unsigned long long res = 0;
unsigned long long sum = 0;
for (unsigned long long i = 0; i < 10000000000000000ULL; i++) {
res = i;
sum += res;
asm volatile("nop");
}
return (int)(res + sum);
}
结果:每个CPU周期执行2.9844条指令(instructions / cpu-cycles)
Performance counter statistics:
# count event_name # count / runtime
# 总共的CPU时钟周期
902,484,444,032 cpu-cycles # 2.782264 GHz
# 前端(取指+译码)有多少周期空闲:2.356%
2,126,629,380 stalled-cycles-frontend # 6.556 M/sec
# 后端(执行+写回)有多少周期空闲:3.037%
2,740,797,017 stalled-cycles-backend # 8.450 M/sec
# CPU执行的指令总数
2,693,398,982,520 instructions # 8.303 G/sec
# CPU执行的分支指令数目(遇到条件跳转时触发):33.312%
897,225,753,075 branch-instructions # 2.766 G/sec
# CPU执行分支指令预测失败次数(遇到条件跳转时触发):0.0000006159%
1,658,970 branch-misses # 5.114 K/sec
# 测量的总时间
324370.759684(ms) task-clock # 0.999991 cpus used
# 上下文切换次数
18 context-switches # 0.055 /sec
# 缺页次数
246 page-faults # 0.758 /sec
10.1.2 测试程序(加法)
int main() {
unsigned long long res = 0;
unsigned long long sum = 0;
#pragma unroll
for (unsigned long long i = 0; i < 10000000000000000ULL; i++) {
res = i;
sum += res;
asm volatile("nop");
}
return (int)(res + sum);
}
结果:每个CPU周期执行7.9542条指令(instructions / cpu-cycles)
Performance counter statistics:
# count event_name # count / runtime
985,449,462,578 cpu-cycles # 2.782279 GHz
2,176,472,797 stalled-cycles-frontend # 6.145 M/sec
3,043,795,245 stalled-cycles-backend # 8.594 M/sec
7,838,421,723,115 instructions # 22.131 G/sec
# 条件跳转指令的占比:10.0000%
783,852,286,826 branch-instructions # 2.213 G/sec
1,417,902 branch-misses # 4.003 K/sec
354187.916498(ms) task-clock # 0.999999 cpus used
5 context-switches # 0.014 /sec
238 page-faults # 0.672 /sec
10.1.3 测试程序(乘法)
int main() {
unsigned long long res = 0;
unsigned long long sum = 0;
#pragma unroll
for (unsigned long long i = 0; i < 10000000000000000ULL; i++) {
res = i;
sum *= res;
asm volatile("nop");
}
return (int)(res + sum);
}
结果:每个CPU周期执行7.9544条指令(instructions / cpu-cycles)
Performance counter statistics:
# count event_name # count / runtime
498,646,336,876 cpu-cycles # 2.782260 GHz
1,107,763,386 stalled-cycles-frontend # 6.181 M/sec
1,497,657,830 stalled-cycles-backend # 8.356 M/sec
3,966,417,253,814 instructions # 22.131 G/sec
# 条件跳转指令的占比:10.0000%
396,646,880,276 branch-instructions # 2.213 G/sec
743,042 branch-misses # 4.146 K/sec
179223.487370(ms) task-clock # 0.999997 cpus used
13 context-switches # 0.073 /sec
229 page-faults # 1.278 /sec
10.2 分析一个二进制程序指令的比例(但是实际跳转的消耗还是要用perf)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 函数用于执行 objdump 并获取汇编代码
char* get_disassembly(const char* binary_path) {
// 打开临时文件
FILE* tmp_file = tmpfile();
if (!tmp_file) {
perror("Failed to open temporary file");
exit(EXIT_FAILURE);
}
// 执行 objdump 并将输出重定向到临时文件
char command[256];
snprintf(command, sizeof(command), "objdump -d %s > %p", binary_path, tmp_file);
if (system(command) != 0) {
perror("Failed to execute objdump");
exit(EXIT_FAILURE);
}
// 重新定位临时文件的文件指针到开始
rewind(tmp_file);
// 读取临时文件内容到缓冲区
fseek(tmp_file, 0, SEEK_END);
long size = ftell(tmp_file);
fseek(tmp_file, 0, SEEK_SET);
char* disassembly = (char*)malloc(size + 1);
fread(disassembly, 1, size, tmp_file);
disassembly[size] = '\0';
// 关闭临时文件
fclose(tmp_file);
return disassembly;
}
// 简单的字符串匹配函数,用于查找特定类型的指令
int count_instructions(char* disassembly, const char* instruction_type) {
int count = 0;
char* pos = disassembly;
char* result = NULL;
// 使用 strstr 来查找指令
while ((result = strstr(pos, instruction_type)) != NULL) {
count++;
pos = result + 1; // 移动到找到的指令之后,继续搜索
}
return count;
}
// 函数用于计算所有指令的总数
int count_all_instructions(char* disassembly) {
int count = 0;
char* line = strtok(disassembly, "\n");
while (line) {
count++;
line = strtok(NULL, "\n");
}
return count;
}
int main(int argc, char* argv[]) {
if (argc != 2) {
printf("Usage: %s <binary_path>\n", argv[0]);
return EXIT_FAILURE;
}
const char* binary_path = argv[1];
char* disassembly = get_disassembly(binary_path);
int jumps = count_instructions(disassembly, "jmp");
jumps += count_instructions(disassembly, "jcc");
int calls = count_instructions(disassembly, "call");
int rets = count_instructions(disassembly, "ret");
int movs = count_instructions(disassembly, "mov");
int adds = count_instructions(disassembly, "add");
int subs = count_instructions(disassembly, "sub");
int all_instructions = count_all_instructions(disassembly);
printf("Jumps: %d\n", jumps);
printf("Calls: %d\n", calls);
printf("Returns: %d\n", rets);
printf("Movs: %d\n", movs);
printf("Adds: %d\n", adds);
printf("Subs: %d\n", subs);
printf("All Instructions: %d\n", all_instructions);
free(disassembly);
return EXIT_SUCCESS;
}
10.3 普通程序可以用完完整的调度时间吗?
cat /proc/<pid>/status
我们可以通过下面的公式来计算未用完调度时间的百分比
过早完成调度比率(%) = voluntary_ctxt_switches ÷ (voluntary_ctxt_switches + nonvoluntary_ctxt_switches) × 100%
以下是一些计算结果:
-
SurfaceFlinger: 94.31%
-
SystemServer: 64.64%
-
Init: 78.89%
-
com.miui.home: 70.91%
这些比率反映了进程主动放弃调度时间的行为。例如,SurfaceFlinger主要执行短时任务(或I/O频繁),主动让出CPU;而 SystemServer的CPU使用强度相对较高。
10.4 吐槽
用惯了飞书文档,再用Markdown是真的难受啊!但是博客又需要用Markdown,有没有什么富文本博客框架能够完美兼容飞书文档的!?
光把飞书文档转换成Markdown就要一个小时,还得同步图床发布啥的,还很丑陋,不会自动适应系统黑夜模式,不支持很多特别的格式……
飞书文档的格式是真的好看,而且操作起来太方便了!所以不是不想发布博客,主要实在是太费力!都可以干很多事情了!