Chapter 5 - The Philosophers' Dinner – Threads and Concurrency
Activity 1: Creating a Simulator to Model the Work of the Art Gallery
The Art Gallery work simulator is an application that simulates how the visitors and the watchman behave. There is a quantity limit for visitors, that is, only 50 people can be inside the gallery simultaneously. Visitors constantly come to the gallery. The watchman checks if the limit of visitors has been exceeded. If so, it asks new visitors to wait and puts them on a waiting list. If not, it allows them to enter the gallery. Visitors can leave the gallery at any time. If somebody leaves the gallery, the watchman lets somebody from the waiting list enter the gallery.
Follow these steps to implement this activity:
- Create a file that will contain all the constants that we need for this project – Common.hpp.
- Add the include guards and the first variable, CountPeopleInside, which represents that the limit for visitors is 50 people:
#ifndef COMMON_HPP
#define COMMON_HPP
constexpr size_t CountPeopleInside = 5;
#endif // COMMON_HPP
- Now, create a header and the source files for the Person class, that is, Person.hpp and Person.cpp. Also, add the include guards. Define the Person class and delete the copy constructor and copy assignment operator; we will only use the user-defined default constructor, move constructor, and move assignment operator and default destructor. Add a private variable called m_Id; we will use it for logging. Also, add a private static variable called m_NextId; it will be used for generating unique IDs:
#ifndef PERSON_HPP
#define PERSON_HPP
class Person
{
public:
Person();
Person& operator=(Person&);
Person(Person&&);
~Person() = default;
Person(const Person&) = delete;
Person& operator=(const Person&) = delete;
private:
int m_Id;
static int m_NextId;
};
#endif // PERSON_HPP
- In the source file, define our static variable, m_NextId. Then, in the constructor, initialize the m_Id variable with the value of m_NextId. Print the log in the constructor. Implement the move copy constructor and the move assignment operator. Now, implement thread-safe storage for our Person objects. Create the required header and source files, that is, Persons.hpp and Persons.cpp. Also, add the include guards. Include "Person.hpp" and the <mutex> and <vector> headers. Define the Persons class with a user-defined default constructor and default destructor. Declare the add() function for adding the Person and get() for getting the Person and removing it from the list. Define the size() function to get the count of Person elements, as well as removePerson(), which removes any person from the storage. In the private section, declare a variable of the mutex type, namely m_Mutex, and the vector to store Persons, namely m_Persons:
#ifndef PERSONS_HPP
#define PERSONS_HPP
#include "Person.hpp"
#include <mutex>
#include <vector>
class Persons
{
public:
Persons();
~Persons() = default;
void add(Person&& person);
Person get();
size_t size() const;
void removePerson();
private:
std::mutex m_Mutex;
std::vector<Person> m_Persons;
};
#endif // PERSONS_HPP
- In the source file, declare the user-defined constructor where we reserve the size of the vector to be 50 elements (to avoid resizing during growth):
Persons::Persons()
{
m_Persons.reserve(CountPeopleInside);
}
- Declare the add() function, which takes an rvalue parameter of the Person type, locks the mutex, and adds Person to the vector using the std::move() function:
void Persons::add(Person&& person)
{
std::lock_guard<std::mutex> m_lock(m_Mutex);
m_Persons.emplace_back(std::move(person));
}
- Declare the get() function, which locks the mutex and returns the last element and then removes it from the vector. If the vector is empty, it will throw an exception:
Person Persons::get()
{
std::lock_guard<std::mutex> m_lock(m_Mutex);
if (m_Persons.empty())
{
throw "Empty Persons storage";
}
Person result = std::move(m_Persons.back());
m_Persons.pop_back();
return result;
}
- Declare the size() function, which returns the size of the vector:
size_t Persons::size() const
{
return m_Persons.size();
}
- Finally, declare the removePerson() function, which locks the mutex and removes the last item from the vector:
void Persons::removePerson()
{
std::lock_guard<std::mutex> m_lock(m_Mutex);
m_Persons.pop_back();
std::cout << "Persons | removePerson | removed" << std::endl;
}
- Now, implement the PersonGenerator class, which is responsible for creating and removing Person items. Create the respective header and source files, that is, PersonGenerator.hpp and PersonGenerator.cpp. Also, add the include guards. Include the "Person.hpp", <thread>, and <condition_variable> headers. Define the PersonGenerator class. In the private section, define two std::thread variables, namely m_CreateThread and m_RemoveThread. In one thread, we will create new Person objects and will notify the user about removing Person objects in the other thread asynchronously. Define a reference to a shared variable of the Persons type, namely m_CreatedPersons. We will place every new person in it. m_CreatedPersons will be shared between several threads. Define two references to std::condition_variable, namely m_CondVarAddPerson and m_CondVarRemovePerson. They will be used for communication between threads. Define two references to the std::mutex variables, namely m_AddLock and m_RemoveLock. They will be used for receiving access to condition variables. Finally, define two references to a bool value, namely m_AddNotified and m_RemoveNotified. They will be used for checking whether the notification is true or false. Also, in the private section, define two functions that will be start functions for our threads – runCreating() and runRemoving(). Next, define two functions that will trigger condition variables, namely notifyCreated() and notifyRemoved(). In the public section, define a constructor that takes all the references that we defined in the private section as a parameter. Finally, define a destructor. This will ensure that the other default generated functions are deleted:
#ifndef PERSON_GENERATOR_HPP
#define PERSON_GENERATOR_HPP
#include "Persons.hpp"
#include <condition_variable>
#include <thread>
class PersonGenerator
{
public:
PersonGenerator(Persons& persons,
std::condition_variable& add_person,
std::condition_variable& remove_person,
std::mutex& add_lock,
std::mutex& remove_lock,
bool& addNotified,
bool& removeNotified);
~PersonGenerator();
PersonGenerator(const PersonGenerator&) = delete;
PersonGenerator(PersonGenerator&&) = delete;
PersonGenerator& operator=(const PersonGenerator&) = delete;
PersonGenerator& operator=(PersonGenerator&&) = delete;
private:
void runCreating();
void runRemoving();
void notifyCreated();
void notifyRemoved();
private:
std::thread m_CreateThread;
std::thread m_RemoveThread;
Persons& m_CreatedPersons;
// to notify about creating new person
std::condition_variable& m_CondVarAddPerson;
std::mutex& m_AddLock;
bool& m_AddNotified;
// to notify that person needs to be removed
std::condition_variable& m_CondVarRemovePerson;
std::mutex& m_RemoveLock;
bool& m_RemoveNotified;
};
#endif // PERSON_GENERATOR_HPP
- Now, move on to the source file. Include the <stdlib.h> file so that we can access the srand() and rand() functions, which are used for random number generation. Include the <time.h> header so that we can access the time() function, as well as the std::chrono namespace. They are used for when we work with time. Include the <ratio> file, which used for typedefs so that we can work with the time library:
#include "PersonGenerator.hpp"
#include <iostream>
#include <stdlib.h> /* srand, rand */
#include <time.h> /* time, chrono */
#include <ratio> /* std::milli */
- Declare the constructor and initialize all the parameters except the threads in the initializer list. Initialize the threads with the appropriate functions in the constructor body:
PersonGenerator::PersonGenerator(Persons& persons,
std::condition_variable& add_person,
std::condition_variable& remove_person,
std::mutex& add_lock,
std::mutex& remove_lock,
bool& addNotified,
bool& removeNotified)
: m_CreatedPersons(persons)
, m_CondVarAddPerson(add_person)
, m_AddLock(add_lock)
, m_AddNotified(addNotified)
, m_CondVarRemovePerson(remove_person)
, m_RemoveLock(remove_lock)
, m_RemoveNotified(removeNotified)
{
m_CreateThread = std::thread(&PersonGenerator::runCreating, this);
m_RemoveThread = std::thread(&PersonGenerator::runRemoving, this);
}
- Declare a destructor and check if the threads are joinable. Join them if not:
PersonGenerator::~PersonGenerator()
{
if (m_CreateThread.joinable())
{
m_CreateThread.join();
}
if (m_RemoveThread.joinable())
{
m_RemoveThread.join();
}
}
- Declare the runCreating() function, which is the start function for the m_CreateThread thread. In this function, in an infinite loop, we will generate a random number from 1 to 10 and make the current thread sleep for this time. After this, create a Person value, add it to the shared container, and notify other threads about it:
void PersonGenerator::runCreating()
{
using namespace std::chrono_literals;
srand (time(NULL));
while(true)
{
std::chrono::duration<int, std::milli> duration((rand() % 10 + 1)*1000);
std::this_thread::sleep_for(duration);
std::cout << "PersonGenerator | runCreating | new person:" << std::endl;
m_CreatedPersons.add(std::move(Person()));
notifyCreated();
}
}
- Declare the runRemoving() function, which is the start function for the m_RemoveThread thread. In this function, in an infinite loop, we will generate a random number from 20 to 30 and make the current thread sleep for this time. After this, notify the other threads that some of the visitors should be removed:
void PersonGenerator::runRemoving()
{
using namespace std::chrono_literals;
srand (time(NULL));
while(true)
{
std::chrono::duration<int, std::milli> duration((rand() % 10 + 20)*1000);
std::this_thread::sleep_for(duration);
std::cout << "PersonGenerator | runRemoving | somebody has left the gallery:" << std::endl;
notifyRemoved();
}
}
- Declare the notifyCreated() and notifyRemoved() functions. In their bodies, lock the appropriate mutex, set the appropriate bool variable to true, and call the notify_all() functions on the appropriate condition variables:
void PersonGenerator::notifyCreated()
{
std::unique_lock<std::mutex> lock(m_AddLock);
m_AddNotified = true;
m_CondVarAddPerson.notify_all();
}
void PersonGenerator::notifyRemoved()
{
std::unique_lock<std::mutex> lock(m_RemoveLock);
m_RemoveNotified = true;
m_CondVarRemovePerson.notify_all();
}
- Finally, we need to create the files for our last class, Watchman, namely Watchman.hpp and Watchman.cpp. As usual, add the include guards. Include the "Persons.hpp", <thread>, <mutex>, and <condition_variable> headers. Define the Watchman class. In the private section, define two std::thread variables, namely m_ThreadAdd and m_ThreadRemove. In one of the threads, we will move new Person objects to the appropriate queue and will remove Person objects in the other thread asynchronously. Define the references to the shared Persons variables, namely m_CreatedPeople, m_PeopleInside, and m_PeopleInQueue. We will take every new person from the m_CreatedPeople list and move them to the m_PeopleInside list if the limit is not exceeded. We will move them to the m_PeopleInQueue list otherwise. They will be shared between several threads. Define two references to std::condition_variable, namely m_CondVarAddPerson and m_CondVarRemovePerson. They will be used for communication between threads. Define two references to the std::mutex variables, namely m_AddMux and m_RemoveMux. They will be used for receiving access to condition variables. Finally, define two references to a bool value, namely m_AddNotified and m_RemoveNotified. They will be used for checking if the notification is true or false. Also, in the private section, define two functions that will be start functions for our threads – runAdd() and runRemove(). In the public section, define a constructor that takes all the references that we defined in the private section as parameters. Now, define a destructor. Make sure that all the other default generated functions are deleted:
#ifndef WATCHMAN_HPP
#define WATCHMAN_HPP
#include <mutex>
#include <thread>
#include <condition_variable>
#include "Persons.hpp"
class Watchman
{
public:
Watchman(std::condition_variable&,
std::condition_variable&,
std::mutex&,
std::mutex&,
bool&,
bool&,
Persons&,
Persons&,
Persons&);
~Watchman();
Watchman(const Watchman&) = delete;
Watchman(Watchman&&) = delete;
Watchman& operator=(const Watchman&) = delete;
Watchman& operator=(Watchman&&) = delete;
private:
void runAdd();
void runRemove();
private:
std::thread m_ThreadAdd;
std::thread m_ThreadRemove;
std::condition_variable& m_CondVarRemovePerson;
std::condition_variable& m_CondVarAddPerson;
std::mutex& m_AddMux;
std::mutex& m_RemoveMux;
bool& m_AddNotified;
bool& m_RemoveNotified;
Persons& m_PeopleInside;
Persons& m_PeopleInQueue;
Persons& m_CreatedPeople;
};
#endif // WATCHMAN_HPP
- Now, move on to the source file. Include the "Common.hpp" header so that we can access the m_CountPeopleInside variable and the other necessary headers:
#include "Watchman.hpp"
#include "Common.hpp"
#include <iostream>
- Declare the constructor and initialize all the parameters except for the threads in the initializer list. Initialize the threads with the appropriate functions in the constructor's body:
Watchman::Watchman(std::condition_variable& addPerson,
std::condition_variable& removePerson,
std::mutex& addMux,
std::mutex& removeMux,
bool& addNotified,
bool& removeNotified,
Persons& peopleInside,
Persons& peopleInQueue,
Persons& createdPeople)
: m_CondVarRemovePerson(removePerson)
, m_CondVarAddPerson(addPerson)
, m_AddMux(addMux)
, m_RemoveMux(removeMux)
, m_AddNotified(addNotified)
, m_RemoveNotified(removeNotified)
, m_PeopleInside(peopleInside)
, m_PeopleInQueue(peopleInQueue)
, m_CreatedPeople(createdPeople)
{
m_ThreadAdd = std::thread(&Watchman::runAdd, this);
m_ThreadRemove = std::thread(&Watchman::runRemove, this);
}
- Declare a destructor and check if the threads are joinable. Join them if not:
Watchman::~Watchman()
{
if (m_ThreadAdd.joinable())
{
m_ThreadAdd.join();
}
if (m_ThreadRemove.joinable())
{
m_ThreadRemove.join();
}
}
- Declare the runAdd() function. Here, we create an infinite loop. In the loop, we are waiting for a condition variable. When the condition variable notifies, we take people from the m_CreatedPeople list and move them to the appropriate list, that is, m_PeopleInside, or m_PeopleInQueue if the limit has been exceeded. Then, we check if there are any people in the m_PeopleInQueue list and if m_PeopleInside is not full, we move them into this list:
void Watchman::runAdd()
{
while (true)
{
std::unique_lock<std::mutex> locker(m_AddMux);
while(!m_AddNotified)
{
std::cerr << "Watchman | runAdd | false awakening" << std::endl;
m_CondVarAddPerson.wait(locker);
}
std::cout << "Watchman | runAdd | new person came" << std::endl;
m_AddNotified = false;
while (m_CreatedPeople.size() > 0)
{
try
{
auto person = m_CreatedPeople.get();
if (m_PeopleInside.size() < CountPeopleInside)
{
std::cout << "Watchman | runAdd | welcome in our The Art Gallery" << std::endl;
m_PeopleInside.add(std::move(person));
}
else
{
std::cout << "Watchman | runAdd | Sorry, we are full. Please wait" << std::endl;
m_PeopleInQueue.add(std::move(person));
}
}
catch(const std::string& e)
{
std::cout << e << std::endl;
}
}
std::cout << "Watchman | runAdd | check people in queue" << std::endl;
if (m_PeopleInQueue.size() > 0)
{
while (m_PeopleInside.size() < CountPeopleInside)
{
try
{
auto person = m_PeopleInQueue.get();
std::cout << "Watchman | runAdd | welcome in our The Art Gallery" << std::endl;
m_PeopleInside.add(std::move(person));
}
catch(const std::string& e)
{
std::cout << e << std::endl;
}
}
}
}
}
- Next, declare the runRemove() function, where we will remove visitors from m_PeopleInside. Here, also in the infinite loop, we are waiting for the m_CondVarRemovePerson condition variable. When it notifies the thread, we remove people from the list of visitors. Next, we will check if there's anybody in the m_PeopleInQueue list and if the limit is not exceeded, we add them to m_PeopleInside:
void Watchman::runRemove()
{
while (true)
{
std::unique_lock<std::mutex> locker(m_RemoveMux);
while(!m_RemoveNotified)
{
std::cerr << "Watchman | runRemove | false awakening" << std::endl;
m_CondVarRemovePerson.wait(locker);
}
m_RemoveNotified = false;
if (m_PeopleInside.size() > 0)
{
m_PeopleInside.removePerson();
std::cout << "Watchman | runRemove | good buy" << std::endl;
}
else
{
std::cout << "Watchman | runRemove | there is nobody in The Art Gallery" << std::endl;
}
std::cout << "Watchman | runRemove | check people in queue" << std::endl;
if (m_PeopleInQueue.size() > 0)
{
while (m_PeopleInside.size() < CountPeopleInside)
{
try
{
auto person = m_PeopleInQueue.get();
std::cout << "Watchman | runRemove | welcome in our The Art Gallery" << std::endl;
m_PeopleInside.add(std::move(person));
}
catch(const std::string& e)
{
std::cout << e << std::endl;
}
}
}
}
}
- Finally, move to the main() function. First, create all the shared variables that we used in the Watchman and PersonGenerator classes. Next, create the Watchman and PersonGenerator variables and pass those shared variables to the constructors. At the end of the main function read the character to avoid closing the application:
int main()
{
{
std::condition_variable g_CondVarRemovePerson;
std::condition_variable g_CondVarAddPerson;
std::mutex g_AddMux;
std::mutex g_RemoveMux;
bool g_AddNotified = false;;
bool g_RemoveNotified = false;
Persons g_PeopleInside;
Persons g_PeopleInQueue;
Persons g_CreatedPersons;
PersonGenerator generator(g_CreatedPersons, g_CondVarAddPerson, g_CondVarRemovePerson,
g_AddMux, g_RemoveMux, g_AddNotified, g_RemoveNotified);
Watchman watchman(g_CondVarAddPerson,
g_CondVarRemovePerson,
g_AddMux,
g_RemoveMux,
g_AddNotified,
g_RemoveNotified,
g_PeopleInside,
g_PeopleInQueue,
g_CreatedPersons);
}
char a;
std::cin >> a;
return 0;
}
- Compile and run the application. In the terminal, you will see logs from different threads about creating and moving people from one list to another. Your output will be similar to the following screenshot:
Figure 5.27: The result of the application's execution
As you can see, all the threads communicate with each other in a very easy and clean way. We protected our shared data by using mutexes so that we can avoid race conditions. Here, we used an exception to warn about the empty lists and caught them in the thread's functions so that our threads handle exceptions on their own. We also checked if the thread is joinable before joining it in the destructor. This allowed us to avoid the unexpected termination of the program. Thus, this small project demonstrates our skills when it comes to working with threads.