C/C++开发基础——原子操作与多线程编程

2023-11-13 14:02:09 浏览数 (1)

一,线程的创建与终止

线程是CPU最小的执行和调度单位。多个线程共享进程的资源。

创建线程比创建进程更快,开销更小。

创建线程的方法:pthread_create、std::thread。

pthread_create:传入的线程函数只有一个参数。

std::thread:传入的线程函数可以有任意数量的参数。

因为,thread类的构造函数是一个可变参数模板,可接收任意数目的参数,其中第一个参数是线程对应的函数名称。

std::thread调用以后返回一个线程类,每创建一个线程类,就会在系统中启动一个线程,并利用这个线程类来管理线程。

线程类可以被移动,但是不可以被复制,可以调用move()来改变线程的所有权。

线程的标识符是线程id,线程类可以调用this_thread::get_id()来获得当前线程的id。

创建线程以后,可以调用join()或者detach()来等待线程结束,join()会等启动的线程运行结束以后再继续执行当前代码,detach()会直接往后继续执行当前代码,而不需要等待启动的线程运行结束。如果调用detach()分离线程,该线程结束后,线程资源会自动被系统回收。

std::thread常用的创建线程类的方式有:

通过函数指针创建线程

通过函数对象创建线程

通过lambda表达式创建线程

通过成员函数创建线程

1.通过函数指针创建线程

代码样例:

函数

代码语言:javascript复制
void counter(int id, int numIterations)
{
    for(int i=0; i<numIterations;   i){
        cout << "Counter " << id << " has value " << i << endl;
    }
}

利用函数创建线程:

代码语言:javascript复制
thread t1(counter, 1, 6);
thread t2(counter, 2, 4);
t1.join();
t2.join();

注意,线程中的函数,比如counter(),在创建线程的时候,默认的传参方式是值拷贝,比如id,numIterations会被拷贝以后再传递到线程空间中。

2.通过函数对象创建线程

代码样例:

函数对象Counter:

代码语言:javascript复制
class Counter
{
    public:
    Counter(int id, int numIterations)
    :mId(id), mNumIterations(numIterations)
    {


    }

    //重载运算符operator()
    void operator()() const
    {
        for(int i=0; i < mNumIterations;   i){
            cout << "Counter " << mId << " has value " << i << endl;
        }
    }

    private:
        int mId;
        int mNumIterations;
};

利用函数对象创建线程:

方法1:通过构造函数创建Counter类的一个实例,将实例传递给thread类

代码语言:javascript复制
thread t1{Counter{1, 4}};

方法2:创建Counter类的一个实例c,将实例传递给thread类

代码语言:javascript复制
Counter c(2, 5);
thread t2(c);

完整代码实现:

代码语言:javascript复制
#include <thread>
#include <iostream>

using namespace std;

class Counter
{
    public:
    Counter(int id, int numIterations)
    :mId(id), mNumIterations(numIterations)
    {

    }

    //重载运算符operator()
    void operator()() const
    {
        for(int i=0; i < mNumIterations;   i){
            cout << "Counter " << mId << " has value " << i << endl;
        }
    }

    private:
        int mId;
        int mNumIterations;
};

int main(){
    thread t1{Counter{1, 4}};
    Counter c(2, 5);
    thread t2(c);

    t1.join();
    t2.join();

    cout << "Main thread end." << endl;
    return 0;
}

运行结果:

代码语言:javascript复制
Counter 1 has value 0
Counter 1 has value 1
Counter 1 has value 2
Counter 1 has value 3
Counter 2 has value 0
Counter 2 has value 1
Counter 2 has value 2
Counter 2 has value 3
Counter 2 has value 4
Main thread end.

3.通过lambda表达式创建线程

代码样例:

代码语言:javascript复制
#include <thread>
#include <iostream>

using namespace std;

int main(){
    int id = 1;
    int numIterations = 5;
    thread t1(
        [id, numIterations]{
            for(int i=0; i<numIterations;   i){
                cout << "Counter " << id << " has value " << i << endl;
            }
        }
    );

    t1.join();
    return 0;
}

运行结果:

代码语言:javascript复制
Counter 1 has value 0
Counter 1 has value 1
Counter 1 has value 2
Counter 1 has value 3
Counter 1 has value 4

4.通过成员函数创建线程

