Asyncio
asyncio is a c++20 library to write concurrent code using the async/await syntax.
Install / Use
/learn @netcan/AsyncioREADME
- asyncio
- Build & Run
- Hello world
- Dump callstack
- TCP Echo
- Gather
- WaitFor
- ScheduledTask & Cancel
- Tested Compiler
- TODO
- FAQ
- How to handle the cancelled coroutine?
- The coroutine performance and comparisons with other methods
- Why needs some primitives(async_mutex/async_conditional_variable) even in the single threaded mode?
- Why is the epoll version slower?
- io_uring better than epoll
- Why is python asyncio so performant?
- How to print the coroutine callstack?
- Will the buffer size of the benchmark code impact on performance?
- Reference
asyncio
Asyncio is a C++20 coroutine library to write concurrent code using the await syntax, and imitate python asyncio library.
Build & Run
$ git clone --recursive https://github.com/netcan/asyncio.git
$ cd asyncio
$ mkdir build
$ cd build
$ cmake ..
$ make -j
Hello world
Task<> hello_world() {
fmt::print("hello\n");
co_await asyncio::sleep(1s);
fmt::print("world\n");
}
int main() {
asyncio::run(hello_world());
}
output:
hello
world
Dump callstack
Task<int> factorial(int n) {
if (n <= 1) {
co_await dump_callstack();
co_return 1;
}
co_return (co_await factorial(n - 1)) * n;
}
int main() {
fmt::print("run result: {}\n", asyncio::run(factorial(10)));
return 0;
}
output:
[0] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:17
[1] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[2] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[3] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[4] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[5] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[6] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[7] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[8] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
[9] void factorial(factorial(int)::_Z9factoriali.Frame*) at asyncio/test/st/hello_world.cpp:20
run result: 3628800
TCP Echo
Client
Task<> tcp_echo_client(std::string_view message) {
auto stream = co_await asyncio::open_connection("127.0.0.1", 8888);
fmt::print("Send: '{}'\n", message);
co_await stream.write(Stream::Buffer(message.begin(), message.end()));
auto data = co_await stream.read(100);
fmt::print("Received: '{}'\n", data.data());
fmt::print("Close the connection\n");
stream.close(); // unneeded, just imitate python
}
int main(int argc, char** argv) {
asyncio::run(tcp_echo_client("hello world!"));
return 0;
}
output:
Send: 'hello world!'
Received: 'hello world!'
Close the connection
Server
Task<> handle_echo(Stream stream) {
auto& sockinfo = stream.get_sock_info();
auto sa = reinterpret_cast<const sockaddr*>(&sockinfo);
char addr[INET6_ADDRSTRLEN] {};
auto data = co_await stream.read(100);
fmt::print("Received: '{}' from '{}:{}'\n", data.data(),
inet_ntop(sockinfo.ss_family, get_in_addr(sa), addr, sizeof addr),
get_in_port(sa));
fmt::print("Send: '{}'\n", data.data());
co_await stream.write(data);
fmt::print("Close the connection\n");
stream.close(); // optional, close connection early
}
Task<> echo_server() {
auto server = co_await asyncio::start_server(
handle_echo, "127.0.0.1", 8888);
fmt::print("Serving on 127.0.0.1:8888\n");
co_await server.serve_forever();
}
int main() {
asyncio::run(echo_server());
return 0;
}
output:
Serving on 127.0.0.1:8888
Received: 'Hello World!' from '127.0.0.1:49588'
Send: 'Hello World!'
Close the connection
Benchmark
Using the Apache Benchmarking tool, 10000000 requests that each size is 106 byte, 1000 concurrency, enable keepalive, the QPS/RPS result below:
| framework | RPS [#/sec] (mean) | Language | Pattern | |----------------|--------------------:| --------: |----------:| | python asyncio | 47393.59 | Python | coroutine | | python asyncio with uvloop | 100426.97 | Python | coroutine | | this project | 164457.63 | C++20 | coroutine | | asio | 159322.66 | C++20 | coroutine | | tokio-rs | 156852.70 | Rust1.59.0-nightly | coroutine | | epoll | 153147.79 | C | eventloop | | libevent | 136996.46 | C | callback | | libuv | 159937.73 | C | callback |
The result may be incredible, but it is possible, the magnitude of IO is milliseconds(1e-3 s), while the magnitude of the coroutine is nanoseconds(1e-9 s).
More detail see: benchmark.md
Gather
auto factorial(std::string_view name, int number) -> Task<int> {
int r = 1;
for (int i = 2; i <= number; ++i) {
fmt::print("Task {}: Compute factorial({}), currently i={}...\n", name, number, i);
co_await asyncio::sleep(500ms);
r *= i;
}
fmt::print("Task {}: factorial({}) = {}\n", name, number, r);
co_return r;
};
auto test_void_func() -> Task<> {
fmt::print("this is a void value\n");
co_return;
};
int main() {
asyncio::run([&]() -> Task<> {
auto&& [a, b, c, _void] = co_await asyncio::gather(
factorial("A", 2),
factorial("B", 3),
factorial("C", 4),
test_void_func());
assert(a == 2);
assert(b == 6);
assert(c == 24);
}());
}
output:
Task A: Compute factorial(2), currently i=2...
Task B: Compute factorial(3), currently i=2...
Task C: Compute factorial(4), currently i=2...
this is a void value
Task C: Compute factorial(4), currently i=3...
Task A: factorial(2) = 2
Task B: Compute factorial(3), currently i=3...
Task B: factorial(3) = 6
Task C: Compute factorial(4), currently i=4...
Task C: factorial(4) = 24
WaitFor
asyncio::run([&]() -> Task<> {
REQUIRE_NOTHROW(co_await wait_for(gather(sleep(10ms), sleep(20ms), sleep(30ms)), 50ms));
REQUIRE_THROWS_AS(co_await wait_for(gather(sleep(10ms), sleep(80ms), sleep(30ms)), 50ms),
TimeoutError);
}());
ScheduledTask & Cancel
auto say_after = [&](auto delay, std::string_view what) -> Task<> {
co_await asyncio::sleep(delay);
fmt::print("{}\n", what);
};
GIVEN("schedule sleep and cancel") {
auto async_main = [&]() -> Task<> {
auto task1 = schedule_task(say_after(100ms, "hello"));
auto task2 = schedule_task(say_after(200ms, "world"));
co_await task1;
task2.cancel();
};
auto before_wait = get_event_loop().time();
asyncio::run(async_main());
auto after_wait = get_event_loop().time();
auto diff = after_wait - before_wait;
REQUIRE(diff >= 100ms);
REQUIRE(diff < 200ms);
}
output:
hello
Tested Compiler
- Debian Linux gcc-11/12, gcc-11 crash at Release mode
TODO
- [x] implement result type for code reuse,
variant<monostate, value, exception> - [x] implement coroutine backtrace(dump continuation chain)
- [x] implement some io coroutine(socket/read/write/close)
- [ ] using libuv as backend
FAQ
Source:
- https://www.reddit.com/r/cpp/comments/r5oz8q/asyncio_imitate_pythons_asyncio_for_c/
- https://www.reddit.com/r/cpp/comments/r7xvd1/c20_coroutine_benchmark_result_using_my_coroutine/
How to handle the cancelled coroutine?
Q: technically, you can add a handle that doesn't exist in the event_loop queue. Would the cancelled event become a dangler in such a scenario?
void cancel_handle(Handle& handle) { cancelled_.insert(&handle); }A: it maybe memory leak at some scenario but it's safe, the cancelled set stores handle was destroyed, it notices eventloop when handle was readying, just skip it and remove from cancelled set prevent some memory leaks.
A: you are right, I find a bug at release mode when a handle is destroyed and inserted into the cancelled set, and then another coroutine is created, it has the same address as the destroyed coroutine handle!!! The loop will remove the new ready coroutine had created. fixed pa
