The use of multithreading and thread pool construction in C++. 150 lines of code, handwritten thread pool

C++ 11 introduces the std::thread standard library, which facilitates the development of multithreading.

When it comes to multi-threaded development, it's not just about creating a new thread, it's inevitable to involve thread synchronization.

To ensure thread synchronization and realize thread safety, it is necessary to use related tools, such as semaphores, mutexes, condition variables, atomic variables, and so on.

These noun concepts are derived from the operating system, and are not unique to which programming language. They have different forms of expression in different languages, but the principles behind them are the same.

A video explanation of 150 lines of code for the thread pool, friends who need to learn can click to watch: 150 lines of code, handwritten thread pool

C++ 11 also introduces mutex, condition_variable, future and other thread-safe classes, let’s learn about them one by one.

mutex

As a mutex, mutex provides the characteristic of exclusive ownership.

A thread locks the mutex until the unlock is called, the thread owns the lock, and other threads accessing the locked mutex will be blocked.

Example of use:

#include <thread>
#include <iostream>

int num = 0;
std::mutex mutex;

void plus(){
    std::lock_guard<std::mutex> guard(mutex);
    std::cout << num++ <<std::endl;
}

int main(){
    std::thread threads[10];
    for (auto & i : threads) {
        i = std::thread(plus);
    }
    for (auto & thread : threads) {
        thread.join();
    }
    return 0;
}

In the above code, 10 threads are created, and each thread will print the value of num first, and then increment the num variable by one, and print 0 to 9 in turn.

As we all know, the +1 operation is not thread-safe. It actually involves three steps: first read, then add one, and finally assign value. But because the mutex is used to ensure exclusivity, the results are printed in increasing order.

If the mutex is not used, it is possible that the previous thread has not yet completed the assignment, and the latter thread has read it, and the final result is random and unexpected.

condition_variable

As a condition variable, condition_variable will call the wait function to wait for a certain condition to be met. If it is not met, it will lock the current thread through unique_lock, and the current thread will be in a blocked state until other threads call the nofity function of the condition variable to wake up.

Example of use:

#include <iostream>
#include <thread>

int num = 0;
std::mutex mutex;
std::condition_variable cv;

void plus(int target){
    std::unique_lock<std::mutex> lock(mutex);
    cv.wait(lock,[target]{return num == target;});
    num++;
    std::cout << target <<std::endl;
    cv.notify_all();
}

int main(){
    std::thread threads[10];
    for (int i = 0; i < 10; ++i) {
        threads[i] = std::thread(plus,9-i);
    }
    for (auto & thread : threads) {
        thread.join();
    }
    return 0;
}

10 threads are also created, and each thread will have a target parameter, which represents the value to be printed by the thread. The threads are created in the order of 9 -> 0, and the final running result is to print 0 -> 9 in turn.

When each thread is running, it will first call the wait function to wait for the condition of num == target to be satisfied. Once it is satisfied, it will increase num by one, print the target value, and then wake up the next value that meets the condition.

By changing the wait function of the condition variable to wake up the conditions, different multithreading modes can be realized, such as the common producer-consumer model.

Share More on C / C ++ Linux back-end development of the underlying principles of knowledge and learning network to enhance the learning materials , complete technology stack, content knowledge, including Linux, Nginx, ZeroMQ, MySQL, Redis, thread pool, MongoDB, ZK, streaming media, audio and video , Linux kernel, CDN, P2P, epoll, Docker, TCP/IP, coroutine, DPDK, etc.

Click on the video learning materials: C/C++Linux server development/Linux background architect-learning video

condiation_variable implements production consumer mode

#include <iostream>
#include <thread>
#include <queue>

int main(){
    std::queue<int> products;
    std::condition_variable cv_pro,cv_con;
    std::mutex mtx;

    bool end = false;
    std::thread producer([&]{
        for (int i = 0; i < 10; ++i) {
            std::unique_lock<std::mutex> lock(mtx);
            cv_pro.wait(lock,[&]{return products.empty();});
            products.push(i);
            cv_con.notify_all();
        }
        cv_con.notify_all();
        end = true;
    });

    std::thread consumer([&]{
        while (!end){
            std::unique_lock<std::mutex> lock(mtx);
            cv_con.wait(lock,[&]{return !products.empty();});
            int d = products.front();
            products.pop();
            std::cout << d << std::endl;
            cv_pro.notify_all();
        }
    });

    producer.join();
    consumer.join();
    return 0;
}