代码样例:

在线程中指定要执行该类的哪个成员函数。

代码语言:javascript复制
#include <thread>
#include <iostream>

using namespace std;

class Request
{
    public:
        Request(int id): mId(id){ }

        void process()
        {
            cout << "Processing request." << mId << endl;
        }
    private:
        int mId;
};

int main(){
    Request req_obj(100);
    thread t{ &Request::process, &req_obj };

    t.join();
    return 0;
}

运行结果:

代码语言:javascript复制
Processing request.100

我们也可用采用RAII写法,封装一个新的线程类,在线程类析构的时候自动调用join()来等待线程执行结束,写法如下:

代码语言:javascript复制
class RaiiThread {
private:
    std::thread& t;
public:
    RaiiThread(std::thread& _t ) : t(_t) {}

    ~RaiiThread() {
        if(t.joinable())
            t.join();
    }        

    //线程类不能被拷贝
    RaiiThread(const RaiiThread &)= delete;
    RaiiThread& operator=(const RaiiThread &)= delete ;
};

5.线程的终止

线程终止的方式有:

1.线程函数运行完返回,该子线程终止。

2.同一进程中的其他线程调用pthread_cancel()取消该线程,该子线程终止。

3.线程函数中调用pthread_exit()主动退出,该子线程终止。

4.主线程(main函数中)退出,所有子线程全部被终止。

5.子线程调用exit()函数,整个进程被终止。

二,thread_local变量

thread_local关键字可以实现线程的本地存储。

thread_local变量在多线程中只初始化一次,而且每个线程都有这个变量的独立副本, 每个线程都可以独立访问和修改自己的变量副本,而不会干扰其他线程。

thread_local变量的生命周期从初始化时开始,到线程运行完毕时结束。

代码语言:javascript复制
int m;  //所有线程共享m
thread_local int n; //每个线程都有自己的n副本

代码样例:

代码语言:javascript复制
#include <iostream>
#include <thread>
using namespace std;
void thread_func()
{
       thread_local int stls_variable = 0;
       stls_variable  = 1;
       cout << "Thread ID: " << this_thread::get_id()
              << ", Variable: " << stls_variable
              << endl;
}
int main()
{
       thread t1(thread_func);
       thread t2(thread_func);
       t1.join();
       t2.join();
       return 0;
}

运行结果:

代码语言:javascript复制
Thread ID: 16312, Variable: 1
Thread ID: 14848, Variable: 1

三,原子类型与原子操作

1.原子操作与数据安全

对于一个变量,编译器首先将值从内存加载到寄存器中,在寄存器中进行处理,然后再把结果保存回内存。由于多个线程共享进程中的内存空间,因此,这段内存可以被多个线程同时访问,导致数据争用。原子操作可以解决数据争用问题,保证数据安全。

如果对一个共享内存资源的操作是原子操作,当多个线程访问该共享资源时,在同一时刻,有且仅有一个线程可以对这个资源进行操作。

实现原子操作的方式:

1,使用互斥锁等同步机制

2,使用原子类型

2.常见的原子类型

图源自《深入理解C 11》

除了使用内置类型,开发者可以通过atomic类模板来自定义原子类型。

例如,定义一个T类型的原子类型变量t

代码语言:javascript复制
std::atomic<T> t;

3.代码样例

使用原子类型之前的多线程代码:

代码语言:javascript复制
#include <atomic>

using namespace std;

void increment(int& counter)
{
    for (int i = 0; i < 100;   i){
          counter;
        this_thread::sleep_for(1ms);
    }
}

int main()
{
    int counter = 0;
    vector<thread> threads;
    for(int i = 0; i < 10;   i){
        threads.push_back(thread{ increment, ref(counter) });
    }

    for (auto& t : threads){
        t.join();
    }

    cout << "Result = " << counter << endl;
}

使用原子类型之后的多线程代码:

代码语言:javascript复制
#include <atomic>

using namespace std;

void increment(atomic<int>& counter)
{
    for(int i=0; i<100;   i){
          counter;
        this_thread::sleep_for(1ms);
    }
}

int main()
{
    atomic<int> counter(0);
    vector<thread> threads;
    for(int i = 0; i < 10;   i){
        threads.push_back(thread{increment, ref(counter)});
    }

    for (auto& t : threads){
        t.join();
    }
    cout << "Result = " << counter << endl;
}

