Multi-threaded Bank Transaction System in C++ with Message Queue
This blog post will discuss a multi-threaded bank transaction system implemented in C++ that demonstrates various threading and synchronization concepts. This simple program simulates a bank with multiple accounts and processes transactions using worker threads. We will use a message queue to store transactions and ensure thread-safe communication between the main and worker threads.
The complete program is available for download at https://github.com/codeversionmaster/cplusplus/blob/cplusplus17/projects/bank_transaction_multithread_msgq.cpp.
The complete program is as below.
$ cat projects/bank_transaction_multithread_msgq.cpp
#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <random>
#include <chrono>
#include <atomic>
constexpr int NUM_ACCOUNTS = 5;
constexpr int NUM_TRANSACTIONS = 10;
constexpr int NUM_THREADS = 2;
class Bank {
public:
Bank(int num_accounts) : accounts(num_accounts, 1000) {}
void transfer(int from, int to, int amount) {
if (from != to && accounts[from] >= amount) {
accounts[from] -= amount;
accounts[to] += amount;
}
}
void print_balances() {
for (size_t i = 0; i < accounts.size(); ++i) {
std::cout << "Account " << i << ": " << accounts[i] << std::endl;
}
}
private:
std::vector<int> accounts;
};
struct Transaction {
int from;
int to;
int amount;
};
std::queue<Transaction> transaction_queue;
std::mutex transaction_mutex;
std::condition_variable transaction_cv;
std::atomic<bool> done{false};
void process_transactions(Bank &bank) {
while (!done.load() || !transaction_queue.empty()) {
std::unique_lock<std::mutex> lock(transaction_mutex);
transaction_cv.wait(lock, [] { return !transaction_queue.empty() || done.load(); });
if (!transaction_queue.empty()) {
Transaction transaction = transaction_queue.front();
transaction_queue.pop();
bank.transfer(transaction.from, transaction.to, transaction.amount);
std::cout << "Transferred " << transaction.amount << " from account " << transaction.from << " to account " << transaction.to << std::endl;
lock.unlock();
transaction_cv.notify_all();
}
}
}
int main() {
Bank bank(NUM_ACCOUNTS);
std::thread workers[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; ++i) {
workers[i] = std::thread(process_transactions, std::ref(bank));
}
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> account_dist(0, NUM_ACCOUNTS - 1);
std::uniform_int_distribution<> amount_dist(1, 100);
for (int i = 0; i < NUM_TRANSACTIONS; ++i) {
int from = account_dist(gen);
int to = account_dist(gen);
int amount = amount_dist(gen);
std::unique_lock<std::mutex> lock(transaction_mutex);
transaction_queue.push({from, to, amount});
lock.unlock();
transaction_cv.notify_all();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
done.store(true);
transaction_cv.notify_all();
for (auto &worker : workers) {
worker.join();
}
std::cout << "\nFinal account balances:\n";
bank.print_balances();
return 0;
}
Below is the compilation and execution output.
$ g++ -o bank_transaction_multithread_msgq bank_transaction_multithread_msgq.cpp
$ ./bank_transaction_multithread_msgq
Transferred 58 from account 4 to account 2
Transferred 31 from account 2 to account 2
Transferred 63 from account 3 to account 1
Transferred 94 from account 1 to account 0
Transferred 85 from account 3 to account 1
Transferred 18 from account 4 to account 2
Transferred 24 from account 0 to account 0
Transferred 47 from account 4 to account 4
Transferred 4 from account 1 to account 4
Transferred 54 from account 0 to account 0
Final account balances:
Account 0: 1094
Account 1: 1050
Account 2: 1076
Account 3: 852
Account 4: 928
C++ Concepts Covered
- Multi-threading: The program uses multiple threads to process transactions concurrently, increasing the efficiency and performance of the system.
- Mutexes: Mutexes protect shared resources (e.g., the message queue) from simultaneous access by multiple threads, ensuring thread safety and preventing race conditions.
- Condition Variables: Condition variables allow threads to wait for a certain condition to be met before proceeding. In this program, worker threads wait for the transaction queue to have pending transactions.
- Atomic Variables: Atomic variables provide a way to perform operations on data types in an atomic (uninterruptible) manner. We use an atomic boolean variable to signal when all transactions are completed.
C++ Standard Library Functions Used
- std::thread: Represents a single thread of execution.
- std::mutex: Provides exclusive ownership semantics for protecting shared data.
- std::unique_lock: A movable and non-copyable mutex ownership wrapper.
- std::condition_variable: Provides a synchronization primitive for blocking threads until notified.
- std::atomic: Template class for atomic (uninterruptible) operations on data types.
- std::queue: A container adapter that provides a FIFO (first-in, first-out) data structure.
- std::vector: A dynamic array-like container that can be resized.
Code Explanation
Bank Class:
The Bank class manages account balances and provides a transfer function to perform transactions. The class has a private member “accounts“, a vector of integers representing account balances. The transfer function takes the source account, destination account, and the amount to transfer as arguments. It checks if the source and destination accounts are different and the source account has sufficient funds before performing the transfer.
Transaction Struct:
The Transaction struct represents a single transaction with three members: from, to, and amount. This struct will be used to store transactions in the message queue.
Thread Function – process_transactions:
The process_transactions function is the entry point for worker threads. It takes a reference to the bank object as an argument. The function runs in a loop until the done variable is set to true and the transaction queue is empty. Inside the loop, it acquires a unique lock on the transaction_mutex and waits for the condition variable, ensuring that the transaction queue is not empty or the done variable is set to true. Once the condition is met, the function dequeues a transaction, processes it by calling the bank object’s transfer function, and then notifying any waiting threads. The loop continues until there are no more transactions to process.
Global Variables and Main Function:
The global variables include the transaction queue, a mutex for protecting the queue, a condition variable for notifying waiting threads, and an atomic boolean variable to signal when all transactions are completed.
The main function initializes the bank with a predefined number of accounts and creates worker threads to process transactions. It then generates random transactions and pushes them into the message queue. Once all transactions are generated, the done variable is set to true, and worker threads are notified to finish processing the remaining transactions before joining the main thread. Finally, the bank’s account balances are printed.
The main function initializes the bank with a predefined number of accounts and creates worker threads to process transactions. It then generates random transactions and pushes them into the message queue. Once all transactions are generated, the done variable is set to true, and worker threads are notified to finish processing the remaining transactions before joining the main thread. Finally, the bank’s account balances are printed.
In this blog post, we discussed a multi-threaded bank transaction system in C++ that demonstrates various threading and synchronization concepts. We covered the use of threads, mutexes, condition variables, atomic variables, and message queues to implement a simple and efficient bank transaction system. This program is an excellent starting point for learning about concurrency and synchronization in C++.
Avoiding race conditions in financial applications
Race conditions can cause serious issues in financial applications, where the correctness and consistency of data are paramount. A race condition occurs when multiple threads access shared data simultaneously, leading to unpredictable and undesirable outcomes. It is crucial to avoid race conditions when working with multi-threaded C++ applications to ensure the reliability of the application.
Continuing from the previous program, let’s delve deeper into the techniques to avoid race conditions in financial applications, focusing on the context of the multi-threaded bank transaction system. Here are some tips and techniques to help you achieve this goal:
- Mutexes (Mutual Exclusion): Mutexes are one of the primary tools for avoiding race conditions in multi-threaded applications. A mutex ensures that only one thread can access a shared resource at a time. When a thread needs to access the resource, it must first lock the mutex. If another thread attempts to lock the mutex while it’s already locked, it will block until it is released. This approach guarantees exclusive access to the shared resource, preventing race conditions.
- Fine-Grained Locking: In financial applications, it’s essential to minimize the time a resource is locked to allow more concurrent operations. Fine-grained locking involves locking only the specific resources that are being accessed or modified rather than locking the entire data structure. In our bank transaction system, we can improve concurrency by implementing separate locks for each account, allowing simultaneous transactions on different accounts.
- Read-Write Locks: In some financial applications, there might be a higher frequency of read operations than write operations. Using read-write locks (also known as shared-exclusive locks) can help improve concurrency by allowing multiple threads to read shared data simultaneously while ensuring exclusive access for write operations. C++17 introduced shared_mutex and shared_lock for this purpose. Refactor the bank class to utilize read-write locks for account balance access.
- Avoiding Deadlocks: Deadlocks can occur when two or more threads are waiting for each other to release a lock, resulting in a circular dependency. Deadlocks can be detrimental to financial applications, causing them to become unresponsive. In our bank transaction system, one way to avoid deadlocks is to establish a strict lock ordering when acquiring locks for multiple accounts. For example, always lock the account with the lower index first. This strategy prevents circular dependencies and eliminates the risk of deadlocks.
- Test and Monitor: Rigorous testing and monitoring are crucial for financial applications. Continuously test your application under various scenarios to identify potential race conditions, deadlocks, or other concurrency-related issues. Tools like ThreadSanitizer, Helgrind, or Intel Inspector can help detect potential problems. Furthermore, monitor your application in production to identify bottlenecks or unexpected behavior, allowing you to address issues promptly.
- Transactional Memory: In some cases, transactional memory can help avoid race conditions and simplify concurrency management. Transactional memory allows threads to execute a series of operations on shared data atomically. The transaction is aborted if a conflict is detected, and the operations are rolled back. The transaction is then retried. C++ does not provide built-in support for transactional memory, but there are third-party libraries like Intel’s Transactional Synchronization Extensions (TSX) or GCC’s libitm that offer this functionality. Note that transactional memory may not be suitable for all scenarios and might introduce performance overhead.
- Use of Atomic Operations: Atomic operations can help avoid race conditions in specific scenarios by guaranteeing that an operation is completed without interruption. C++11 introduced the std::atomic library, which provides a template class for atomic operations. Use atomic operations judiciously in financial applications, as they may not be suitable for all use cases and could degrade performance.
- Thorough Code Reviews: When developing financial applications, ensure that your code is thoroughly reviewed by colleagues or peers knowledgeable about concurrency and synchronization issues. This collaborative approach helps identify potential problems and ensures that everyone on the team understands the critical aspects of the code.
- Continuous Learning: Concurrency and synchronization are complex topics, and there’s always more to learn. Stay updated with the latest developments in the field, and don’t hesitate to seek guidance from experts or online resources. Understanding the intricacies of multi-threading will help you develop safer, more efficient financial applications.
By applying these techniques and staying vigilant about potential concurrency issues, you can ensure the reliability and correctness of your multi-threaded financial applications. Always remember that in finance, the stakes are high, and the cost of errors can be significant. Adopting best practices and being proactive in avoiding race conditions will help your financial applications maintain data integrity, ensuring that critical operations are executed accurately and efficiently. By staying informed about the latest advancements in concurrency and synchronization, and continuously refining your skills, you can build robust and reliable multi-threaded financial applications that can withstand the demands of today’s fast-paced and data-intensive world.