Implementing Go-style Channels in C++ from scratch
Learn about C++'s powerful concurrency primitives by building an intuitive inter-thread messaging channel.
If you've ever worked with Go, you know how channels simplify concurrent programming. They provide a clean, safe way for goroutines to communicate and synchronize. One other such cool feature is the for-select loop, which lets you listen on multiple channels concurrently.
C++ provides some extremely powerful concurrency primitives, however, there’s nothing as intuitive and easy to use as Channels, but it doesn’t have to be that way.
I recently wrote a small library called CppChan, which allows you to use Go-like channels and for-select loops inside C++ (albeit with C++-like syntax).
We’ll go through the library’s implementation, which uses nothing but the language’s built-in concurrency primitives. By the end of this article, you will have a deep understanding of this library and a strong grasp of C++’s concurrency model.
The Channel API in Go
Go's concurrency model is built around goroutines and channels, providing a powerful yet straightforward way to manage concurrent operations. Goroutines are lightweight threads managed by the Go runtime, allowing you to execute functions concurrently without the overhead of traditional threads. Channels are Go's mechanism for inter-goroutine communication, enabling safe data exchange between goroutines.
You can create channels with the make function, and send/receive messages using the <- operator.
The select statement allows a goroutine to wait on multiple communication operations, proceeding with whichever is ready first. This is particularly useful for managing multiple concurrent tasks by enabling the program to react dynamically to various events.
Buffered and Unbuffered Channels
Channels can be either buffered or unbuffered. Unbuffered channels, created with make(chan Type), require both the sender and receiver to be ready at the same time for the communication to occur. This ensures a synchronous exchange, where the sender blocks until the receiver is ready to receive, and vice versa. This tight coupling is useful for ensuring that data is passed immediately and allows for precise synchronization between goroutines.
Buffered channels, on the other hand, are created with make(chan Type, capacity), where capacity specifies the number of elements the channel can hold before blocking. In buffered channels, the sender can send up to capacity elements without waiting for a receiver, making the communication asynchronous. Once the buffer is full, the sender blocks until a receiver reads an element from the channel. Buffered channels are useful for decoupling the sender and receiver, allowing the sender to proceed with other tasks without being immediately blocked by the receiver's readiness. They provide a way to handle bursty data flows and can help in balancing workloads between goroutines, making them a versatile tool in Go's concurrency model.
Our C++ API
CppChan provides an API for channel-based concurrency, similar to Go's channels and select statements. It offers a Channel class template and a Selector class.
The Channel<T> class template implements both buffered and unbuffered channels. It provides methods for sending and receiving data, including:
Blocking operations: (
send,receive)Non-blocking operations: (
try_send,try_receive)Asynchronous operations: (
async_send,async_receive)
Channels can be closed to signal that no more values will be sent, and their state can be queried using methods like is_closed(), is_empty(), and size(). This allows developers to implement various concurrency patterns and handle different scenarios efficiently.
The Selector class implements functionality similar to Go's select statement and allows simultaneous monitoring of multiple channels. It provides a select method that blocks until a message is available on any of the registered channels, and then processes it using a specified callback function. This enables efficient handling of multiple concurrent operations, similar to Go's for-select loop. The select loop can be terminated by calling the stop() method on the selector.
The Selector’s API exposes three main methods:
add_receive(): Adds a channel to be monitored, along with a callback function (which is executed whenever a message is received on the channel).select(): The core method that waits for and processes data from the channels.stop(): Signals the select operation to stop.
There’s also a notify() method meant for internal use by the channels to notify the selector about updates.
This approach allows for non-blocking multiplexed I/O operations across multiple channels, making it easy to manage complex concurrent systems in C++.
A Working Example
Before we dissect the library’s internals, it will be good to see it in action.
This example demonstrates the usage of our Channel and Selector structures to create a multi-producer, single-consumer scenario with different types of channels. Let's break it down:
We instantiate two buffered channels with distinct data types:
Channel<int>andChannel<std::string>.We create a
Selectorthat will be used by the consumer to listen on the two Channels.Two producer functions are defined:
int_producer: Produces integers and sends them to an int channel.string_producer: Produces strings and sends them to a string channel.
Both producers:
Run for a specified number of iterations.
Use
try_sendto attempt sending without blocking.Log successful and failed send attempts.
The
consumerfunction:Adds receive callbacks for both int and string channels to the selector. This is similar to listening on multiple channels in Go
Calls
selector.select()to continuously process incoming data until signaled to stop by the main thread.
In the
mainfunction:Two channels are created:
ch_intandch_str, both with a capacity of 5.One consumer thread and three producer threads (two for ints, one for strings) are started.
The main thread waits for all producer threads to finish.
The main thread calls the
stop()method on the selector to exit the select loop.The main thread waits for the consumer thread to finish.
Channel Internals
The most basic building blocks of our Channels are the send and receive methods. Let’s begin by understanding their behavior.
send
void send(const T& value);The send method is responsible for sending a value to the channel. It blocks if the channel is full (buffered) or if there’s no receiver (unbuffered).
Locking and Initial Check:
The function starts by acquiring a lock on the mutex using
std::unique_lock.It immediately checks if the channel is closed, throwing an exception if so.
Buffered vs Unbuffered Channels: The function behaves differently based on whether the channel is buffered (capacity > 0) or unbuffered (capacity == 0).
For unbuffered channels
It waits until there's a receiver or the channel is closed.
After waiting, check again if the channel was closed while waiting.
If not closed, decrease the waiting receivers count and push the value to the queue.
For buffered channels:
It waits until there's space in the buffer or the channel is closed.
After waiting, check again if the channel was closed while waiting.
If not closed, push the value to the queue.
Notification
Notify one waiting receiver using
cv_recv.notify_one().Notify all registered selectors (which may be listening on this channel)
The lock is automatically released when the function exits, thanks to
std::unique_lock.
Conditional variables (
std::condition_variable) are synchronization primitives used in conjunction with mutexes to block a thread until a certain condition is met or a notification is received from another thread. They're particularly useful for producer-consumer scenarios, which is exactly what we’re doing here.Here we’re using two condition variables:
cv_sendandcv_recv.The
waitfunction on a condition variable (e.g.,cv_send.wait(lock, ...)) does the following:
It atomically releases the lock (remember,
std::unique_lockallows this behavior) and puts the thread to sleep.When awakened, reacquires the lock and checks the condition.
If the condition is false, goes back to sleep. If true, continues execution.
The condition is provided as a lambda function. For example:
cv_send.wait(lock, [this] { return waitingReceivers > 0 || closed; });This waits until there are waiting receivers or the channel is closed.
After the wait, the code uses
cv_recv.notify_one()to wake up one waiting receiver thread.Using condition variables allows us to efficiently wait for specific conditions without busy-waiting, and to wake up other threads when those conditions are met.
receive
std::optional<T> receive();The receive method is responsible for receiving a value from the channel. Blocks if the channel is empty.
The function begins by acquiring a lock on the mutex using
std::unique_lock.The function behaves differently based on whether the channel is buffered (capacity > 0) or unbuffered (capacity == 0).
Unbuffered Channel Logic
Increment the count of waiting receivers.
Notify one potential sender that a receiver is ready.
Wait until there's a value in the queue or the channel is closed.
After waiting, decrease the count of waiting receivers.
Buffered Channel Logic
Simply wait until there's a value in the queue or the channel is closed.
If the queue is empty and the channel is closed, return
std::nullopt. This indicates that no more values will be available on this channel.If there's a value, retrieve it from the front of the queue.
Notify one waiting sender that space is now available in the queue.
Asynchronous and Non-Blocking Send
The async and non-blocking flavors of the send method are similar to the original implementation with only a few minor tweaks.
async_send
The
async_sendfunction is designed to send a value to the channel asynchronously, meaning it doesn't block the calling thread.It returns a
std::future<void>, which represents the eventual completion of the asynchronous operation.std::launch::asyncis a launch policy that specifically requestsstd::asyncto run the function on a new thread.The lambda supplied to
std::asyncsimply calls the regularsendmethod.
try_send
Largely similar to the regular send method, with the difference that it doesn’t wait for any conditions to become true. Returns true if it is successful and false otherwise.
Asynchronous and Non-Blocking Receive
Similar to how the async and non-blocking send methods are implemented, the different flavors of the receive method include only subtle variations from the original implementation.
Building the Selector
The Selector class allows monitoring multiple Channel objects simultaneously for incoming messages. It provides a non-blocking way to handle data from multiple channels.
Selector flow
Channel Registration:
When add_receive() is called on the Selector: a. It registers the Selector with the Channel using register_selector(). b. It adds a lambda function to the
channelsvector. This lambda:Checks if the channel is closed.
Tries to receive a value from the channel.
Calls the provided callback if a value is received.
Select Operation:
The
select()method enters a wait-loop until a stop is requested or all channels are processed.Inside the loop:
It waits on the condition variable until either:
A stop is requested, or
Any channel has data available (checked by calling the lambda functions in channels).
Once awoken, it processes all channels that have data available:
If a channel returns true (indicating it's done or closed), it's removed from the list.
If a channel returns false, it's kept in the list for future processing.
If all channels are closed and removed, the loop breaks.
Notification Mechanism:
Channels notify the Selector when new data is available, in the Channel's
send()method, after pushing a valuefor (auto selector : selectors) { selector->notify(); }This calls the Selector's
notify()method, which wakes up the waitingselect()method.
Stopping the Selector:
The
stop()method sets thestop_flag_and callsnotify().This causes the
select()method to wake up and check the stop condition.
Conclusion
In this issue, we went deep into how one can design and implement Golang-like channels in C++ along with a multiplexed for-select like structure. These channels simplify writing highly concurrent applications without having to worry about locks or race conditions.
Additionally, we explored modern C++’s powerful concurrency primitives like std::unique_lock and std::condition_variable which allow us to write clean and maintainable concurrent code.
The complete code for the library is available at github.com/JyotinderSingh/CppChan. Be sure to give the repository a star if you find it useful.






![class Selector { public: /** * @brief Adds a channel to the selector for receiving messages. * * @tparam T the type of the channel. * @param ch The channel to add. * @param callback The callback function to call when a message is received. * * Use Case: Add a channel to the selector and specify a callback function * to be called when a message is received. */ template <typename T> void add_receive(Channel<T>& ch, std::function<void(T)> callback); /** * @brief Continuously processes events on registered channels until * signaled to stop. * * This method runs a loop that: * 1. Waits for data to become available on any channel or for a stop * signal. * 2. Processes all available data once woken up. * 3. Removes channels that have been processed. * The loop continues until either all channels are processed or a stop is * requested. * * Usage example: * @code * std::thread selector_thread([&]() { * selector.select(); * }); * * // Do other work... * * selector.stop(); * @endcode */ void select(); /** * @brief Stops the select operation and unblocks the select() method. * * Use Case: Stop the selector and unblock the select() method. * Example: selector.stop(); */ void stop() { stop_flag_.test_and_set(std::memory_order_relaxed); notify(); } /** * @brief Notifies the selector that data may be available on the channels. * * Use Case: Notify the selector that data may be available on the channels. * Example: selector.notify(); * @note This function is meant for internal use and should not be called * directly (unless you know what you're doing). */ void notify() { cv.notify_all(); } private: /** * @brief Checks if a stop has been requested. * * @return true * @return false */ bool stop_requested() const { return stop_flag_.test(std::memory_order_relaxed); } }; class Selector { public: /** * @brief Adds a channel to the selector for receiving messages. * * @tparam T the type of the channel. * @param ch The channel to add. * @param callback The callback function to call when a message is received. * * Use Case: Add a channel to the selector and specify a callback function * to be called when a message is received. */ template <typename T> void add_receive(Channel<T>& ch, std::function<void(T)> callback); /** * @brief Continuously processes events on registered channels until * signaled to stop. * * This method runs a loop that: * 1. Waits for data to become available on any channel or for a stop * signal. * 2. Processes all available data once woken up. * 3. Removes channels that have been processed. * The loop continues until either all channels are processed or a stop is * requested. * * Usage example: * @code * std::thread selector_thread([&]() { * selector.select(); * }); * * // Do other work... * * selector.stop(); * @endcode */ void select(); /** * @brief Stops the select operation and unblocks the select() method. * * Use Case: Stop the selector and unblock the select() method. * Example: selector.stop(); */ void stop() { stop_flag_.test_and_set(std::memory_order_relaxed); notify(); } /** * @brief Notifies the selector that data may be available on the channels. * * Use Case: Notify the selector that data may be available on the channels. * Example: selector.notify(); * @note This function is meant for internal use and should not be called * directly (unless you know what you're doing). */ void notify() { cv.notify_all(); } private: /** * @brief Checks if a stop has been requested. * * @return true * @return false */ bool stop_requested() const { return stop_flag_.test(std::memory_order_relaxed); } };](https://substackcdn.com/image/fetch/$s_!qXgw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F700b3b57-f1a6-47c5-87a5-8d5a1cc160da_3035x6396.png)
 { log("Received int: " + std::to_string(value)); }); selector.add_receive<std::string>(ch_str, [](const std::string& value) { log("Received string: " + value); }); selector.select(); log("Consumer stopped"); } int main() { Channel<int> ch_int(5); Channel<std::string> ch_str(5); Selector selector; std::thread cons( [&ch_int, &ch_str, &selector] { consumer(ch_int, ch_str, selector); }); std::thread prod1([&ch_int] { int_producer(ch_int, 1, 20); }); std::thread prod2([&ch_int] { int_producer(ch_int, 2, 20); }); std::thread prod3([&ch_str] { string_producer(ch_str, 3, 20); }); prod1.join(); prod2.join(); prod3.join(); // Stop the selector selector.stop(); // Wait for consumer to finish cons.join(); return 0; } #include <atomic> #include <chrono> #include <iostream> #include <random> #include <thread> #include "channel.h" void int_producer(Channel<int>& ch, int id, int count) { for (int i = 0; i < count; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(rand() % 500)); int value = id * 1000 + i; if (ch.try_send(value)) { log("Int Producer " + std::to_string(id) + " sent: " + std::to_string(value)); } else { log("Int Producer " + std::to_string(id) + " failed to send: " + std::to_string(value)); } } } void string_producer(Channel<std::string>& ch, int id, int count) { for (int i = 0; i < count; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(rand() % 500)); std::string value = "Message " + std::to_string(id) + "-" + std::to_string(i); if (ch.try_send(value)) { log("String Producer " + std::to_string(id) + " sent: " + value); } else { log("String Producer " + std::to_string(id) + " failed to send: " + value); } } } void consumer(Channel<int>& ch_int, Channel<std::string>& ch_str, Selector& selector) { selector.add_receive<int>(ch_int, [](int value) { log("Received int: " + std::to_string(value)); }); selector.add_receive<std::string>(ch_str, [](const std::string& value) { log("Received string: " + value); }); selector.select(); log("Consumer stopped"); } int main() { Channel<int> ch_int(5); Channel<std::string> ch_str(5); Selector selector; std::thread cons( [&ch_int, &ch_str, &selector] { consumer(ch_int, ch_str, selector); }); std::thread prod1([&ch_int] { int_producer(ch_int, 1, 20); }); std::thread prod2([&ch_int] { int_producer(ch_int, 2, 20); }); std::thread prod3([&ch_str] { string_producer(ch_str, 3, 20); }); prod1.join(); prod2.join(); prod3.join(); // Stop the selector selector.stop(); // Wait for consumer to finish cons.join(); return 0; }](https://substackcdn.com/image/fetch/$s_!ZCzL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6229f1f-435d-4306-babe-077442f02ceb_2999x6648.png)
![template <typename T> void Channel<T>::send(const T& value) { std::unique_lock<std::mutex> lock(mtx); if (closed) { throw std::runtime_error("Send on closed channel"); } if (capacity == 0) { // For unbuffered channels, wait until there's a receiver or the channel // is closed cv_send.wait(lock, [this] { return waitingReceivers > 0 || closed; }); if (closed) { throw std::runtime_error("Channel closed while waiting to send"); } --waitingReceivers; queue.push(value); cv_recv.notify_one(); // Notify a waiting receiver // Notify all registered selectors for (auto selector : selectors) { selector->notify(); } } else { // For buffered channels, wait until there's space in the buffer or the // channel is closed cv_send.wait(lock, [this] { return queue.size() < capacity || closed; }); if (closed) { throw std::runtime_error("Channel closed while waiting to send"); } queue.push(value); cv_recv.notify_one(); // Notify a waiting receiver // Notify all registered selectors for (auto selector : selectors) { selector->notify(); } } } template <typename T> void Channel<T>::send(const T& value) { std::unique_lock<std::mutex> lock(mtx); if (closed) { throw std::runtime_error("Send on closed channel"); } if (capacity == 0) { // For unbuffered channels, wait until there's a receiver or the channel // is closed cv_send.wait(lock, [this] { return waitingReceivers > 0 || closed; }); if (closed) { throw std::runtime_error("Channel closed while waiting to send"); } --waitingReceivers; queue.push(value); cv_recv.notify_one(); // Notify a waiting receiver // Notify all registered selectors for (auto selector : selectors) { selector->notify(); } } else { // For buffered channels, wait until there's space in the buffer or the // channel is closed cv_send.wait(lock, [this] { return queue.size() < capacity || closed; }); if (closed) { throw std::runtime_error("Channel closed while waiting to send"); } queue.push(value); cv_recv.notify_one(); // Notify a waiting receiver // Notify all registered selectors for (auto selector : selectors) { selector->notify(); } } }](https://substackcdn.com/image/fetch/$s_!7Kq7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce09c473-5279-4fde-b961-f18bb14bcdba_3035x3372.png)
![template <typename T> std::optional<T> Channel<T>::receive() { std::unique_lock<std::mutex> lock(mtx); if (capacity == 0) { // For unbuffered channels, notify a sender and wait for a value ++waitingReceivers; cv_send.notify_one(); cv_recv.wait(lock, [this] { return !queue.empty() || closed; }); --waitingReceivers; } else { // For buffered channels, wait until there's a value or the channel is // closed cv_recv.wait(lock, [this] { return !queue.empty() || closed; }); } if (queue.empty() && closed) { return std::nullopt; // Return empty optional if channel is closed and // empty } T value = queue.front(); queue.pop(); cv_send.notify_one(); // Notify a waiting sender return value; } template <typename T> std::optional<T> Channel<T>::receive() { std::unique_lock<std::mutex> lock(mtx); if (capacity == 0) { // For unbuffered channels, notify a sender and wait for a value ++waitingReceivers; cv_send.notify_one(); cv_recv.wait(lock, [this] { return !queue.empty() || closed; }); --waitingReceivers; } else { // For buffered channels, wait until there's a value or the channel is // closed cv_recv.wait(lock, [this] { return !queue.empty() || closed; }); } if (queue.empty() && closed) { return std::nullopt; // Return empty optional if channel is closed and // empty } T value = queue.front(); queue.pop(); cv_send.notify_one(); // Notify a waiting sender return value; }](https://substackcdn.com/image/fetch/$s_!blLA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd7fbd74b-fab9-4e61-a433-cb39d217eff6_2999x2280.png)
![template <typename T> std::future<void> Channel<T>::async_send(const T& value) { // Launch an asynchronous task to send the value return std::async(std::launch::async, [this, value] { this->send(value); }); } template <typename T> bool Channel<T>::try_send(const T& value) { std::unique_lock<std::mutex> lock(mtx); // If the channel is closed or the buffer is full, return false if (closed || (capacity != 0 && queue.size() >= capacity)) { return false; } queue.push(value); cv_recv.notify_one(); // Notify a waiting receiver // Notify all registered selectors for (auto selector : selectors) { selector->notify(); } return true; } template <typename T> std::future<void> Channel<T>::async_send(const T& value) { // Launch an asynchronous task to send the value return std::async(std::launch::async, [this, value] { this->send(value); }); } template <typename T> bool Channel<T>::try_send(const T& value) { std::unique_lock<std::mutex> lock(mtx); // If the channel is closed or the buffer is full, return false if (closed || (capacity != 0 && queue.size() >= capacity)) { return false; } queue.push(value); cv_recv.notify_one(); // Notify a waiting receiver // Notify all registered selectors for (auto selector : selectors) { selector->notify(); } return true; }](https://substackcdn.com/image/fetch/$s_!UjsV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5200db2-5e80-4018-b9ef-c97bc930cb1c_3035x2112.png)
![template <typename T> std::future<std::optional<T>> Channel<T>::async_receive() { // Launch an asynchronous task to receive a value return std::async(std::launch::async, [this] { return this->receive(); }); } template <typename T> std::optional<T> Channel<T>::try_receive() { std::unique_lock<std::mutex> lock(mtx); if (queue.empty()) { return std::nullopt; // Return empty optional if queue is empty } T value = queue.front(); queue.pop(); cv_send.notify_one(); // Notify a waiting sender return value; } template <typename T> std::future<std::optional<T>> Channel<T>::async_receive() { // Launch an asynchronous task to receive a value return std::async(std::launch::async, [this] { return this->receive(); }); } template <typename T> std::optional<T> Channel<T>::try_receive() { std::unique_lock<std::mutex> lock(mtx); if (queue.empty()) { return std::nullopt; // Return empty optional if queue is empty } T value = queue.front(); queue.pop(); cv_send.notify_one(); // Notify a waiting sender return value; }](https://substackcdn.com/image/fetch/$s_!wApV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F312c79b1-220e-4d88-9fb2-d1bdb870c1b5_2964x1776.png)
![template <typename T> void Selector::add_receive(Channel<T>& ch, std::function<void(T)> callback) { std::unique_lock<std::mutex> lock(mtx); ch.register_selector(this); // Add a lambda function to the channels list channels.push_back([&ch, callback = std::move(callback), this]() mutable { if (ch.is_closed()) { ch.unregister_selector(this); return true; // Signal that this channel is done } auto value = ch.try_receive(); if (value) { callback(*value); // Call the callback with the received value return false; } return false; }); } template <typename T> void Selector::add_receive(Channel<T>& ch, std::function<void(T)> callback) { std::unique_lock<std::mutex> lock(mtx); ch.register_selector(this); // Add a lambda function to the channels list channels.push_back([&ch, callback = std::move(callback), this]() mutable { if (ch.is_closed()) { ch.unregister_selector(this); return true; // Signal that this channel is done } auto value = ch.try_receive(); if (value) { callback(*value); // Call the callback with the received value return false; } return false; }); }](https://substackcdn.com/image/fetch/$s_!jU_j!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9bfbb495-ec80-4571-a432-8127664fea44_2963x1860.png)
![void Selector::select() { std::unique_lock<std::mutex> lock(mtx); while (!stop_requested()) { // Wait until a channel has data available or a stop is requested. cv.wait(lock, [this] { return stop_requested() || std::any_of(channels.begin(), channels.end(), [](const auto& ch) { return ch(); }); }); // Process all channels that have data available for (auto ch_it = channels.begin(); ch_it != channels.end();) { if ((*ch_it)()) { // Data processed, remove this channel from the list ch_it = channels.erase(ch_it); } else { ++ch_it; } } if (channels.empty()) { break; } } } void Selector::select() { std::unique_lock<std::mutex> lock(mtx); while (!stop_requested()) { // Wait until a channel has data available or a stop is requested. cv.wait(lock, [this] { return stop_requested() || std::any_of(channels.begin(), channels.end(), [](const auto& ch) { return ch(); }); }); // Process all channels that have data available for (auto ch_it = channels.begin(); ch_it != channels.end();) { if ((*ch_it)()) { // Data processed, remove this channel from the list ch_it = channels.erase(ch_it); } else { ++ch_it; } } if (channels.empty()) { break; } } }](https://substackcdn.com/image/fetch/$s_!4uJy!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ecd1ebe-02b3-4c58-ae68-c31c7edd22a8_2820x2532.png)