Open-source project
andreiavrammsd/cpp-channel avatar
andreiavrammsd/cpp-channel

cpp-channel: a header-only synchronized queue for C++11 and later

C++11 thread-safe container for sharing data between threads (synchronized queue)

595 stars39 forksC++MIT

At a glance

What is it?
msd::channel is a header-only C++11 channel that hands elements between threads with a mutex, in buffered or unbuffered form, with pluggable storage for queue, vector or fixed array, and a close operation for graceful shutdown. It suits a producer and consumer pipeline, and its type requirements rule out element types that are not default constructible.
Who is it for?
cpp-channel is worth considering for a C++ project that needs a producer and consumer pipeline and wants a small, dependency-free container rather than a concurrency framework, because the header-only form drops into an existing build with no package manager, because the storage policy is a template parameter rather than a runtime choice, and because the close operation makes the shutdown path explicit instead of relying on a sentinel value.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly C++, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A channel, not a queue, with two blocking policies

msd::channel is described as a synchronized queue that can be easily and safely shared between multiple threads, and it is header-only and compatible with C++11 and newer. It is tested with GCC, Clang and MSVC, and it synchronizes with std::mutex.

The distinguishing detail is that the same type covers two policies rather than one. In the buffered form, the channel accepts a specified number of elements, after which it blocks the writer threads and waits for a reader thread to read an element, and it blocks the reader threads when the channel is empty until a writer writes. That is a bounded queue with backpressure on both sides.

In the unbuffered form, writes never block, and only readers block when the channel is empty. That is the more surprising of the two policies, because a writer can complete before a reader ever arrives.

The two are selected by the constructor argument:

cpp
    msd::channel<int> chan{2};

is a buffered channel with capacity two, while the unbuffered form is written with an empty brace list, msd::channel<int> chan{}, because there is no capacity to fill.

So the type does not encode the policy in its signature; the construction does. That is a deliberate choice, and it means the same channel type works in both roles without a separate class or a template parameter for it.

Storage is a template parameter, so the allocation policy is a compile-time choice

The second extension point is the storage. A channel can be heap- or stack-allocated by passing a custom storage or choosing one of the built-ins, and the built-ins live in include/msd/storage.hpp.

There are three. msd::queue_storage is the default and uses std::queue. msd::vector_storage uses std::vector, and the documentation notes it is the one to pick if cache locality is important, which is the case for a producer and consumer pair that touches the same memory repeatedly. And msd::array_storage is always buffered, uses std::array, and is the one to pick if you want stack allocation, with the capacity as a template argument:

cpp
    msd::static_channel<int, 2> src{};
    msd::static_channel<int, 2> dst{};

The alias static_channel exists for that case, and the documentation gives a warning about it that is worth reading before you copy the snippet. msd::channel<int, msd::array_storage<int, 10>> chan{10} does not compile because the capacity is already passed as the template argument.

That is a sharp edge, and it is the kind of thing the alias exists to avoid. There is a separate header for it, msd/static_channel.hpp, which is where the static_channel example in the documentation includes from.

The contract for a custom storage is stated in three lines, and it is a small interface: a class with a specific interface for storing elements, which must implement FIFO logic. So a storage is anything that keeps insertion order, and the channel handles the locking around it.

The reason this matters is the separation it creates. The queue, the vector and the array differ in memory behaviour, not in semantics, and the channel's blocking and closing logic is identical across all three. You choose an allocator strategy at the type level and keep the same code.

Closing the channel, and the difference between throwing and returning a status

The channel can be closed, which prevents pushing and stops waiting to fetch, and that is what makes a graceful shutdown possible instead of a sentinel value.

The exception behaviour is documented precisely and it differs between the two ways of writing. The stream insertion operator throws msd::closed_channel if the channel is closed, while msd::channel::write returns a bool status instead of throwing. So you choose per call site whether a closed channel is an exceptional condition or an expected one.

The storage decides the other half. Heap-allocated storages could throw, the documentation says, while static-allocated storage does not throw. And in both cases the channel throws if the stored elements themselves throw, which is worth noting because it means the exception safety of your pipeline depends on your element type.

The close operation is what makes the shutdown path work. The graceful_shutdown.cpp example in the repository is named for exactly this pattern, and the streaming.cpp example is the other obvious one, where a producer keeps writing and a consumer reads until the channel is closed and drained.

The static_channel example shows the whole sequence in a few lines: write two values, close the source, then run std::copy_if over the source range into a destination channel with a back_inserter, filtering on value % 2 == 0, and the destination size ends up at one.

That example also demonstrates the range-based iteration support, where the loop body checks chan.closed() and can break before the channel is empty, and where the fetch blocks until there is data or the channel is closed and empty.

Installing it: copy the include directory, or use a package manager

There is no package manager entry point, because the project is a single header directory. The installation section lists four routes.

The first is the simplest: copy the include directory into your project and add it to your include path. The second is CMake FetchContent, and the third is a CMake install from a chosen release version, with the commands given in the documentation:

shell
VERSION=X.Y.Z \
    && wget https://github.com/andreiavrammsd/cpp-channel/archive/refs/tags/v$VERSION.zip \
    && unzip v$VERSION.zip \
    && cd cpp-channel-$VERSION \
    && mkdir build && cd build \
    && cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local \
    && sudo cmake --install .

The fourth is Bazel, and the repository carries a bazel-project example alongside a cmake-project one, so both build systems have a worked example you can read rather than guess at.

