Go-style CSP concurrency for plain C: fibers, channels, select, async I/O, and GC in one runtime π
libgoc gives C programs:
- β
stackful coroutines (
fibers) - β
Go-like channels +
goc_altsselect - β timeout channels + thread-pool scheduling
- β‘ async I/O and HTTP support
- β»οΈ GC-managed memory via Boehm GC
Use libgoc when you want Go-style concurrency without leaving C:
- π©βπ» C programmers building concurrent systems
- π language implementors targeting C/C++
- π runtime authors who need async I/O + channel-style coordination
Dependencies:
minicoro |
fiber suspend/resume (vendored MIT) |
libuv |
event loop, threads, timers, cross-thread wakeup |
| Boehm GC | garbage collection |
picohttpparser |
HTTP/1.1 request parser (vendored MIT); used by goc_http; disable with -DLIBGOC_SERVER=OFF |
| musl/TRE regex | POSIX ERE regex (vendored BSD-2-Clause); used by goc_schema |
yyjson |
JSON reader/writer (vendored MIT); used by goc_json |
π§ Pre-built static libraries
Available on the Releases page
- Linux (x86-64)
- macOS (arm64)
- Windows (x86-64)
π API reference
π Also see
Two fibers exchange a message back and forth over a pair of unbuffered channels. This is the canonical CSP "ping-pong" pattern β each fiber blocks on a take, then immediately puts to wake the other side.
#include "goc.h"
#include "goc_dict.h"
#include <stdio.h>
#define N_ROUNDS 5
static void player_fiber(void* arg) {
goc_dict* d = arg;
goc_chan* recv = goc_dict_get(d, "recv", NULL);
goc_chan* send = goc_dict_get(d, "send", NULL);
const char* name = goc_dict_get(d, "name", NULL);
goc_val_t* v;
while ((v = goc_take(recv))->ok == GOC_OK) {
int count = goc_unbox(int, v->val);
printf("%s %d\n", name, count);
if (count >= N_ROUNDS) {
goc_close(send);
return;
}
goc_put_boxed(int, send, count + 1);
}
}
static void main_fiber(void* _) {
goc_chan* a_to_b = goc_chan_make(0);
goc_chan* b_to_a = goc_chan_make(0);
goc_chan* done_ping = goc_go(player_fiber, goc_dict_of(
{"recv", b_to_a}, {"send", a_to_b}, {"name", "ping"}
));
goc_chan* done_pong = goc_go(player_fiber, goc_dict_of(
{"recv", a_to_b}, {"send", b_to_a}, {"name", "pong"}
));
/* Kick off the exchange with the first message. */
goc_put_boxed(int, a_to_b, 1);
/* Wait for both fibers to finish. */
goc_take(done_ping);
goc_take(done_pong);
}
int main(void) {
goc_init();
goc_go(main_fiber, NULL);
goc_shutdown();
return 0;
}What this example demonstrates:
goc_chan_make(0)β unbuffered channels enforce a synchronous rendezvous: eachgoc_putblocks until the other fiber callsgoc_take, and vice versa.goc_goβ spawns both player fibers on the current pool (or default pool when called outside fiber context) and returns a join channel that is closed automatically when the fiber returns.goc_closeβ when the round limit is reached the active fiber closes the forward channel, causing the partner's nextgoc_taketo returnGOC_CLOSEDand exit its loop cleanly.goc_dict_of(...)β constructs a GC-managed key-value dict used here to pass multiple named arguments to a fiber in a singlevoid*.goc_put_boxed(T, ch, val)/goc_unbox(T, ptr)β channels carryvoid*; boxing heap-allocates a scalar so it can be sent, unboxing dereferences it back to the original type on the receiving end.
A minimal HTTP example that demonstrates P11-style JSON request parsing and response serialisation. The client sends { "name": "Arjun" }, and the server responds with { "response": "Hi, Arjun!" }.
#include "goc.h"
#include "goc_dict.h"
#include "goc_http.h"
#include "goc_json.h"
#include "goc_schema.h"
#include <stdio.h>
static goc_schema* request_schema;
static goc_schema* response_schema;
/* HTTP request handler */
static void greet_handler(goc_http_ctx_t* ctx) {
goc_json_result req_r = goc_json_parse(goc_http_server_body_str(ctx));
goc_dict* req = req_r.res;
const char* name = goc_dict_get(req, "name", NULL);
goc_dict* resp = goc_dict_of(
{"response", goc_sprintf("Hi, %s!", name)}
);
goc_json_result out_r = goc_json_stringify(response_schema, resp);
goc_http_server_respond(ctx, 200, "application/json", out_r.res);
}
static void main_fiber(void* _) {
/* define schemas */
request_schema = goc_schema_dict_of(
{"name", goc_schema_str()}
);
response_schema = goc_schema_dict_of(
{"response", goc_schema_str()}
);
/* make server */
goc_http_server_opts_t* opts = goc_http_server_opts();
goc_http_server* srv = goc_http_server_make(opts);
goc_http_server_route(srv, "POST", "/greet", greet_handler);
goc_chan* ready = goc_http_server_listen(srv, "127.0.0.1", 8080);
goc_take(ready);
/* send request */
goc_dict* req_obj = goc_dict_of(
{"name", "Arjun"}
);
goc_json_result req_json_r = goc_json_stringify(request_schema, req_obj);
goc_chan* resp_ch = goc_http_post(
"http://127.0.0.1:8080/greet",
"application/json",
req_json_r.res,
goc_http_request_opts()
);
/* handle response */
goc_http_response_t* resp = goc_take(resp_ch)->val;
printf("server replied: %s\n", resp->body);
/* shutdown server */
goc_chan* close_ch = goc_http_server_close(srv);
goc_take(close_ch);
}
int main(void) {
goc_init();
goc_go(main_fiber, NULL);
goc_shutdown();
return 0;
}What this example demonstrates:
goc_json_parseβ parse an inbound JSON request body into agoc_dict*.goc_json_stringifyβ serialize a response object using a schema.goc_http_server_routeandgoc_http_postβ wire a request/response roundtrip over HTTP.goc_http_server_respond(..., "application/json", ...)β send a JSON response with the correct MIME type.
Used the right way, libgoc provides a runtime environment very similar to Go's.
The blocking versions of take/put/alts are intended only for the initial setup in the main function, and should not be used otherwise.
A typical program's main function should be like this:
static void main_fiber(void* _) {
/*
* User code comes here.
* Since this is a fiber context,
* async channel ops work here
* and in all code reachable from here.
*/
}
int main(void) {
goc_init();
/* reify main thread as main fiber */
goc_go(main_fiber, NULL);
goc_shutdown();
return 0;
}expand / collapse
Pre-built static libraries are available on the Releases page.
libgoc ships with a comprehensive, phased test suite covering the full public API. See the Testing section in the Design Doc for a breakdown of the test phases and what each one covers.
test.sh β Full build + test runner with optional watch mode:
./test.sh # build and run all tests
WATCH=1 ./test.sh # rebuild and rerun on any src/include/tests change
./test.sh -dbg 1 # enable verbose [GOC_DBG] outputOptions: -dbg <0|1>, -rp <0|1> (SO_REUSEPORT for HTTP tests), -vmem <0|1>. Output is streamed to console and test.log. In watch mode, only previously-failing tests are rerun on the next change.
run_test_loop.sh β Stress a single test for flakiness detection:
./run_test_loop.sh tests/test_p06_thread_pool.c # run up to 20 times
./run_test_loop.sh tests/test_p06_thread_pool.c -max-tries 100 -trace 1Builds only the named target, runs it in a loop, and exits on the first failure. Each run is timestamped; log path is printed on exit.
| Dependency | macOS | Linux (Debian/Ubuntu) | Linux (Fedora/RHEL) | Windows |
|---|---|---|---|---|
| CMake β₯ 3.20 | brew install cmake |
apt install cmake |
dnf install cmake |
MSYS2 UCRT64 (bundled) |
| libuv | brew install libuv |
apt install libuv1-dev |
dnf install libuv-devel |
MSYS2 UCRT64 β see Windows |
| Boehm GC | brew install bdw-gc |
source build (see below) | dnf install gc-devel |
MSYS2 UCRT64 β see Windows |
| pkg-config | brew install pkg-config |
apt install pkg-config |
dnf install pkgconfig |
MSYS2 UCRT64 (bundled) |
| minicoro | vendored (vendor/minicoro/); instantiated via src/minicoro.c |
A C11 compiler is required: GCC or Clang on Linux/macOS; MinGW-w64 GCC via MSYS2 UCRT64 on Windows.
libgoc is built to link statically against libuv and Boehm GC. Ensure static versions of those dependencies are available to pkg-config before configuring.
# 1. Install dependencies (Homebrew)
brew install cmake libuv bdw-gc pkg-config
# Homebrew's bdw-gc does not ship a bdw-gc-threaded.pc pkg-config alias.
# Create it once in the global Homebrew pkgconfig directory:
PKGDIR="$(brew --prefix)/lib/pkgconfig"
[ -f "$PKGDIR/bdw-gc-threaded.pc" ] || cp "$PKGDIR/bdw-gc.pc" "$PKGDIR/bdw-gc-threaded.pc"
# 2. Configure
export PKG_CONFIG_ALL_STATIC=1
cmake -B build -DLIBGOC_STATIC_DEPENDENCIES=ON
# 3. Build
cmake --build build
# 4. Run tests
ctest --test-dir build --output-on-failure
# Or run a single phase directly for full output
./build/test_p01_foundation# 1. Install dependencies (Debian/Ubuntu shown; see table above for RPM)
sudo apt update
sudo apt install cmake libuv1-dev libatomic-ops-dev pkg-config build-essential
# Ubuntu's libgc-dev is NOT compiled with --enable-threads, which libgoc requires.
# GC_allow_register_threads is required for libgoc's goc_thread_create/
# goc_thread_join wrappers; the system package can crash at runtime.
# Build Boehm GC from source instead:
wget https://github.com/ivmai/bdwgc/releases/download/v8.2.6/gc-8.2.6.tar.gz
tar xf gc-8.2.6.tar.gz && cd gc-8.2.6
./configure --enable-threads=posix --enable-thread-local-alloc --disable-shared --enable-static --prefix=/usr/local
make -j$(nproc) && sudo make install && sudo ldconfig && cd ..
# The source build does not always generate a bdw-gc-threaded.pc pkg-config alias.
# Create it manually if it is missing:
if [ ! -f /usr/local/lib/pkgconfig/bdw-gc-threaded.pc ]; then
sudo ln -s /usr/local/lib/pkgconfig/bdw-gc.pc /usr/local/lib/pkgconfig/bdw-gc-threaded.pc
fi
# Ensure pkg-config searches /usr/local (not on the default path on all distros):
export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:$PKG_CONFIG_PATH
# To make this permanent:
# echo 'export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:$PKG_CONFIG_PATH' >> ~/.bashrc
# 2. Configure
export PKG_CONFIG_ALL_STATIC=1
cmake -B build -DLIBGOC_STATIC_DEPENDENCIES=ON
# 3. Build
cmake --build build
# 4. Run tests
ctest --test-dir build --output-on-failure
# Or run a single phase directly
./build/test_p01_foundationlibgoc uses libuv thread primitives (uv_thread_t, etc.) and C11 atomics via <stdatomic.h> (_Atomic, atomic_*). MSVC builds are still not supported (notably due to bdwgc/toolchain constraints, including vcpkg's Win32-threads build), so the recommended Windows setup remains MSYS2/MinGW-w64 (UCRT64).
# 1. Install MSYS2 from https://www.msys2.org/, then in a UCRT64 shell:
pacman -S mingw-w64-ucrt-x86_64-gcc \
mingw-w64-ucrt-x86_64-cmake \
mingw-w64-ucrt-x86_64-libuv \
mingw-w64-ucrt-x86_64-gc \
mingw-w64-ucrt-x86_64-pkg-config
# 2. Create the bdw-gc-threaded pkg-config alias if it is missing
PKGDIR="/ucrt64/lib/pkgconfig"
[ -f "$PKGDIR/bdw-gc-threaded.pc" ] || cp "$PKGDIR/bdw-gc.pc" "$PKGDIR/bdw-gc-threaded.pc"
# 3. Configure and build everything (library + tests)
export PKG_CONFIG_ALL_STATIC=1
cmake -B build -DLIBGOC_STATIC_DEPENDENCIES=ON
cmake --build build --parallel $(nproc)
# 4. Run tests
ctest --test-dir build --output-on-failureTests: Phases P1βP7 and P9 run normally on Windows. Phase 8 (safety tests) requires
fork()/waitpid()to isolate processes that callabort()β these POSIX APIs are not available in MinGW. The P8 test binary builds successfully but all 11 tests reportskipat runtime.
# Debug (no optimisation, debug symbols)
cmake -B build -DCMAKE_BUILD_TYPE=Debug
# Release
cmake -B build -DCMAKE_BUILD_TYPE=Release
# RelWithDebInfo (default)
cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo# Default: canary-protected stacks (recommended, portable)
cmake -B build
# Enable virtual memory allocator (dynamic stack growth)
cmake -B build -DLIBGOC_VMEM=ONThe default fiber stack size can be set at build time:
cmake -B build -DLIBGOC_STACK_SIZE=131072 # 128 KBlibgoc is installed as a static archive plus headers. The install step writes a libgoc.pc pkg-config file to <prefix>/lib/pkgconfig/, so downstream projects can locate and link libgoc without knowing its install prefix.
cmake -B build
cmake --build build
sudo cmake --install build # installs goc.h, goc_io.h, goc_array.h, libgoc.a, and libgoc.pc# Compile and link a consumer with pkg-config
cc $(pkg-config --cflags libgoc) my_app.c $(pkg-config --libs libgoc) -o my_appIn a CMake-based consumer, use pkg_check_modules in the same way as libgoc itself uses it for libuv:
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBGOC REQUIRED IMPORTED_TARGET libgoc)
target_link_libraries(my_target PRIVATE PkgConfig::LIBGOC)Code coverage instrumentation is opt-in via -DLIBGOC_COVERAGE=ON. It requires GCC or Clang and uses gcov-compatible .gcda/.gcno files. If lcov and genhtml are found, a coverage build target is also registered that runs the test suite and produces a self-contained HTML report.
Install lcov
| Platform | Command |
|---|---|
| macOS | brew install lcov |
| Debian/Ubuntu | apt install lcov |
| Fedora/RHEL | dnf install lcov |
Configure and build
# Coverage builds should use Debug to avoid optimisation hiding branches
cmake -B build-cov \
-DCMAKE_BUILD_TYPE=Debug \
-DLIBGOC_COVERAGE=ON
cmake --build build-covGenerate the HTML report
cmake --build build-cov --target coverage
# Report written to: build-cov/coverage_html/index.html
open build-cov/coverage_html/index.html # macOS
xdg-open build-cov/coverage_html/index.html # LinuxThe coverage target runs ctest internally, so there is no need to invoke the test binary separately. The final report includes branch coverage and filters out system headers and build-system generated files.
Note: Coverage and sanitizer builds are mutually exclusive β configure them in separate build directories. Coverage is also incompatible with
-DCMAKE_BUILD_TYPE=Releaseoptimisation levels that inline or eliminate branches.
AddressSanitizer and ThreadSanitizer builds are available as opt-in targets.
# AddressSanitizer
cmake -B build-asan -DLIBGOC_ASAN=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build-asan
ctest --test-dir build-asan --output-on-failure
# ThreadSanitizer
cmake -B build-tsan -DLIBGOC_TSAN=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build-tsan
ctest --test-dir build-tsan --output-on-failureNote: ASAN and TSAN are mutually exclusive β configure them in separate build directories.
Copyright (c) Divyansh Prakash | MIT License