By popular demand, and at the request of my coworkers, this week’s class was about implementing a simple thread pool using only standard C++ components. It would have taken 1h-30m if I didn’t completely forget how to use a condition variable. It’s 2h long!
Bad
std::condition_variable.wait(...) predicate caused a deadlock and I spent 30 minutes trying to figure it out. If you’re interested in the step-by-step implementation only then fast forward from 1h-15m-15s to 1h-45m-0s. Otherwise watch me reason through it. I eventually realized that the predicate should have returned
true if the queue contained any elements, instead it tried to answer if 1 element was added to it.
For me the lesson was…
If you want to master something, teach it.
Richard Feynman
Code from the class:
lesson_thread_pool_how_to.cpp
Hey, thanks a lot for this blog post. I did not watch the video, but the code in lesson_thread_pool_how_to.cpp looks good. It is nice to see so many components from the STL working together 🙂
If you don’t mind, I would like to share a few suggestions:
1.) In line 20 I suggest to insert a m_threads.reserve(thread_count);. It’s almost always a speedup to reduce the number of dynamic memory allocations. And std::vector::reserve lets you allocate all the required dynamic memory at once.
2.) Why do you use work_item_ptr_t? I assume to be able to have a null-state? But std::function already supports that. So I don’t see a need to wrap the work_item_t in an other work_item_ptr_t.
3.) If you have access to C++20, I recommend to use std::jthread
cppreference: https://en.cppreference.com/w/cpp/thread/jthread
youtube: https://www.youtube.com/watch?v=ln5ERAVXEMY
The advantage of std::jthread over std::thread is, that it automatically joins in the destructor and that it comes with a built-in stopping-mechanism. So in the end you can default your thread_pool’s destructor when using jthread.
Best regards, Kilian
Kilian, thank you for your thought out comment. Let me respond to each point:
1) I totally forgot 🙂 so thank you for pointing this out.
2) Now I remember that std::function can in fact have an empty state that can be tested with operator ! I think. I think I went with a pointer because that’s what I did in the past in my previous implementations which actually required it due to the nature of tasks and features.
3) I don’t think I could default my destructor, it still needs to push the sentinels, but I could eliminate the .join() calls.
I do have some C++20 support, I will see if I have access to std::jthread; I’m using Xcode or llvm through brew package manager and I noticed the C++20 coverage so far has been spotty…
Thank You again for your quality contribution!
Sincerely, Martin Vorbrodt.
Sorry, I might have been a little unprecise on bullet point 3.). I would say it maybe depends on how you want your ThreadPool to behave upon destruction. In your thread_pool the destructor pushes the sentinels and then calls .join() which will wait till all work_items from m_queue got executed. See this godbolt-link: https://godbolt.org/z/6hfGsq871 . There is a SimpleThreadPool class which has a defaulted destructor. The stopping happens via the stopping-mechanism from std::jthread and std::stop_token. But this implementation will in its destructor discard remaining actions in the m_queue. Probably it depens on your use-case which behavior you want.
Your blog post inspired me to dig a little bit more into thread-pools. Thanks a lot for this inspiration 🙂
Best regards, Kilian
I think it is logically correct for the pool to finish all work it accepted. Perhaps once it enters destructor it should reject further work. Then again constructors and destructors should run in isolation… stop token looks interesting when combined with CV_any 🙂
I am also not a fan of methods like stop/finish on a thread pool: it stops it from accepting more work but also puts it in a useless state I think.
Happy to be an inspiration!