1

I'm trying to learn about coroutines, senders/receivers, async, structured concurrency, etc. within C++. I've watched cppcon talks, read online blogs, and read example code on GitHub, yet I'm still struggling to understand asynchronous computation. To simplify things, I am making this question about a single problem: opening a file asynchronously.

Why I think you should transfer files asynchronously

In my head the problem looks like this:

  1. There are system calls to move files between RAM and the disk. These system calls used to have two downsides: (1) they were blocking and (2) upon completion, they cause an interrupt from the DMA.
  2. Blocking calls and interrupts are bad because they result in a context switch, which is inefficient.
  3. Instead of blocking, async paradigms allow us to signal to the DMA that we want data to be moved. Moving file data to/from RAM does not require participation of the CPU, so while the DMA does its thing, our thread can continue its computation. When the DMA completes, it should (in some way) signal to our program that the file transfer is complete. Our program can then (somehow?) run a call-back function scheduled for when the file transfer completes.

The Question

Is this the kind of problem that the async paradigms exist to solve? If not, what kinds of problems are they meant to solve? If so, what is the correct way to asynchronously open a file using these new asynchronous paradigms?

My Attempt at an answer

I've been trying to answer my own question by looking at example code in CppCoro and libunifex. I can't see how the code solves the problem I have laid out. My expectation is that there should be (1) a system call for a non-blocking file transfer and (2) a way to receive a signal that the file transfer is complete so the call-back can be called. I do not see either of things. This leads me to believe that these asynchronous libraries do not exist to solve the problem I have laid out.

Mark Wallace
  • 528
  • 2
  • 12
  • This is not technically c++, but looking at the implementation of C#'s `ReadAsync` might help: https://referencesource.microsoft.com/#mscorlib/system/io/stream.cs,e224b4bec8748849,references – ph3rin Jul 25 '22 at 01:06
  • 1
    There is only one way to effectively learn C++. It is not "watching cppcon talks", or reading "online blogs" or "example code on GitHub". It is also not watching random Youtube videos, either. Any clown can upload a video to Youtube (even I can do that), upload crap to Github (I've got tons of it there), or post a rambling stream of consciousness on their blog (I haven't done that yet, but give me time). Cppcon talks are targeted to advanced, experienced, C++ developers who already know C++. The only way to effectively learn C++ is with a good textbook. Everything else is wasting time. – Sam Varshavchik Jul 25 '22 at 01:28
  • The only way to learn C++ is by compiling and running C++ – Tom Huntington Jul 25 '22 at 01:42
  • @SamVarshavchik Sure, can you recommend a good textbook on C++20 coroutines, sender/receiver, and executors? because I don't think any exist yet. These are fairly recent additions to the language and standard proposals + the things I named are the primary sources of information. – Mark Wallace Jul 25 '22 at 12:15
  • How about "C++20 The Complete Guide"? All one has to do is [consult Stackoverflow's curated list of C++ textbooks](https://stackoverflow.com/questions/388242/the-definitive-c-book-guide-and-list), and find it. – Sam Varshavchik Jul 25 '22 at 12:22
  • @SamVarshavchik it doesn't cover executors or senders/receivers. (and why would it, these aren't yet part of the standard). Furthermore, the coroutine section in that book doesn't discuss any kind of theory of asynchronous programming. The flavor of the coroutine section is "here are the language primitives and here is how you you build a generator." You wouldn't, for example, know how to write code to asynchronously open a file from that book. I know one writer on coroutines poorly reviewed the coroutine section in that book, but I am having trouble finding that review now. – Mark Wallace Jul 25 '22 at 13:02
  • the book dedicates 52 pages to "Date and Time Zones" and 19 pages to coroutines. Coroutines are an afterthought in that book. – Mark Wallace Jul 25 '22 at 13:14

4 Answers4

1

Is this the kind of problem that the async paradigms exist to solve?

I'd say the archetypical problem that async paradigms exists to solve is non-blocking UI.

I'd recommend looking into C++/WinRT WinUI/UWP.

In WinUI/UWP, you have a UI thread that shouldn't be blocked. And this is enforced by C++/WinRT raising an exception if their async type IAsyncOperation<T> (equivalent to unifex::task<T>) blocks on the UI thread.

IAsyncOperation<StorageFile> future_file = GetFileFromPathAsync(L"my_file.txt");
future_file.get(); // error on UI thread

All functions in WinRT working with the file system return an IAsyncOperation<T> because we don't want the UI to go unresponsive while the system calls block.

To use the IAsyncOperation<T>, C++/WinRT uses coroutines, which are syntax sugar that chop up the remainder of your function after co_await into a callback.

