TooManyCooks icon indicating copy to clipboard operation
TooManyCooks copied to clipboard

`channel::try_pull()`

Open tzcnt opened this issue 8 months ago • 0 comments

Currently data can only be read from a channel using std::optional<T> data = co_await chan.pull();. It would be useful to have a try_pull function which would simply return immediately rather than block when data is not available.

Possible APIs:

  • the function could result in 3 possibilities: success, failure because queue was empty, or failure because queue was closed. Thus, a boolean or optional-returning API is insufficient. Returning a chan_error enum could solve this issue.
  • Returning a std::expected would be nice for this; however that requires C++23. Should I increase the required compiler level, or use another implementation? Both https://github.com/TartanLlama/expected and https://github.com/martinmoene/expected-lite are available as single-header with compatible licenses.
  • Another option would be to return a std::variant.
  • Or a std::tuple which always contains both the value and an error code. With tuple-destructuring syntax auto [data, err] = chan.try_pull(); this is quite similar to Go / asio error handling, and is (IMO) less painful than checking and handling the variant types.
  • A final option would be to return an error code, and require the user to pass an object of the result type by reference, which then has the queue data move-constructed into it. T data; auto err = chan.try_pull(data); This is an old-school pattern that has several downsides. 1. Requires 2 statements/lines to separately declare the object and pass it (this explicitly goes against the design philosophy of TMC, which avoids this pattern in every other case). 2. Requires the object be default-constructible. 3. May introduce additional overhead by calling the default constructor before calling the move-constructor.

Implementation notes:

  • pull() is implemented using a speculative fetch_add; it always gets the ticket first and then afterward finds the block/data element and determines if data is ready. If data is not ready, then it suspends itself and stores its handle into that element.
  • try_pull() could instead load the write and read indexes, and then try to perform a compare_exchange on the read index only if there appears to be data available (the read index is behind the write index). If this CAS succeeds, then it's guaranteed to be able to read the data. However, retries could be expensive in the case on high contention.
  • I think try_pull() could also use the speculative fetch_add with careful handling:
    • If data is ready, no problem, just return the data.
    • If data is not ready, rather than suspending, mark the element as "done"; this operation needs to be a CAS.
      • If the CAS succeeds, then return false / empty.
      • If the CAS fails, then the data is now ready, so return the data.
    • However, this requires writers to handle a "done" element in their slot and retry to get a new write ticket. This handling may be a non-zero-cost operation... thus making try_pull faster at the cost of overall (write) performance.
  • Additional note: currently, writers load the value of flags and check for the presence of a waiting reader before attempting a CAS. Perhaps this could be optimized by simply attempting the CAS immediately. The impact of this needs to be benchmarked.

tzcnt avatar Apr 13 '25 22:04 tzcnt