The build system itself is CMake with a Makefile on top for the common tasks, and the Makefile is where the developer workflow lives. The bench target configures a Release build with benchmarks enabled, builds the channel_benchmark target, and runs it with ten repetitions and aggregate-only reporting, with a comment noting that on Linux you may need to adjust the CPU frequency governor first if you want stable numbers. The coverage target builds with tests and coverage enabled, runs ctest with the channel_tests label, then uses lcov to capture, strip system and gtest paths, generate an HTML report and serve it with python3 -m http.server on port 8000. The doc target runs doxygen and serves the output the same way.

The repository also carries benchmarks/ and tests/ at the top level, plus a Doxyfile for the documentation, a .clang-format, a .clang-tidy and a .cmake-format.json, and a .devcontainer/ directory.

The type requirements are the real constraint, and they bite in unusual places

The feature list has one requirement that is easy to skip and expensive to discover later: the value type must be default constructible, move constructible, move assignable and destructible.

That is four conditions on your element type, and default construction is the one that surprises people. A channel of std::unique_ptr is fine on the move requirements, but a channel of a type with no default constructor is not, and neither is a type whose default constructor is expensive or has side effects. If your payload is a non-default-constructible handle, this container is not usable and you need to wrap it.

The documentation does not say whether the default constructor is actually invoked on the hot path, and the requirement reads as a property of the type rather than a description of when it runs. The buffered storage reserving capacity eagerly is the likely reason, since a fixed-capacity array has to have its elements constructed before they are filled, and a queue that grows on demand still benefits from having a value ready to assign into. The honest conclusion from the documentation alone is that you should not assume default construction is free or rare.

The other feature worth calling out is the STL algorithm integration, and it comes with a known caveat. The documentation lists std::move(ch.begin(), ch.end(), ...), std::transform(input_chan.begin(), input_chan.end(), msd::back_inserter(output_chan)), and std::copy_if(chan.begin(), chan.end(), ...) as examples of working with the channel's iterators.

The known limitations section then says that in some cases the integration with some STL algorithms does not compile with MSVC, and points at the Transform test in the repository as the reference case. So the iterator interface is genuinely useful, and one compiler in the supported list has a corner where it does not build.

Where cpp-channel is the wrong tool

The first limit is the missing feature that a channel is usually assumed to have: there is no timed wait in the documented API. Everything here blocks forever. A reader that has used a language with a select or a timeout on its queue will find that a slow producer permanently parks the consumer, and the fix is the close operation rather than a deadline. For a pipeline where one stage may stall, that is a design choice you have to make deliberately, and this container only offers the forever variant.

The second is the element type requirement, which comes back here because it is the limit that disqualifies the library for some payloads outright rather than making it awkward. A non-default-constructible element type means you write a wrapper, and a wrapper around a handle adds a level of indirection to every push and pop.

The third is that this is one primitive, not a concurrency framework. There is no executor, no task pool, no future or promise, no atomic shared state helper. The channel is the synchronisation point and nothing else. If what you are building is a thread pool with work stealing, a fork and join structure, or an actor system, this library gives you one piece of that and you assemble the rest yourself, which is a reasonable trade but a real one.

The fourth is the MSVC corner on STL algorithm integration, on a library whose whole value proposition is being portable across three compilers. It is a known limitation rather than an unknown, and the Transform test is where you would look, but it means a codebase that leans on the iterator interface may not build on MSVC without workarounds.

The fifth is the release rhythm. The tags are v1.3.0 from 2025-06-13, v1.3.1 from 2025-06-17 and v1.3.2 from 2026-10-19, so more than a year passed between the first two and the third, and then the third landed on the same day as the last push to the repository. The project is not archived and the last push was recent, but a header-only library that other code depends on and that ships occasionally is a different risk profile from one with a steady stream of small fixes.

Editorial conclusion

cpp-channel is worth considering for a C++ project that needs a producer and consumer pipeline and wants a small, dependency-free container rather than a concurrency framework, because the header-only form drops into an existing build with no package manager, because the storage policy is a template parameter rather than a runtime choice, and because the close operation makes the shutdown path explicit instead of relying on a sentinel value. It is the wrong choice if your element type is not default constructible, and the wrong choice if you need timed waits. Before you adopt it, check the three things the project states: that the value type must be default constructible, move constructible, move assignable and destructible; that the documentation does not say when a default-constructed value is actually used, so the requirement should be assumed to hold; and that the MSVC limitation on STL algorithm integration is known, since some of the iterator-based algorithms do not compile there and the Transform test is the reference case.

Frequently asked questions

How do I install cpp-channel in a C++ project?

It is header-only, so the simplest route is to copy the include directory into your project and add it to your include path. The alternatives are CMake FetchContent, a CMake install from a chosen release version, and Bazel, and the repository carries a cmake-project example and a bazel-project example for the last two.

What is the difference between a buffered and an unbuffered msd::channel?

A buffered channel accepts a specified number of elements and then blocks the writer threads until a reader consumes one, and it blocks readers when empty. An unbuffered channel never blocks writes, and only readers block when the channel is empty. The choice is made at construction: msd::channel<int> chan{2} is buffered, msd::channel<int> chan{} is unbuffered.

What are the storage options for cpp-channel?

There are three built-in storages in include/msd/storage.hpp: msd::queue_storage using std::queue as the default, msd::vector_storage using std::vector for better cache locality, and msd::array_storage using std::array for stack allocation, which is always buffered and has its capacity as a template argument. A custom storage is any class with the required interface that implements FIFO logic.

Official sources

  1. andreiavrammsd/cpp-channel on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/andreiavrammsd-cpp-channel.svg)](https://hysenlabs.com/projects/andreiavrammsd-cpp-channel)

Was this useful?