IAsyncOperation<StorageFile> future_file = GetFileFromPathAsync(L"my_file.txt");
StorageFile file = co_await future_file; 
// this is now a callback

The UI thread runs tasks from a Dispatcher Queue. When the future_file is ready, the remainder of the coroutine will be queued, and the UI thread will get around to processing it.

The actual work of opening the file (GetFileFromPathAsync) is done on the Windows thread pool. In fact, you can manually switch which thread your coroutine is running on by using winrt::resume_background and winrt::resume_forground:

void MainPage::ClickHandler(
    IInspectable const& sender,
    RoutedEventArgs const& /* args */)
{
    // start on UI thread
    co_await winrt::resume_background();
    // now on thread pool
    // do compute
    co_await winrt::resume_forground(sender.dispatcher());
    // Back on UI thread and can now access UI components again
    myButton().Content(box_value(L"Clicked"));
}
Remy Lebeau
  • 555,201
  • 31
  • 458
  • 770
Tom Huntington
  • 2,260
  • 10
  • 20
1

Tom talked about how to do it on Windows using Windows native libraries.

On Linux, you can use io_uring (the userspace library would be liburing).

But I would recommend to use asio which as of version 1.21 support asynchronous file I/O on both platforms ("This feature currently supports I/O completion ports on Windows, and io_uring on Linux"). According to the docs, newer versions of asio also have support for coroutines and standard executors.

tzcnt
  • 86
  • 6
0

My expectation is that there should be (1) a system call for a non-blocking file transfer and (2) a way to receive a signal that the file

I'd also say that you can already roll you own non-blocking async functionality easily with just standard C++11. This is similar structure to my directX12 render loop where I have to do compute on the gpu then on the cpu without droping frames.

// you could use std::future and std::promise instead of std::shared_ptr<std::optional>
// but we don't need that functionality here
std::queue<std::shared_ptr<std::optional<int>>> queue;
for(;;)
{
    auto future = std::make_shared<std::optional<int>>(std::nullopt);
    queue.push(future);
    std::thread([promise = future]() {
          //non-blocking work
          std::this_thread::sleep_for(std::chrono::seconds(1))
          *promise = 1;
      }).detach();
   while(queue.size() && queue.front()->has_value())
   {
        Render(queue.front()->value());
        queue.pop();
   }
   PresentFrame();
}

You are always going to have a thread polling if tasks in a queue have completed hidden somewhere.

What cppcoro and libunifex provide is async algorithms, coroutine support (which make writing async code easier), more performance with sender/receiver and thread pools (as creating a new thread with std::thread for every task is quite inefficient).

Tom Huntington
  • 2,260
  • 10
  • 20
  • 1
    "You are always going to have a thread polling if tasks in a queue have completed hidden somewhere." - I disagree with this statement. I think you should read https://blog.stephencleary.com/2013/11/there-is-no-thread.html . For any operation that involves delegating work to hardware, it is possible to have the hardware completion -> ISR -> OS thread -> app thread pool scheduler -> coroutine wakeup be completely push-driven. – tzcnt May 19 '23 at 15:01
  • Even if you are waiting on CPU-bound work, you can either have your completion handler be at the end of the task graph, or put your waiting thread to sleep on a condition variable, which on Windows would be similar to calling WaitForSingleObject - the OS wakes you up when the task is done. – tzcnt May 19 '23 at 15:12
  • @tzcnt I guess what I was thinking was that polling is being done by the os thread. I'll have a look at your link – Tom Huntington May 20 '23 at 00:45
0

I got a toy example going with libunifex based on my understanding of the WinUI/UWP model.

timed_single_thread_context ui_thread; processes tasks in a queue.

The UI handler once it has finished resubmits itself on the queue, after a delay.

std::function<void(void)> ui_handler = [&](){
    ...  
    submit(then(schedule_after(ui_thread.get_scheduler(), 20ms), ui_handler), ...);
};

To avoid blocking the UI we submit work to a thread pool static_thread_pool pool;

auto non_blocking_work = ... 
                              then(schedule(pool.get_scheduler()),
                                   [](){ std::printf("non-blocking work\n");
                                         std::this_thread::sleep_for(90ms); })), 
                         ...;

But updating the UI is not thread safe so we must get on the UI thread in order to update the UI based on the result of the non_blocking_work.

auto non_blocking_work = then(via(ui_thread.get_scheduler(), 
                                 ..., 
                             [&](){ UI_Element++; std::printf("safely accessing UI\n");});

The basic strategy is to eventually reduce everything to "fire and forget" work switching to a background thread to avoid blocking and then back to the main thread to use the result of the blocking work thread safely.

Tom Huntington
  • 2,260
  • 10
  • 20