future & promise

The future feature is rarely used in daily development. It can be used to obtain the results of asynchronous tasks, and it can also be used as a means of synchronization between threads.

Assuming that the program needs to create a thread to perform the time-consuming operation, and get the return value after the time-consuming operation is over, it can be achieved by condiation_variable. After the asynchronous thread is executed, the notify method is called to wake up the main thread. The same is true This can be achieved through futures.

When the program creates an asynchronous operation through a specific method, it returns a future, which can access the state of the asynchronous thread.

Set the value of a shared state in the asynchronous thread, and the future associated with the shared state can get the result through the get method. The get() method will block the calling thread and wait for the asynchronous thread to complete the setting.

The get method of future is actually equivalent to the wait method of condiation_variable, and the method of setting the value of shared state by asynchronous threads is equivalent to the notify method of condiation_variable.

There are three ways to create a future:

std::promise

Promise, just like its literal meaning, represents a promise, indicating that it will set the shared state in the asynchronous thread, and the future will wait patiently.

The calling process of promise and future is shown in the following figure:

The code example is as follows:

#include <iostream>
#include <thread>
#include <chrono>
#include <future>

void task(std::promise<int>& promise){
    std::this_thread::sleep_for(std::chrono::seconds(1));
    promise.set_value(10);
}

int main(){
    std::promise<int> promise;
    std::future<int> future = promise.get_future();
    std::thread t(task,std::ref(promise));
    int result = future.get();
    std::cout << "thread result is " << result << std::endl;
    t.join();
    return 0;
}
复制代码

The promise obtains the future object associated with the promise through the get_future method, and sets the value of the shared state through the set_value method.

std::packaged_task

packaged_task can be used to package a callable object, and can be used as a thread's running function, a bit similar to std::function.

But the difference is that it passes the execution result of the callable object it wraps to an associated future, so as to realize the sharing of state, and the future waits for the end of the execution of the callable object through the get method.

As shown in the following code:

#include <iostream>
#include <thread>
#include <chrono>
#include <future>

int task(){
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return 10;
}

int main(){
    std::packaged_task<int(void)> packaged_task(task);
    std::future<int> future = packaged_task.get_future();
    std::thread thread(std::move(packaged_task));
    int result = future.get();
    std::cout << "thread result is " << result << std::endl;
    thread.join();
    return 0;
}

packaged_task obtains the associated future object through the get_future method.

std::async

Async can also create futures, and it is more like a encapsulation of std::thread, std::packaged_task, and std::promise.

As shown in the following code:

#include <iostream>
#include <thread>
#include <chrono>
#include <future>

int task(){
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return 10;
}

int main(){
    std::future<int> future = std::async(std::launch::async,task);
    int result = future.get();
    std::cout << "thread result is " << result << std::endl;
    return 0;
}
复制代码

Through async to directly create an asynchronous thread and obtain the associated future object, even the thread creation operation is saved.

There are two execution strategies for async, launch::async and launch::deferred. The former is executed immediately, and the latter is to wait until the future.get() method is called to create a thread to execute the task.

Thread pool construction

After understanding the above thread-related operation classes, you can further advance and use them to create a thread pool.

Regarding the construction of thread pools, there will be many different supports according to specific businesses and usage scenarios, but some essential content will remain unchanged.

The starting point of the thread pool is of course to reduce the time and system resource overhead spent on frequently creating and destroying threads. In the form of expression, there is a pool of threads, which submit tasks to the thread pool, and finally allocate them to a thread for execution.

As shown in the figure below, it is the prototype of a simple thread pool. There are tasks, Thread Pool to distribute tasks, and Worker Thread to finally perform tasks. Although the sparrow is small and complete.

Next, we will disassemble the above part in detail.

Share More on C / C ++ Linux back-end development of the underlying principles of knowledge and learning network to enhance the learning materials , complete technology stack, content knowledge, including Linux, Nginx, ZeroMQ, MySQL, Redis, thread pool, MongoDB, ZK, streaming media, audio and video , Linux kernel, CDN, P2P, epoll, Docker, TCP/IP, coroutine, DPDK, etc.

