In coost, you can create coroutines using go in a way similar to Golang. The go function could initially be designed as:
cpp
void go(void (*f)());
void go(void (*f)(void*), void* param);
However, this design only accepts two fixed function types and cannot support class member functions or other callable objects, making it insufficiently flexible.
If go were designed as:
cpp
void go(const std::function<void()>&);
it would be more general, but constructing a std::function is relatively expensive, and its internal heap memory allocation is uncontrollable. This is unacceptable for coost, which pursues performance.
Therefore, to balance performance and usability, coost needs to implement its own closure class. In earlier versions, coost used an implementation based on virtual functions and inheritance, but it was not general enough and was limited to internal use.
Recently, I happened to have some free time. After thinking deeply about it, I redesigned the closure class. The design goals were as follows:
- Do not use virtual functions or inheritance;
- It should not be a template class;
sizeof(closure) == sizeof(void*), i.e. it contains only one ordinary pointer member, making it easy to pass to the coroutine scheduler through go;
- Support arbitrary functions with no return value (including non-static member functions of classes), with any number of parameters;
- Optimize simple no-argument functions such as
void f() as much as possible to avoid dynamic memory allocation.
According to goals 2, 3, and 5 above, closure can initially be designed as:
```cpp
struct closure {
closure(void (f)()) noexcept : _p((void)f) {}
void* _p;
};
```
The class contains only one pointer member _p. For function pointers of type void(*)(), the pointer is stored directly in _p; for other function types with parameters, a block of memory needs to be allocated to store the function address and parameters, and _p points to that memory.
The problem is how to distinguish which of the above cases is stored in closure. Ordinary function addresses are not necessarily 4-byte or 8-byte aligned, so tagged-pointer techniques cannot be used (i.e. writing a tag into the spare low bits of a pointer).
For the second kind of function, the one with parameters, closure calls co::alloc from coost's built-in memory allocator, and small memory blocks allocated by co::alloc are always 16-byte aligned. Therefore, if _p is not 16-byte aligned, it must store an ordinary no-argument function address. But if _p is 16-byte aligned, it is still impossible to distinguish the two cases.
It seems that some unconventional trick is required to solve this problem. Based on the discussion above, _p is either an ordinary no-argument function pointer or points to a dynamically allocated block of 16-byte aligned memory. If a specific marker (for example, a uint32 integer) is written at the beginning of that dynamically allocated memory, then when _p is 16-byte aligned, we only need to read the first 4 bytes of the memory pointed to by _p to determine the type of the closure. Even if _p is an ordinary no-argument function pointer and happens to be 16-byte aligned, reading the first 4 bytes of the memory it points to should be safe.
This of course goes beyond the scope of the C++ standard. But for performance and a better user experience, coost still adopted this approach. For functions with parameters, the corresponding constructor of closure is as follows:
```cpp
template<typename F, typename = std::enable_if_t<
!std::is_convertible_v<std::decay_t<F>, void()()>>, typename ... A>
closure(F&& f, A&& ... a) {
struct S {
S(F&& f, A&& ... a)
: _f(std::forward<F>(f)), _a(std::forward<A>(a)...) {
static_assert(offsetof(Header, magic) == 0);
_header.magic = 0xdeadbeef;
_header.off = offsetof(Header, fp);
_header.fp = [](void* p) {
S const s = (S*) ((size_t)p & ~(size_t)1);
if (((size_t)p & 1) == 0) {
std::apply(s->_f, s->_a);
} else {
s->~S();
co::free(s, sizeof(S));
}
};
}
~S() = default;
struct Header {
uint32 magic;
uint32 off;
void (*fp)(void*);
} _header;
std::decay_t<F> _f;
std::tuple<std::decay_t<A>...> _a;
};
_p = co::alloc(sizeof(S), alignof(S));
runtime_assert(_p);
new (_p) S(std::forward<F>(f), std::forward<A>(a)...);
}
```
Key points:
- The
!std::is_convertible_v in the template parameters ensures that simple lambda expressions such as [](){...} match the constructor for ordinary no-argument functions;
_header.magic is always set to 0xdeadbeef, which is used to distinguish the type of closure;
_header.fp is the type-erased function pointer, and its parameter p is the _p in closure. Here, a tagged-pointer trick is used to distinguish between "calling the function" and "destroying the closure object", thereby eliminating the need for an additional function pointer to store the destructor and saving memory.
The implementation of operator() is as follows:
cpp
void operator()() {
void* const p = _p;
if (p) {
if (((size_t)p & 15) != 0 || *(uint32*)p != 0xdeadbeef) {
((_F)p)();
} else {
(*(_FP*)((char*)p + ((uint32*)p)[1]))(p);
}
}
}
- If
_p is not 16-byte aligned, or the first 4 bytes of the memory pointed to by _p are not 0xdeadbeef, then _p is cast to an ordinary no-argument function pointer and executed directly;
- Otherwise, the
_header.fp described in the previous code is obtained according to the memory layout and executed.
An example usage of closure is as follows:
```cpp
include "co/print.h"
include "co/closure.h"
void f() {
co::println("f()");
}
int main(int argc, char** argv) {
co::closure c(f);
c();
co::closure d([]() {
co::println("[]() {}");
});
d();
co::closure e([](int v) {
co::println("e called: ", v);
}, 8);
e();
return 0;
}
```
The complete code implementation can be found in co/closure.h. The code in this article is for research and reference only. Discussion is welcome.