如果你想要在程序中等待100ms,你会如何做?使用sleep_for(100ms)吗? 看完本文后,可以得到一个更好的方法来实现上述操作。
目标
在以往,我们想要让程序等待100ms,我们只能调用线程的sleep函数来阻塞当前线程100ms。
这样做的确可以使得程序等待100ms,但坏处是在这100ms期间,被阻塞的当前线程什么也做不了,白白占用了内存。有了协程之后,我们可以让协程在需要sleep的时候挂起,100ms之后再来恢复执行,完全不需要阻塞当前线程。
用例如下:
Task<int, AsyncExecutor> simple_task2() {
debug("task 2 start ...");
using namespace std::chrono_literals;
// 之前的写法,用 sleep_for 让当前线程睡眠 1 秒
// std::this_thread::sleep_for(1s);
// 等待 1 秒,注意 1s 是 chrono_literals 的字面值写法
co_await 1s;
debug("task 2 returns after 1s.");
co_return 2;
}
这个例子应该很容易理解,之前使用sleep_for
让线程睡眠1秒,这次我们直接使用co_await 1s
,这样就可以在当前协程等待1s的时间内去调度其他协程。
为duration实现await_transform
如果读者对C++11不熟悉,可能会疑惑co_await 1s
中的1s
是个什么东西。实际上这是C++11对字面值的一种支持,本质上就是一个运算符重载,这里的1s
的类型是duration<long long>
。除了秒以外,时间的单位也可以是毫秒、纳秒、分钟、小时等等,这些C++11的duration
都已经提供了完善的支持,因此我们只要对duration
做支持就行。
template<typename ResultType, typename Executor>
struct TaskPromise {
...
template<typename _Rep, typename _Period>
SleepAwaiter await_transform(std::chrono::duration<_Rep, _Period> &&duration) {
return SleepAwaiter(&executor, std::chrono::duration_cast<std::chrono::milliseconds>(duration).count());
}
...
}
这里引入了一个新的类型 SleepAwaiter
,它的任务有两个:
- 确保当前协程在若干毫秒之后恢复执行。
- 确保当前协程恢复执行时要调度到对应的调度器上。
不难想到,std::chrono::duration_cast<std::chrono::milliseconds>(duration).count()
实际上就是把任意单位的 duration
转换成毫秒。
SleepAwaiter
的实现也很简单,我们直接给出:
truct SleepAwaiter {
explicit SleepAwaiter(AbstractExecutor *executor, long long duration) noexcept
: _executor(executor), _duration(duration) {}
bool await_ready() const { return false; }
void await_suspend(std::coroutine_handle<> handle) const {
// 自定义的延时执行工具类,全局只需要一个实例
static Scheduler scheduler;
scheduler.execute([this, handle]() {
// _duration 毫秒之后执行下面的代码
_executor->execute([handle]() {
handle.resume();
});
}, _duration);
}
void await_resume() {}
private:
AbstractExecutor *_executor;
long long _duration;
}
当中最关键的就是Scheduler
的实现了,这个类实际上就是一个独立的定时任务调度器。
定时任务调度器Scheduler
定时任务调度器,可以理解为一个定时器。任何交给它的任务都需要有优先级,优先级的计算规则就是任务延时的长短,于是我们可以使用优先级队列来存储需要执行的任务。
任务队列?有没有想起上一篇文章# C++协程: 协程的调度器提到的LoopExecutor
,如果我们给它加上计时执行的能力,Scheduler
的功能就差不多完成了。换个角度看,LooperExecutor
其实就是Scheduler
的一个特化版本,它的所有任务的延时都是0。
定时任务的数据类型
为了方便对定时任务管理,我们可以定义一个DelayedExecutable
类型,它包含一个函数和它要执行的绝对时间:
class DelayedExecutable {
public:
DelayedExecutable(std::function<void()> &&func, long long delay) : func(std::move(func)) {
using namespace std;
using namespace std::chrono;
auto now = system_clock::now();
// 当前的时间戳,单位毫秒
auto current = duration_cast<milliseconds>(now.time_since_epoch()).count();
// 计算出任务的计划执行时间
scheduled_time = current + delay;
}
// 调用时,返回从当前时间还需要多少毫秒到任务执行时间
long long delay() const {
using namespace std;
using namespace std::chrono;
auto now = system_clock::now();
auto current = duration_cast<milliseconds>(now.time_since_epoch()).count();
return scheduled_time - current;
}
long long get_scheduled_time() const {
return scheduled_time;
}
void operator()() {
func();
}
private:
long long scheduled_time;
std::function<void()> func;
};
定时任务的描述类DelayedExecutable
非常简单,相信大家一看就明白。
为了将DelayedExecutable
存入优先级队列当中,我们还需要给它提供一个比较大小的类:
class DelayedExecutableCompare {
public:
bool operator()(DelayedExecutable &left, DelayedExecutable &right) {
return left.get_scheduled_time() > right.get_scheduled_time();
}
};
这个类将对DelayedExecutable
的比较转换成对它们的执行时间的比较。使用这个类对DelayedExecutable
进行排序时,会使得时间靠前的对象排到前面。
实现定时任务调度器
接下来我们直接给出Scheduler
的实现,由于这个类与前面的LooperExecutor
很像,我们只给出不同的部分:
class Scheduler {
private:
std::condition_variable queue_condition;
std::mutex queue_lock;
// 注意这里改用优先级队列
std::priority_queue<DelayedExecutable, std::vector<DelayedExecutable>, DelayedExecutableCompare> executable_queue;
std::atomic<bool> is_active;
std::thread work_thread;
void run_loop() {
while (is_active.load(std::memory_order_relaxed) || !executable_queue.empty()) {
std::unique_lock lock(queue_lock);
if (executable_queue.empty()) {
queue_condition.wait(lock);
if (executable_queue.empty()) {
continue;
}
}
// 从这里开始于 LooperExecutor 不同,这里需要判断优先级队头的任务,也就是最先要执行的任务是否需要立即执行
auto executable = executable_queue.top();
long long delay = executable.delay();
if (delay > 0) {
// 队头的任务还没到执行时间,等待 delay 毫秒
auto status = queue_condition.wait_for(lock, std::chrono::milliseconds(delay));
// 如果等待期间没有延时比 delay 更小的任务加入,这里就会返回 timeout
if (status != std::cv_status::timeout) {
// 不是 timeout,需要重新计算队头的延时
continue;
}
}
executable_queue.pop();
lock.unlock();
executable();
}
}
public:
Scheduler() {
... // 与 LooperExecutor 完全相同
}
~Scheduler() {
... // 与 LooperExecutor 完全相同
}
void execute(std::function<void()> &&func, long long delay) {
delay = delay < 0 ? 0 : delay;
std::unique_lock lock(queue_lock);
if (is_active.load(std::memory_order_relaxed)) {
// 只有队列为空或者比当前队头任务的延时更小时,需要调用 notify_one
// 其他情况只需要按顺序依次执行即可
bool need_notify = executable_queue.empty() || executable_queue.top().delay() > delay;
executable_queue.push(DelayedExecutable(std::move(func), delay));
lock.unlock();
if (need_notify) {
queue_condition.notify_one();
}
}
}
void shutdown(bool wait_for_complete = true) {
... // 与 LooperExecutor 完全相同
}
void join() {
if (work_thread.joinable()) {
work_thread.join();
}
}
};
通过阅读代码和注释,可以发现延时的实现其实时通过阻塞一个专门用于调度延时任务的线程来做到的。
可能你会说,这不还是有阻塞吗?
是这样的,阻塞是避免不了的。通常来说,我们也不会用一个线程去严格对应一个协程,当一个协程挂起时,执行这个协程的线程就会被空闲出来有机会去调度执行其它协程,进而让线程的利用率得到充分提升。如果有10个协程都需要执行延时,相较于阻塞这10个协程当前所在的10个线程而言,阻塞一个线程显然时更加经济的。
小试牛刀
我们又扩充了C++协程的功能,现在让我们尝试使用这个功能。
auto scheduler = Scheduler();
debug("start")
scheduler.execute([]() { debug("2"); }, 100);
scheduler.execute([]() { debug("1"); }, 50);
scheduler.execute([]() { debug("6"); }, 1000);
scheduler.execute([]() { debug("5"); }, 500);
scheduler.execute([]() { debug("3"); }, 200);
scheduler.execute([]() { debug("4"); }, 300);
scheduler.shutdown();
scheduler.join();
打印的数字是按照时间顺序排序的,但任务的添加却是乱序的。运行结果如下:
16:49:40.590 [Thread-128964672616000] (main.cpp:14) operator(): 1
16:49:40.639 [Thread-128964672616000] (main.cpp:13) operator(): 2
16:49:40.739 [Thread-128964672616000] (main.cpp:17) operator(): 3
16:49:40.839 [Thread-128964672616000] (main.cpp:18) operator(): 4
16:49:41.039 [Thread-128964672616000] (main.cpp:16) operator(): 5
16:49:41.539 [Thread-128964672616000] (main.cpp:15) operator(): 6
可以看到 1-6 的顺序是可以保证的,前面的时间信息也可以看到延时能力基本上是符合预期的。
接下来,我们把前面用了好几次的 Task
的 demo 拿出来,加上延时,顺便也验证一下 AsyncExecutor
和 NewThreadExecutor
的效果:
Task<int, AsyncExecutor> simple_task2() {
debug("task 2 start ...");
using namespace std::chrono_literals;
co_await 1s;
debug("task 2 returns after 1s.");
co_return 2;
}
Task<int, NewThreadExecutor> simple_task3() {
debug("in task 3 start ...");
using namespace std::chrono_literals;
co_await 2s;
debug("task 3 returns after 2s.");
co_return 3;
}
Task<int, LooperExecutor> simple_task() {
debug("task start ...");
using namespace std::chrono_literals;
co_await 100ms;
debug("after 100ms ...");
auto result2 = co_await simple_task2();
debug("returns from task2: ", result2);
co_await 500ms;
debug("after 500ms ...");
auto result3 = co_await simple_task3();
debug("returns from task3: ", result3);
co_return 1 + result2 + result3;
}
void main() {
auto simpleTask = simple_task();
simpleTask.then([](int i) {
debug("simple task end: ", i);
}).catching([](std::exception &e) {
debug("error occurred", e.what());
});
try {
auto i = simpleTask.get_result();
debug("simple task end from get: ", i);
} catch (std::exception &e) {
debug("error: ", e.what());
}
}
运行结果如下:
16:44:02.782 [Thread-127592004843072] (main.cpp:41) simple_task: task start ...
16:44:02.882 [Thread-127592004843072] (main.cpp:44) simple_task: after 100ms ...
16:44:02.882 [Thread-127591988057664] (main.cpp:25) simple_task2: task 2 start ...
16:44:03.883 [Thread-127591988057664] (main.cpp:28) simple_task2: task 2 returns after 1s.
16:44:03.883 [Thread-127592004843072] (main.cpp:46) simple_task: returns from task2: 2
16:44:04.383 [Thread-127592004843072] (main.cpp:49) simple_task: after 500ms ...
16:44:04.383 [Thread-127591988057664] (main.cpp:33) simple_task3: in task 3 start ...
16:44:06.383 [Thread-127591988057664] (main.cpp:36) simple_task3: task 3 returns after 2s.
16:44:06.383 [Thread-127592004843072] (main.cpp:51) simple_task: returns from task3: 3
16:44:06.383 [Thread-127592004843072] (main.cpp:58) operator(): simple task end: 6
16:44:06.383 [Thread-127592012040000] (main.cpp:64) test_tasks: simple task end from get: 6
16:44:06.383 [Thread-127592004843072] (Executor.h:64) run_loop: run_loop exit.
16:44:06.383 [Thread-127591996450368] (Scheduler.h:87) run_loop: run_loop exit.
我们把所有的 sleep_for
都替换成了本文实现的无阻塞的 sleep,运行效果上来看确实可以按照要求实现延时执行。
另外,由于这里的 co_await 1s
这样的操作都是挂起点,因此恢复时也会用协程的调度器去调度。可以看到,simple_task2
的两行日志的线程都是 127591988057664
,这大概是因为 std::async
背后是一个线程池,两次调度都调度到了同一个线程上,当然这个完全取决于 std::async
的实现。而 simple_task3
的两行日志就分别运行在 16816
和 26756
,因为它的调度器是 NewThreadExecutor
,每次都会新起一个线程来实现调度。
总结
本文结合前面的Task
的内容进一步给出了无阻塞式的sleep实现。通过本文的介绍,相信大家对C++协程的理解又加深了一部分。