Click on the video learning materials: C/C++Linux server development/Linux background architect-learning video

Task type

Task types can have multiple definitions based on business requirements. The main difference lies in the types of parameters and return values ​​required by the task.

In addition, the task itself can also have some attributes to identify the type of the attribute, which thread should be put in for execution, and so on.

For simplicity, define a simple Task type without parameters and return value types.

using task = std::function<void()>;

Number of threads

How many threads should there be in a thread pool? If the number is too large, resources will be wasted, and some threads may not be fully used. If it is too few, it will cause frequent creation of new threads.

A flexible thread pool should be able to dynamically change the number of threads. Refer to the implementation principle of Java thread pool and its practice in Meituan's business.

In Java's ThreadPoolExecutor, corePoolSize and maximumPoolSize are used to limit the number of threads. The number of threads will fluctuate between [0 ~ corePoolSize] and [corePoolSize ~ maximumPoolSize].

When the task is tight and the threads and cache are full, it will apply for threads and the number will reach the range of [corePoolSize ~ maximumPoolSize]. Once the task is slack, some idle threads will be released and the number will fall back to the range of [0 ~ corePoolSize]. If the task continues to be tight , Then the task will be rejected.

Of course, there are other strategies for determining the number of threads, which are determined according to specific business requirements, such as determining the number of threads based on CPU multi-core.

For simplicity, a fixed number of threads is used as a demonstration here.

size_t N = std::thread::hardware_concurrency();

Task cache

Assuming that N threads have been fixed, and each thread has a task executing, and a new task has arrived at this time, how should we deal with it? At this time, the task caching mechanism is needed (of course, the task can also be rejected directly).

Task caching is also divided into many forms:

  1. Global cache
  2. Thread cache
  3. Global cache + thread cache

Global cache

Global cache, as the name implies, is a global cache queue in the thread pool. All tasks that enter the thread pool will advance to the global cache, and then the global cache will distribute the tasks, and finally different worker threads will execute the tasks.

Thread cache

Thread caching, as the name suggests, is to have a cache queue in each worker thread, and then the thread continuously loops to process the tasks on its own cache queue. All tasks that enter the thread pool will be dispatched and distributed from the thread pool, and then entered into the cache queue corresponding to the worker thread, and finally executed.

Global cache + thread cache

Global cache + thread cache is a combination of the above two, use the following figure to summarize the demonstration:

This kind of caching method can be regarded as a more complicated situation. It is suitable for the situation of large amount of calculation and fast execution. Generally speaking, it is more common for global caching.

Cache queue

With the task cache, then the cache queue should also be defined. There is no doubt that the cache queue must be thread-safe because it shares tasks among multiple worker threads.

There are many forms of cache queues, such as blocking queues, blocking queues of doubly linked lists, etc. Here, we define a simple queue and make std::queue a thread-safe encapsulation.

#pragma once

#include <mutex>
#include <queue>

// Thread safe implementation of a Queue using an std::queue
template <typename T>
class SafeQueue {
private:
  std::queue<T> m_queue;
  std::mutex m_mutex;
public:
  SafeQueue() {

  }

  bool empty() {
    std::unique_lock<std::mutex> lock(m_mutex);
    return m_queue.empty();
  }
  
  int size() {
    std::unique_lock<std::mutex> lock(m_mutex);
    return m_queue.size();
  }

  void enqueue(T& t) {
    std::unique_lock<std::mutex> lock(m_mutex);
    m_queue.push(t);
  }
  
  bool dequeue(T& t) {
    std::unique_lock<std::mutex> lock(m_mutex);

    if (m_queue.empty()) {
      return false;
    }
    t = std::move(m_queue.front());
    
    m_queue.pop();
    return true;
  }
};

The enqueue and dequeue methods are defined to stuff tasks and fetch tasks in the queue, and locks are used to ensure thread safety.

Thread scheduling

The core part of the thread pool is thread scheduling. Assuming that the global cache is used, how to distribute the tasks in the global cache to idle threads?