原子类型可以省去在线程的函数中进行加锁和解锁的操作,下面两段代码实现的效果一样:

Demo1:

代码语言:javascript复制
#include <thread>
#include <iostream>

using namespace std;

static long total = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void* func(void *){
    long i;
    for (i = 0; i < 999; i  )
    {
        pthread_mutex_lock(&m);
        total  = 1;
        pthread_mutex_unlock(&m);
    }
}

int main(){
    pthread_t thread1, thread2;
    if (pthread_create(&thread1, NULL, &func, NULL)){
        throw;
    }
    if (pthread_create(&thread2, NULL, &func, NULL)){
        throw;
    }

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    cout << total << endl;
    return 0;
}

运行结果:

代码语言:javascript复制
1998

Demo2:

代码语言:javascript复制
#include <thread>
#include <iostream>
#include <atomic>

using namespace std;

atomic_long total {0};  //原子数据类型

void* func(void *){
    long i;
    for (i = 0; i < 999; i  )
    {
        total  = 1;
    }
}

int main(){
    pthread_t thread1, thread2;
    if (pthread_create(&thread1, NULL, &func, NULL)){
        throw;
    }
    if (pthread_create(&thread2, NULL, &func, NULL)){
        throw;
    }

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    cout << total << endl;
    return 0;
}

运行结果:

代码语言:javascript复制
1998

四,互斥体与锁

对于数组类型或者布尔类型等简单的数据类型可以使用原子操作来同步,如果当数据类型变得很复杂的时候,需要采用显式的同步机制来保证线程之间的同步,常用的同步机制有互斥体类和锁类。

锁类的对象可以用来管理互斥体类的对象,比如unique_lock对象可以管理mutex对象。

互斥体的主要操作是加锁(lock)和解锁(unlock)。

互斥体还分定时互斥体和非定时互斥体。

1.非定时互斥体

头文件 :<mutex>

互斥体名:std::mutex、std::recursive_mutex

头文件:<shared_mutex>

互斥体名:std::shared_mutex

std::mutex互斥体的常用方法:

lock():调用该方法的线程将尝试获取锁,获取不到锁就会一直阻塞。

try_lock():调用该方法的线程将尝试获取锁,获取不到锁就会立即返回,获得锁时返回true,未获得锁时返回false。

unlock():释放由该线程持有的锁。

shared_mutex类功能和读写锁类似,写文件的时候加锁,然后独占所有权,读文件的时候加锁,但是会和其他线程共享所有权。

shared_mutex类除了支持lock()、try_lock()、unlock()等方法获取和释放锁,还支持lock_shared()、try_lock_shared()、unlock_shared()等方法获取和释放共享所有权。

注意:已经获取到锁的线程不能再次调用lock()和try_lock(),否则可能导致死锁。

2.定时互斥体

头文件:<mutex>

互斥体名:std::timed_mutex

头文件:<shared_mutex>

互斥体名:std::shared_timed_mutex

shared_timed_mutex类除了支持lock()、try_lock()、unlock()等方法获取和释放锁,还支持lock_shared()、try_lock_shared()、unlock_shared()等方法获取和释放共享所有权。

std::timed_mutex定时互斥体还支持以下方法:

try_lock_for():调用该方法的线程在给定时间间隔内尝试获取锁,在超时之前获取锁失败,返回false,在超时之前获取锁成功,返回true。

try_lock_until():调用该方法的线程在到达指定时间点之前尝试获取锁,在超时之前获取锁失败,返回false,在超时之前获取锁成功,返回true。

3.互斥锁

锁类是RAII写法,不需要手动释放和获取锁,比如lock_guard锁的构造函数里调用了锁的lock成员函数,析构函数里调用了锁的unlock成员函数。

因此,在生命周期结束或离开作用域时,锁类的析构函数会自动释放所关联的互斥体等资源。不需要手动调用unlock()方法,这样可以避免使用锁的时候出现问题,还可以防止死锁的发生。

锁类在标准库中都是模板类,用来生成锁的对象。

C 中常用的锁类:

头文件:<mutex>

常见的锁类:

std::lock_guard

std::unique_lock

std::scoped_lock