In fact, from a certain perspective, the thread pool of the global cache can also be considered as a single producer-multi-consumer model. The global cache is the producer, and multiple threads are multiple consumers.

In the previous code practice, a single-producer-single-consumer model has been written. After the producer produces the Task, the consumer is awakened by the notify method, and the Task is assigned to the consumer for execution. Since there is only one consumer, it is the only one that wakes up.

If there are multiple consumers, which one is awakened? The answer is random. Calling the notify_one method will wake up one thread randomly, and calling notify_all will wake up all threads.

But waking up does not mean that threads will consume Task. One Task corresponds to multiple threads. After the thread wakes up, it will go to the global cache to grab the Task task. Once it succeeds, it will be executed. Other threads that have not been grabbed will continue to hang and wait for the next time. wake.

The running status of threads in the thread pool is shown in the figure below:

In essence, the thread pool still wakes up threads through the notify method to achieve task distribution and scheduling.

This method has a certain degree of randomness and cannot guarantee which thread is awakened. You can customize the related scheduling logic according to business needs, such as waking up only certain threads with common attributes, or waking up specific threads according to the requirements of Task tasks, and more You can directly dispatch the task to the corresponding thread for execution without the notify method.

According to the above flowchart, the code running by the worker thread can be given:

class WorkerThread {
private:
    int m_id;
    ThreadPool *m_pool;
public:
    WorkerThread(ThreadPool *pool, int id) : m_pool(pool), m_id(id) {

    }

    void operator()() {
        task func;
        bool dequeued;
        while (!m_pool->m_shutdown) {
            std::unique_lock<std::mutex> lock(m_pool->m_mutex);
            if (m_pool->m_queue.empty()){
                m_pool->m_condition_variable.wait(lock);
            }
            dequeued = m_pool->m_queue.dequeue(func);
            if (dequeued) {
                func();
            }
        }
    }
};

The following is a simple thread pool code practice:


#ifndef THREAD_POOL_THREADPOOL_H
#define THREAD_POOL_THREADPOOL_H

#include <functional>
#include <future>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <queue>
#include "SafeQueue.h"

using task = std::function<void()>;

class ThreadPool {
public:
    ThreadPool(size_t thread_num = std::thread::hardware_concurrency()) : m_threads(
            std::vector<std::thread>(thread_num)), m_shutdown(false) {
    }

    void init() {
        for (int i = 0; i < m_threads.size(); ++i) {
            m_threads[i] = std::thread(WorkerThread(this, i));
        }
    }

    void shutdown() {
        m_shutdown = true;
        m_condition_variable.notify_all();
        for (int i = 0; i < m_threads.size(); ++i) {
            if (m_threads[i].joinable()) {
                m_threads[i].join();
            }
        }
    }


    std::future<void> submit(task t){
        auto p_task = std::make_shared<std::packaged_task<void()>>(t);
        task wrapper_task = [p_task](){
             (*p_task)();
        };
        m_queue.enqueue(wrapper_task);
        m_condition_variable.notify_one();
        return p_task->get_future();
    }

private:
    class WorkerThread {
    private:
        int m_id;
        ThreadPool *m_pool;
    public:
        WorkerThread(ThreadPool *pool, int id) : m_pool(pool), m_id(id) {

        }

        void operator()() {
            task func;
            bool dequeued;
            while (!m_pool->m_shutdown) {
                std::unique_lock<std::mutex> lock(m_pool->m_mutex);
                if (m_pool->m_queue.empty()){
                    m_pool->m_condition_variable.wait(lock);
                }
                dequeued = m_pool->m_queue.dequeue(func);
                if (dequeued) {
                    func();
                }
            }
        }
    };

    bool m_shutdown;
    SafeQueue<task> m_queue;
    std::vector<std::thread> m_threads;
    std::mutex m_mutex;
    std::condition_variable m_condition_variable;

};

#endif //THREAD_POOL_THREADPOOL_H

Submit the task to the global cache queue through the submit method, and then wake up the thread to consume task execution.

summary

There are still many knowledge points about the use of C++ multithreading. The above is only part of the introduction, and there are many shortcomings, which will be added later.

 

Guess you like

Origin blog.csdn.net/Linuxhus/article/details/114948553