std::scoped_lock与std::lock_guard类似,它接收数量可变的互斥体,可以获取多个锁。

std::lock_guard比较轻量级,执行速度比std::unique_lock更快,但是std::unique_lock用法更灵活。std::lock_guard创建对象即加锁,不能显式的调用lock()和unlock(),而std::unique_lock可以在任意时候调用它们。

std::unique_lock类包含以下方法:

lock():加锁。

unlock():解锁。

try_lock():尝试获取锁,获取失败返回false,获取成功返回true。

try_lock_for():尝试在指定时间段内获取锁,获取失败返回false,获取成功返回true。

try_lock_until():尝试在指定时间点之前获取锁,获取失败返回false,获取成功返回true。

release():释放所有权。

4.代码样例

代码语言:javascript复制
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
int main()
{
    int counter = 0;
    std::mutex counter_mutex;
    std::vector<std::thread> threads;
    auto worker_task = [&](int id){
        std::unique_lock<std::mutex> lock(counter_mutex);
          counter;
        std::cout << id << ", initial counter: " << counter << 'n';
        lock.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
        lock.lock();
          counter;
        std::cout << id << ", final counter: " << counter << 'n';
    };
    for (int i = 0; i < 5;   i)
    {
        threads.emplace_back(worker_task, i);
    }

    for (auto& thread : threads)
    {
        thread.join();
    }
    return 0;  
}

运行结果:

代码语言:javascript复制
0, initial counter: 1
1, initial counter: 2
2, initial counter: 3
3, initial counter: 4
4, initial counter: 5
0, final counter: 6
3, final counter: 7
1, final counter: 8
2, final counter: 9
4, final counter: 10

五,条件变量

条件变量的使用在一定程度上可以避免线程的死锁。

条件变量可以让线程一直阻塞,直到某个条件成立,这个条件可以是另一个线程完成了某操作或者系统时间达到了指定时间。

条件变量允许显式的线程间通信。

条件变量所在的头文件:<condition_variable>

常用的两个条件变量:

std::condition_variable:只能等待unique_lock<mutex>上的条件变量。

std::condition_variable_any:可等待任何对象的条件变量,包括自定义的锁类型,自定义的锁类应提供lock()和unlock()方法。

两种条件变量都支持以下常用的方法:

notify_one():唤醒等待这个条件变量的线程之一。

notify_all():唤醒等待这个条件变量的所有线程。

wait():阻塞当前线程,直到条件变量被唤醒。

wait_for():阻塞当前线程,直到条件变量被唤醒,或到达指定时长。

wait_until():阻塞当前线程,直到条件变量被唤醒,或到达指定时间点。

六,多线程代码实战——线程安全的队列

1.具体设计

1.使用互斥锁来保护共享资源,这里的共享资源是队列。

2.互斥锁在push或者pop队列的时候加锁,在执行完毕后解锁。

3.使用条件变量来等待队列的更改。

4.当数据元素被添加到队列中时,条件变量会notify正在等待的线程,等待队列被更改的线程被唤醒并开始操作。

5.线程从队列中删除数据元素时,会先检查队列是否为空,如果为空,它会等待条件变量,直到有新元素被添加到队列中。

2.代码实现

代码语言:javascript复制
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>

template <typename T>
class TSQueue {
private:
    std::queue<T> m_queue;

    // mutex for thread synchronization
    std::mutex m_mutex;

    // Condition variable for signaling
    std::condition_variable m_cond;

public:
    void push(T item)
    {
        // acquire lock
        std::unique_lock<std::mutex> lock(m_mutex);

        m_queue.push(item);

        // Notify one thread that is waiting
        m_cond.notify_one();
    }

    T pop()
    {
        // acquire lock
        std::unique_lock<std::mutex> lock(m_mutex);

        // wait until queue is not empty
        m_cond.wait(lock,
            [this]() { return !m_queue.empty(); });

        T item = m_queue.front();
        m_queue.pop();

        return item;
    }
};

int main()
{
    TSQueue<int> q;

    // Push some data
    q.push(30);
    q.push(40);
    q.push(50);

    // Pop some data
    std::cout << q.pop() << std::endl;
    std::cout << q.pop() << std::endl;
    std::cout << q.pop() << std::endl;

    return 0;
}

运行结果:

代码语言:javascript复制
30
40
50

0 人点赞