Linux系统编程—线程同步与互斥

第一章:线程互斥

1-1 进程线程间的互斥相关背景概念

  • 共享资源
  • 临界资源:多线程执行流被保护的共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

1-2 互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。
cpp 复制代码
#include <iostream>
#include <cstdio>
#include <pthread.h>
#include <vector>
#include <unistd.h>
#include <cstring>

using namespace std;

//线程互斥
#define NUM 4

class threadData {
public:
    threadData(int number) { 
        threadname = "thread-" + to_string(number);
    }
public:
    string threadname;
};

int tickets = 30;
void* getTicket(void* args) {
    threadData* td = static_cast<threadData*>(args);
    const char* name = td->threadname.c_str();
    while (true) {
        if (tickets > 0) {
            printf("who=%s, get a ticket:%d\n", name, tickets);
            tickets--;
        }
        else
            break;
        // sleep(1);
    }
    printf("%s ... quit\n", name);
    return nullptr;
}
int main() {
    vector<pthread_t> tids;
    vector<threadData*> thread_datas;
    for (int i = 1; i <= NUM; i++) {
        pthread_t tid;
        threadData* td = new threadData(i);
        thread_datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_datas[i-1]);
        tids.push_back(tid);
    }

    for (auto thread : tids) pthread_join(thread, nullptr);
    for (auto td : thread_datas) delete td;
    return 0;
}

为什么可能无法获得正确结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket 操作本身就不是一个原子操作
bash 复制代码
取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax #
600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) #
600b34 <ticket>

-- 操作并不是原子操作,而是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

重复结果

负数结果

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

互斥量的接口

初始化互斥量

初始化互斥量有两种方法:

方法1,静态分配:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
cpp 复制代码
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

#define NUM 4

// 线程数据类:封装每个线程的名称和锁的指针
class threadData {
public:
    threadData(int number) { 
        threadname = "thread-" + to_string(number);
    }
public:
    string threadname;
};

int tickets = 300;
// 线程运行函数
void* getTicket(void* args) {
    threadData* td = static_cast<threadData*>(args);
    const char* name = td->threadname.c_str();

    while (true) {
        //线程对于锁的竞争能力可能会不同
        pthread_mutex_lock(&lock);//线程申请锁成功,才能往后执行;不成功,阻塞等待
        if (tickets > 0) {
            printf("who=%s, get a ticket:%d\n", name, tickets);
            tickets--;
            pthread_mutex_unlock(&lock);
        }
        else {
            pthread_mutex_unlock(&lock);
            break;     
        }
        //如果下面这行,基本都是一个线程在抢票或一个线程会连续抢票
        usleep(13);//抢到了票,还会立马抢下一张吗。其实多线程还要执行得到票后的后续动作,usleep模拟该动作
    }
   
    printf("%s ... quit\n", name);
    return nullptr;
}
int main() {
    // 存放线程 ID 和 线程数据对象的容器
    vector<pthread_t> tids;
    vector<threadData*> thread_datas;
    for (int i = 1; i <= NUM; i++) {
        pthread_t tid;//输出型参数,用来接收系统分配的线程ID。
        threadData* td = new threadData(i);
        thread_datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_datas[i-1]);
        tids.push_back(tid);
    }

    for (auto thread : tids) pthread_join(thread, nullptr);
    for (auto td : thread_datas) delete td;
    return 0;
}

方法2,动态分配:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t* restrict mutex, const
	pthread_mutexattr_t* restrict attr);
	参数:
		mutex:要初始化的互斥量
		attr:NULL
cpp 复制代码
//锁
#define NUM 4

// 线程数据类:封装每个线程的名称和锁的指针
class threadData {
public:
    threadData(int number, pthread_mutex_t* mutex) { 
        threadname = "thread-" + to_string(number);
        lock = mutex;
    }
public:
    string threadname;
    pthread_mutex_t* lock;
};

int tickets = 300;
// 线程运行函数
void* getTicket(void* args) {
    threadData* td = static_cast<threadData*>(args);
    const char* name = td->threadname.c_str();

    while (true) {
        //线程对于锁的竞争能力可能会不同
        pthread_mutex_lock(td->lock);//线程申请锁成功,才能往后执行;不成功,阻塞等待
        if (tickets > 0) {
            printf("who=%s, get a ticket:%d\n", name, tickets);
            tickets--;
            pthread_mutex_unlock(td->lock);
        }
        else {
            pthread_mutex_unlock(td->lock);
            break;     
        }
        //如果下面这行,基本都是一个线程在抢票或一个线程会连续抢票
        usleep(13);//抢到了票,还会立马抢下一张吗。其实多线程还要执行得到票后的后续动作,usleep模拟该动作
    }
   
    printf("%s ... quit\n", name);
    return nullptr;

    // //该版本,else break有问题。
    // //如果没有票了,那么就会执行else break,即不会执行最后的解锁。
    // //这样会导致锁资源一直未就绪,即其他线程一直卡在申请锁资源这里
    // while (true) {
    //     pthread_mutex_lock(td->lock);//线程申请锁成功,才能往后执行;不成功,阻塞等待
    //     if (tickets > 0) {
    //         printf("who=%s, get a ticket:%d\n", name, tickets);
    //         tickets--;
    //     }
    //     else break;     
    //     pthread_mutex_unlock(td->lock);
    // }
    
    // //这种方式不对。这种是一个线程抢完票才轮到下一个  
    // //在进入 while(true) 之前就把互斥锁 td->lock 锁住了,整个循环期间锁一直被同一个线程持有。
    // //结果就是:线程 A 会在拿完(或耗尽)所有票之前不会释放锁,线程 B/C/D 无法并发地进入临界区,
    // //只能被阻塞 ------ 等于是把并发变成了串行,违背了抢票场景想要的"多个线程交替竞争"的行为。
    // pthread_mutex_lock(td->lock);
    // while (true) {       
    //     if (tickets > 0) {
    //         printf("who=%s, get a ticket:%d\n", name, tickets);
    //         tickets--;
    //     }
    //     else break;     
    // }
    // pthread_mutex_unlock(td->lock);
}
int main() {
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    // 存放线程 ID 和 线程数据对象的容器
    vector<pthread_t> tids;
    vector<threadData*> thread_datas;
    for (int i = 1; i <= NUM; i++) {
        pthread_t tid;//输出型参数,用来接收系统分配的线程ID。
        threadData* td = new threadData(i, &lock);
        thread_datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_datas[i-1]);
        tids.push_back(tid);
    }

    for (auto thread : tids) pthread_join(thread, nullptr);
    for (auto td : thread_datas) delete td;
    pthread_mutex_destroy(&lock);
    return 0;
}

销毁互斥量

销毁互斥量需要注意:

  • 使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量加锁和解锁

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

调用 pthread_lock 时,可能会遇到以下情况:

互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功

发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到

互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

改进上面的售票系统:

LockGuard.hpp

cpp 复制代码
#pragma once

#include <pthread.h>

//封装锁的接口
class Mutex {
public:
    Mutex(pthread_mutex_t* lock)
        :lock_(lock) {}

    void Lock() { pthread_mutex_lock(lock_); }
    void Unlock() { pthread_mutex_unlock(lock_); }

    ~Mutex() {}
private:
    pthread_mutex_t* lock_;
};

class LockGuard {
public:
    LockGuard(pthread_mutex_t* lock)
        :mutex_(lock) {
        mutex_.Lock();
    }

    ~LockGuard() { mutex_.Unlock(); }
private:
    Mutex mutex_;
};

mythread.cc

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <pthread.h>
#include <vector>
#include <unistd.h>
#include <cstring>
#include "LockGuard.hpp"

using namespace std;

//使用自己封装的锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

#define NUM 4

// 线程数据类:封装每个线程的名称和锁的指针
class threadData {
public:
    threadData(int number) { 
        threadname = "thread-" + to_string(number);
    }
public:
    string threadname;
};

int tickets = 300;
// 线程运行函数
void* getTicket(void* args) {
    threadData* td = static_cast<threadData*>(args);
    const char* name = td->threadname.c_str();
    while (true) {
        //用{}明确临界区,在该区域中自动加锁、解锁
        //在这个作用域里定义的局部变量,会在离开这个 {} 时被自动销毁。
        //销毁时会调用对象的析构函数(如果有),这就是 C++ RAII 的核心思想之一。
        {
            LockGuard lockguard(&lock);//构造LockGuard对象
            //用LockGuard对象的声明周期管理加锁、解锁。RAII风格的锁
            if (tickets > 0) {
                printf("who=%s, get a ticket:%d\n", name, tickets);
                tickets--;           
            }
            else break;  
        }
        usleep(13);//usleep模拟抢完票后的动作,所以不需要在临界区。即该动作不需要持有锁
    }
   
    printf("%s ... quit\n", name);
    return nullptr;
}
int main() {
    // 存放线程 ID 和 线程数据对象的容器
    vector<pthread_t> tids;
    vector<threadData*> thread_datas;
    for (int i = 1; i <= NUM; i++) {
        pthread_t tid;//输出型参数,用来接收系统分配的线程ID。
        threadData* td = new threadData(i);
        thread_datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_datas[i-1]);
        tids.push_back(tid);
    }

    for (auto thread : tids) pthread_join(thread, nullptr);
    for (auto td : thread_datas) delete td;
    return 0;
}

1-3 互斥量实现原理探究

  • 经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下

1-4 互斥量的封装

Lock.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
namespace LockModule {
	// 对锁进⾏封装,可以独⽴使⽤
	class Mutex {
	public:
		// 删除不要的拷贝和赋值
		Mutex(const Mutex&) = delete;
		const Mutex& operator =(const Mutex&) = delete;
		Mutex() {
			int n = pthread_mutex_init(&_mutex, nullptr);
			(void)n;
		}
		void Lock() {
			int n = pthread_mutex_lock(&_mutex);
			(void)n;
		}
		void Unlock() {
			int n = pthread_mutex_unlock(&_mutex);
			(void)n;
		}
		pthread_mutex_t* GetMutexOriginal() { // 获取原始指针
			return &_mutex;
		}
		~Mutex() {
			int n = pthread_mutex_destroy(&_mutex);
			(void)n;
		}
	private:
		pthread_mutex_t _mutex;
	};
	// 采⽤RAII风格,进⾏锁管理
	class LockGuard {
	public:
		LockGuard(Mutex& mutex) :_mutex(mutex) {
			_mutex.Lock();
		}
		~LockGuard() {
			_mutex.Unlock();
		}
	private:
		Mutex& _mutex;
	};
}

抢票的代码就可以更新成为

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Lock.hpp"
using namespace LockModule;
int ticket = 1000;
Mutex mutex;
void* route(void* arg) {
	char* id = (char*)arg;
	while (1) {
		LockGuard lockguard(mutex); // 使⽤RAII风格的锁
		if (ticket > 0) {
			usleep(1000);
			printf("%s sells ticket:%d\n", id, ticket);
			ticket--;
		}
		else {
			break;
		}
	}
	return nullptr;
}
int main(void) {
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, route, (void*)"thread 1");
	pthread_create(&t2, NULL, route, (void*)"thread 2");
	pthread_create(&t3, NULL, route, (void*)"thread 3");
	pthread_create(&t4, NULL, route, (void*)"thread 4");
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
}

RAII风格的互斥锁,C++11也有,比如:

std::mutex mtx;

std::lock_guard<std::mutex> guard(mtx);

此处我们仅做封装,方便后续使用,详情见C++课程

另外,如果课堂有时间,也可以把我们封装的线程加入进来。

第二章:线程同步

2-1 条件变量

  • 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
  • 例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

2-2 同步概念与竞态条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

2-3 条件变量函数

初始化

cpp 复制代码
int pthread_cond_init(pthread_cond_t* restrict cond, const pthread_condattr_t
	* restrict attr);
参数:
	cond:要初始化的条件变量
	attr:NULL

销毁

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond)

等待条件满足

cpp 复制代码
int pthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict
	mutex);
参数:
	cond:要在这个条件变量上等待
	mutex:互斥量,后面详细解释

唤醒等待

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

简单案例:

我们先使用PTHREAD_COND/MUTEX_INITIALIZER进行测试,对其他细节暂不追究

然后将接口更改成为使用 pthread_cond_init/pthread_cond_destroy 的方式,方便后续进行封装

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

//演示条件变量
int cnt = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void* Count(void* args) {
    pthread_detach(pthread_self());
    uint64_t number = (uint64_t)args;
    cout << "pthread:" << number << " create success" << endl;

    //在多线程环境下,多个线程可能会同时访问同一个资源。如果没有加锁,判断资源状态和修改资源状态的操作就可能是不一致的。
    //例如,线程A检查到资源是空的,打算向其中添加数据,但在线程A执行添加操作之前,线程B也检查了资源是空的并进行添加操作,这就导致了冲突。
    //加锁之后,线程才能安全地检查临界资源的状态,因为加锁后,其他线程无法访问被加锁的资源,确保了状态判断和修改操作的一致性。
    while (true) {
        //整个流程:线程进入临界资源,判断临界资源是否就绪,
        //如果就绪,执行任务;如果不就绪,就等待。
        pthread_mutex_lock(&mutex);
        //如何知道要让线程休眠?一定是临界资源没就绪,临界资源是有状态的。
        //怎么知道临界资源是否就绪。自己判断的。判断是访问临界资源吗?是的,即判断必须在加锁之后
        pthread_cond_wait(&cond, &mutex);//看似是先拿锁,在去等待,不合理。实际上1.让线程等待时,会自动释放锁
        //不管临界资源的状态情况
        cout << "pthread:" << number << ", cnt:" << cnt++ << endl;
        pthread_mutex_unlock(&mutex);
        sleep(1);
    }
}

int main() {
    for (uint64_t i = 0; i < 5; i++) {
        pthread_t tid;
        //如果对i取地址,会造成这里和Count函数访问同一个i,从而数据混乱
        // pthread_create(&tid, nullptr, Count, (void*)&i);
        pthread_create(&tid, nullptr, Count, (void*)i);
        usleep(1000);
    }
    sleep(3);

    //让主线程去唤醒等待的线程
    cout << "main thread ctrl begin:" << endl;

    while (true) {
        sleep(1);
        // pthread_cond_signal(&cond);//唤醒在cond等待队列中等待的一个线程,默认是第一个
        pthread_cond_broadcast(&cond);//唤醒所有线程
        cout << "signal one thread..." << endl;
    }
    return 0;
}

2-4 生产者消费者模型

  • 321原则(便于记忆)

2-4-1 为何要使用生产者消费者模型

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

2-4-2 生产者消费者模型优点

  • 解耦
  • 支持并发
  • 支持忙闲不均

2-5 基于BlockingQueue的生产者消费者模型

2-5-1 BlockingQueue

在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)

2-5-2 C++ queue模拟阻塞队列的生产消费模型

代码:

为了便于同学们理解,我们以单生产者,单消费者,来进行讲解。

刚开始写,我们采用原始接口。

我们先写单生产,单消费。然后改成多生产,多消费(这里代码其实不变)。

演示生产数据

BlockQueue.hpp

cpp 复制代码
​​#pragma once

#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>

using namespace std;

template <class T>
class BlockQueue {
public:
    static const int defaultnum = 20;
    BlockQueue(int maxcap = defaultnum)
        :maxcap_(maxcap) {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&c_cond_, nullptr);
        pthread_cond_init(&p_cond_, nullptr);
        low_water_ = maxcap_ / 3;
        high_water_ = (maxcap_ * 2) / 3;
    }

    T pop() {
        pthread_mutex_lock(&mutex_);//该队列是共享资源,需要加锁
        if (q_.size() == 0) {
            //不能让消费和生产在同一个队列等。因为如果有多个消费和生产,本应该唤醒消费,结果可能唤醒生产
            // pthread_cond_wait(&cond_, &mutex_);
            pthread_cond_wait(&c_cond_, &mutex_);
        }

        T out = q_.front();
        q_.pop();
        if (q_.size() < low_water_) pthread_cond_signal(&p_cond_);//消费过,就能生产
        pthread_mutex_unlock(&mutex_);
        return out;
    }

    //"先判断再加锁"的过程中,这个判断不是受保护的。
    //也就是说,当你在判断 queue.empty() 时,别的线程可能正在修改 queue。
    //比如:线程A判断queue不空,此时线程B从queue取走了最后一个元素
    //线程A获取锁,准备取数据,但queue已空,可能导致出错或访问非法数据
    void push(const T& in) {
        pthread_mutex_lock(&mutex_);//该队列是共享资源,需要加锁
        if (q_.size() == maxcap_) //队列满了就等待
            pthread_cond_wait(&p_cond_, &mutex_);//1.调用的时候自动释放锁
        q_.push(in);//确保生产条件满足才能生产(即队列不满)
        if (q_.size() > high_water_) pthread_cond_signal(&c_cond_);//生产了就能消费
        pthread_mutex_unlock(&mutex_);
    }

    ~BlockQueue() {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&c_cond_);
        pthread_cond_destroy(&p_cond_);
    }
private:
    queue<T> q_;//共享资源
    int maxcap_;//极值
    pthread_mutex_t mutex_;
    pthread_cond_t c_cond_;//解决生产和消费的同步问题
    pthread_cond_t p_cond_;
    int low_water_;//低于low_water_ 赶紧生产
    int high_water_;//高于high_water_ 赶紧消费
};

main.cc

cpp 复制代码
//演示生产数据
void* Consumer(void* args) {
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
    while (true) {
        //消费
        int data = bq->pop();
        cout << "{消费}了一个数据:" << data << endl;
        // sleep(2);
    }
}

void* Productor(void* args) {
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
    int data = 0;
    while (true) {
        //生产

        data++;
        bq->push(data);
        cout << "[生产]了一个数据:" << data << endl;
        usleep(500000);
    }
}

int main() {
    //BlockQueue 内部可不可以传递其他数据,比如对象?比如任务
    BlockQueue<int>* bq = new BlockQueue<int>();

    pthread_t c, p;
    pthread_create(&c, nullptr, Consumer, bq);//传bq让两个线程看到用一份资源
    pthread_create(&p, nullptr, Productor, bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    delete bq;
    return 0;
}

注意:这里采用模版,是想告诉我们,队列中不仅仅可以放置内置类型,比如int,对象也可以作为任务来参与生产消费的过程哦。

演示生产任务

Task.hpp

cpp 复制代码
#pragma once

#include <iostream>
using namespace std;

class Task {
private:
    int a;
    int b;
public:
    Task(int x, int y) 
        :a(x), b(y) {}

    void run() {
        cout << "run task " << a << "+" << b << "=" << a+b << endl;
    }

    ~Task() {}
};

BlockQueue.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>

using namespace std;

template <class T>
class BlockQueue{
public:
    static const int defaultnum = 20;
    BlockQueue(int maxcap = defaultnum)
        :maxcap_(maxcap) {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&c_cond_, nullptr);
        pthread_cond_init(&p_cond_, nullptr);
        low_water_ = maxcap_ / 3;
        high_water_ = (maxcap_ * 2) / 3;
    }

    T pop() {
        pthread_mutex_lock(&mutex_);//该队列是共享资源,需要加锁
        if (q_.size() == 0) {
            //不能让消费和生产在同一个队列等。因为如果有多个消费和生产,本应该唤醒消费,结果可能唤醒生产
            // pthread_cond_wait(&cond_, &mutex_);
            pthread_cond_wait(&c_cond_, &mutex_);
        }

        T out = q_.front();
        q_.pop();
        if(q_.size() < low_water_) pthread_cond_signal(&p_cond_);//消费过,就能生产
        pthread_mutex_unlock(&mutex_);
        return out;
    }

    //"先判断再加锁"的过程中,这个判断不是受保护的。
    //也就是说,当你在判断 queue.empty() 时,别的线程可能正在修改 queue。
    //比如:线程A判断queue不空,此时线程B从queue取走了最后一个元素
    //线程A获取锁,准备取数据,但queue已空,可能导致出错或访问非法数据
    void push(const T& in) {
        pthread_mutex_lock(&mutex_);//该队列是共享资源,需要加锁
        if (q_.size() == maxcap_) //队列满了就等待
            pthread_cond_wait(&p_cond_, &mutex_);//1.调用的时候自动释放锁
        q_.push(in);//确保生产条件满足才能生产(即队列不满)
        if(q_.size() > high_water_) pthread_cond_signal(&c_cond_);//生产了就能消费
        pthread_mutex_unlock(&mutex_);
    }

    ~BlockQueue() {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&c_cond_);
        pthread_cond_destroy(&p_cond_);
    }
private:
    queue<T> q_;//共享资源
    int maxcap_;//极值
    pthread_mutex_t mutex_;
    pthread_cond_t c_cond_;//解决生产和消费的同步问题
    pthread_cond_t p_cond_;
    int low_water_;//低于low_water_ 赶紧生产
    int high_water_;//高于high_water_ 赶紧消费
};

main.cc

cpp 复制代码
#include "BlockQueue.hpp"
#include "Task.hpp"

//演示生产任务
void* Consumer(void* args) {
    BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(args);
    while (true) {
        //消费
        Task t = bq->pop();
        t.run();
        // sleep(2);
    }
}

void* Productor(void* args) {
    BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(args);
    int x = 10;
    int y = 20;
    while (true) {
        //生产
        Task t(x, y);
        bq->push(t);
        cout << "[生产]了一个任务" << endl;
        usleep(500000);
    }
}

int main() {
    //BlockQueue 内部可不可以传递其他数据,比如对象?比如任务
    BlockQueue<Task>* bq = new BlockQueue<Task>();

    pthread_t c, p;
    pthread_create(&c, nullptr, Consumer, bq);//传bq让两个线程看到用一份资源
    pthread_create(&p, nullptr, Productor, bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    delete bq;
    return 0;
}

2-6 为什么 pthread_cond_wait 需要互斥量?

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有另一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。

按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:

cpp 复制代码
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
	pthread_mutex_unlock(&mutex);
	//解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过
	pthread_cond_wait(&cond, &mutex);
	pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
  • 由于解锁和等待不是原子操作。调用解锁之后,pthread_cond_wait之前,如果已经有其他线程获取到互斥量,并且条件满足,发送了信号,那么pthread_cond_wait将错过这个信号,可能会导致线程永远阻塞在这个pthread_cond_wait。所以解锁和等待必须是一个原子操作。
  • int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_wait返回,把条件量改成1,把互斥量恢复成原样。

2-7 条件变量使用规范

等待条件代码

cpp 复制代码
pthread_mutex_lock(&mutex);
while (条件为假) //if??
	pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);


void push(const T& in) {
    pthread_mutex_lock(&mutex_);//该队列是共享资源,需要加锁
    //3个生产者都在pthread_cond_wait这里等待,全部被唤醒。第一个生产者抢锁成功,往后执行生产。
    //第二个生产者又抢锁成功,因为判断条件是if,已经判断过了,所以第二个生产者就不判断了,继续往后执行,导致溢出。
    //所以把if改为while后,第二个生产者抢锁成功后,不会往后执行,而是回到while在判断一次,满足才生产,不满足就等待。
    // if (q_.size() == maxcap_) //队列满了就等待。
    while (q_.size() == maxcap_) //防止线程被伪唤醒的情况
        pthread_cond_wait(&p_cond_, &mutex_);//1.调用的时候自动释放锁
    q_.push(in);//确保生产条件满足才能生产(即队列不满)
    pthread_cond_broadcast(&c_cond_);
    pthread_mutex_unlock(&mutex_);
}

给条件发送信号代码

cpp 复制代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

2-8 条件变量的封装

基于上面的基本认识,我们已经知道条件变量如何使用,虽然细节需要后面再来进行解释,但这里可以做一下基本的封装,以备后用。

Cond.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include "Lock.hpp"
namespace CondModule {
	using namespace LockModule;
	class Cond {
	public:
		Cond() {
			int n = pthread_cond_init(&_cond, nullptr);
			(void)n; // 酌情加⽇志,加判断
		}

		void Wait(Mutex& mutex) {
			int n = pthread_cond_wait(&_cond, mutex.GetMutexOriginal());
			(void)n;
		}

		void Notify() {
			int n = pthread_cond_signal(&_cond);
			(void)n;
		}

		void NotifyAll() {
			int n = pthread_cond_broadcast(&_cond);
			(void)n;
		}

		~Cond() {
			int n = pthread_cond_destroy(&_cond);
			(void)n; // 酌情加⽇志,加判断
		}
	private:
		pthread_cond_t _cond;
	};
}

注意:

为了让条件变量更具有通用性,建议封装的时候,不要在Cond类内部引用对应的封装互斥量,要不然后面组合的时候,会因为代码耦合的问题难以初始化,因为一般而言Mutex和Cond基本是一起创建的。

2-9 POSIX信号量

POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。但POSIX可以用于线程间同步。

初始化信号量

cpp 复制代码
#include <semaphore.h>
int sem_init(sem_t* sem, int pshared, unsigned int value);
参数:
	pshared : 0表示线程间共享,非零表示进程间共享
	value:信号量初始值

销毁信号量

cpp 复制代码
int sem_destroy(sem_t *sem);

等待信号量

cpp 复制代码
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

发布信号量

cpp 复制代码
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()

上一节生产者-消费者的例子是基于queue的,其空间可以动态分配,现在基于固定大小的环形队列重写这个程序(POSIX信号量):

2-9-1 基于环形队列的生产消费模型

环形队列采用数组模拟,用模运算来模拟环状特性

环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态

但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程。

cpp 复制代码
#pragma once
#include <iostream>
#include <semaphore.h>
// 随⼿做⼀下封装
class Sem {
public:
	Sem(int n) { sem_init(&_sem, 0, n); }
	void P() { sem_wait(&_sem); }
	void V() { sem_post(&_sem); }
	~Sem() { sem_destroy(&_sem); }
private:
	sem_t _sem;
};

注意:

这里我们还是忍住,先进行原始接口的使用

先单生产,单消费,然后改成多生产,多消费。

关于任务,cond处已经介绍,这里就不再重复了。

单生产单消费

RingQueue.hpp

cpp 复制代码
//单生产单消费
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
#include <pthread.h>

const static int defaultcap = 5;

template<class T>
class RingQueue {
private:
    void P(sem_t& sem) { sem_wait(&sem); } //申请资源(资源--)
    void V(sem_t& sem) { sem_post(&sem); } //释放资源(资源++)
public:
    RingQueue(int cap = defaultcap)
        :ringqueue_(cap), cap_(cap), c_step_(0), p_step_(0) {
        sem_init(&cdata_sem_, 0, 0);
        sem_init(&pspace_sem_, 0, cap);
    }

    void Push(const T& in) {
        P(pspace_sem_);
        ringqueue_[p_step_] = in;
        V(cdata_sem_);

        p_step_++;
        p_step_ %= cap_;
    }

    void Pop(T* out) {
        P(cdata_sem_);
        *out = ringqueue_[c_step_];
        V(pspace_sem_);

        c_step_++;
        c_step_ %= cap_;
    }

    ~RingQueue() {
        sem_destroy(&cdata_sem_);
        sem_destroy(&pspace_sem_);
    }
private:
    vector<T> ringqueue_;
    int cap_;
    int c_step_; //消费者下标
    int p_step_; //生产者下标
    sem_t cdata_sem_; //消费者关注的数据资源
    sem_t pspace_sem_; //生产者关注的空间资源
};

Main.cpp

cpp 复制代码
//单生产单消费
void* Productor(void* args) {
    // sleep(3);
    RingQueue<int>* rq = static_cast<RingQueue<int>*>(args);
    while (true) {
        //1. 获取数据
        int data = rand() % 10 + 1;
        //2. 生产数据
        rq->Push(data);
        cout << "Productor data done, data is:" << data << endl;
        // sleep(1);
    }
    return nullptr;
}

void* Consumer(void* args) {
    RingQueue<int>* rq = static_cast<RingQueue<int>*>(args);
    while (true) {
        //1. 消费数据
        int data = 0;
        rq->Pop(&data);
        cout << "Consumer get data, data is:" << data << endl;
        sleep(1);

        //2. 处理数据
        //TODO
    }
    return nullptr;
}

int main() {
    srand(time(nullptr));
    RingQueue<int>* rq = new RingQueue<int>();    
    pthread_t c, p;
    pthread_create(&p, nullptr, Productor, rq);
    pthread_create(&c, nullptr, Consumer, rq);

    pthread_join(p, nullptr);
    pthread_join(c, nullptr);

    return 0;
}

多生产多消费

Task.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
using namespace std;

string opers = "+-*/%";

enum {
    DivZero = 1,
    ModZero,
    Unknown
};

class Task {
public:
    Task() {}
    Task(int x, int y, char op) 
        :data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0) {}

    void run() {
        switch (oper_)
        {
        case '+':
            result_ = data1_ + data2_;
            break;
        case '-':
            result_ = data1_ - data2_;
            break;
        case '*':
            result_ = data1_ * data2_;
            break;
        case '/':
            {
                if (data2_ == 0) exitcode_ = DivZero;
                else result_ = data1_ / data2_;
            }     
            break;
        case '%':
            {
                if (data2_ == 0) exitcode_ = ModZero;
                else result_ = data1_ % data2_;
            }
            break;
        default:
            exitcode_ = Unknown;
            break;
        }
    }

    void operator()() { run(); }

    string GetResult() {
        string r = to_string(data1_);
        r += oper_;
        r += to_string(data2_);
        r += "=";
        r += to_string(result_);
        r += "[code:";
        r += to_string(exitcode_);
        r += "]";
        return r;
    }

    string GetTask() {
        string r = to_string(data1_);
        r += oper_;
        r += to_string(data2_);
        r += "=?";
        return r;       
    }

    ~Task() {}
private:
    int data1_;
    int data2_;
    char oper_;
    int result_;
    int exitcode_;
};

RingQueue.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
#include <pthread.h>

using namespace std;

//多生产多消费
const static int defaultcap = 5;

template<class T>
class RingQueue {
private:
    void P(sem_t& sem) { sem_wait(&sem); } //申请资源(资源--)
    void V(sem_t& sem) { sem_post(&sem); } //释放资源(资源++)
    void Lock(pthread_mutex_t& mutex) { pthread_mutex_lock(&mutex); }
    void Unlock(pthread_mutex_t& mutex) { pthread_mutex_unlock(&mutex); }
public:
    RingQueue(int cap = defaultcap)
        :ringqueue_(cap), cap_(cap), c_step_(0), p_step_(0) {
        sem_init(&cdata_sem_, 0, 0);
        sem_init(&pspace_sem_, 0, cap);
        pthread_mutex_init(&c_mutex_, nullptr);
        pthread_mutex_init(&p_mutex_, nullptr);
    }

    void Push(const T& in) {
        //加锁在申请信号量之前还是之后?之后
        // 技术角度:
        // 信号量资源不需要被保护,它的申请是原子的。
        // 临界区的代码尽可能少。
        // 逻辑角度:
        // 先申请锁,在申请信号量。时间是串行的 。多线程竞争锁,有一个成功后,其他线程就只能等。
        // 先申请信号量,在申请锁,时间是并行的。一个线程在申请到锁进入临界区时,其他线程可以去申请信号量,不用等。

        P(pspace_sem_);
        Lock(p_mutex_);
        ringqueue_[p_step_] = in;
        p_step_++;
        p_step_ %= cap_;
        Unlock(p_mutex_);
        V(cdata_sem_);
    }

    void Pop(T* out) {
        P(cdata_sem_);
        Lock(c_mutex_);
        *out = ringqueue_[c_step_];
        c_step_++;
        c_step_ %= cap_;
        Unlock(c_mutex_);
        V(pspace_sem_);
    }

    ~RingQueue() {
        sem_destroy(&cdata_sem_);
        sem_destroy(&pspace_sem_);
        pthread_mutex_destroy(&c_mutex_);
        pthread_mutex_destroy(&p_mutex_);
    }
private:
    vector<T> ringqueue_;
    int cap_;
    int c_step_; //消费者下标
    int p_step_; //生产者下标
    sem_t cdata_sem_; //消费者关注的数据资源
    sem_t pspace_sem_; //生产者关注的空间资源
    pthread_mutex_t c_mutex_;
    pthread_mutex_t p_mutex_;
};

Main.cpp

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <ctime>
#include "RingQueue.hpp"
#include "Task.hpp"

using namespace std;

//多生产多消费
struct ThreadData {
    RingQueue<Task>* rq;
    string threadname;
};

void* Productor(void* args) {
    // sleep(3);
    ThreadData* td = static_cast<ThreadData*>(args);
    RingQueue<Task>* rq = td->rq;
    string name = td->threadname;
    int len = opers.size();
    while (true) {
        //1. 获取数据
        int data1 = rand() % 10 + 1;
        usleep(10);
        int data2 = rand() % 10;
        char op = opers[rand() % len];
        Task t(data1, data2, op);
        //2. 生产数据
        rq->Push(t);
        cout << "Productor task done, task is:" << t.GetTask() << ", who:" << name << endl;
        // sleep(1);
    }
    return nullptr;
}

void* Consumer(void* args) {
    ThreadData* td = static_cast<ThreadData*>(args);
    RingQueue<Task>* rq = td->rq;
    string name = td->threadname;
    while (true) {
        //1. 消费数据
        Task t;
        rq->Pop(&t);

        //2. 处理数据
        t();
        cout << "Consumer get task, task is:" << t.GetTask() << ", who:" << name << ", result:" << t.GetResult() << endl;
        sleep(1);
        //TODO
    }
    return nullptr;
}

int main() {
    srand(time(nullptr));
    RingQueue<Task>* rq = new RingQueue<Task>();    
    pthread_t c[5], p[3];

    for (int i = 0; i < 3; i++) {
        ThreadData* td = new ThreadData;
        td->rq = rq;
        td->threadname = "Productor-" + to_string(i);
        pthread_create(p+i, nullptr, Productor, td);
    }
    for (int i = 0; i < 5; i++) {
        ThreadData* td = new ThreadData;
        td->rq = rq;
        td->threadname = "Consumer-" + to_string(i);
        pthread_create(c+i, nullptr, Consumer, td);
    }

    for (int i = 0; i < 3; i++) pthread_join(p[i], nullptr);
    for (int i = 0; i < 5; i++) pthread_join(c[i], nullptr);
    

    return 0;
}

第三章:线程池

3-1 策略模式

什么是设计模式

IT行业这么火,涌入的人很多。俗话说林子大了啥鸟都有。大佬和菜鸡们两极分化的越来越严重。为了让菜鸡们不太拖大佬的后腿,于是大佬们针对一些经典的常见的场景,给定了一些对应的解决方案,这个就是设计模式。

3-2 线程池设计

线程池:

一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

线程池的应用场景:

  • 需要大量的线程来完成任务,且完成任务的时间比较短。 比如WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
  • 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
  • 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。

线程池的种类

a. 创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执行任务对象中的任务接口

b. 浮动线程池,其他同上

此处,我们选择固定线程个数的线程池。

Task.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
using namespace std;

string opers = "+-*/%";

enum {
    DivZero = 1,
    ModZero,
    Unknown
};

class Task {
public:
    Task() {}
    Task(int x, int y, char op) 
        :data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0) {}

    void run() {
        switch (oper_)
        {
        case '+':
            result_ = data1_ + data2_;
            break;
        case '-':
            result_ = data1_ - data2_;
            break;
        case '*':
            result_ = data1_ * data2_;
            break;
        case '/':
            {
                if (data2_ == 0) exitcode_ = DivZero;
                else result_ = data1_ / data2_;
            }     
            break;
        case '%':
            {
                if (data2_ == 0) exitcode_ = ModZero;
                else result_ = data1_ % data2_;
            }
            break;
        default:
            exitcode_ = Unknown;
            break;
        }
    }

    void operator()() { run(); }

    string GetResult() {
        string r = to_string(data1_);
        r += oper_;
        r += to_string(data2_);
        r += "=";
        r += to_string(result_);
        r += "[code:";
        r += to_string(exitcode_);
        r += "]";
        return r;
    }

    string GetTask() {
        string r = to_string(data1_);
        r += oper_;
        r += to_string(data2_);
        r += "=?";
        return r;       
    }

    ~Task() {}
private:
    int data1_;
    int data2_;
    char oper_;
    int result_;
    int exitcode_;
};

ThreadPool.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>

using namespace std;

struct ThreadInfo { //封装线程ID和名字
    pthread_t tid;
    string name;
};

static const int defaultnum = 5;

template <class T>
class ThreadPool {
public:
    void Lock() { pthread_mutex_lock(&mutex_); }
    void Unlock() { pthread_mutex_unlock(&mutex_); }
    void Weakup() { pthread_cond_signal(&cond_); }
    void ThreadSleep() { pthread_cond_wait(&cond_, &mutex_); }
    bool IsQueueEmpty() { return tasks_.empty(); }
    string GetThreadName(pthread_t tid) {
        for (const auto& ti : threads_)
            if (ti.tid == tid)
                return ti.name;
        return "None";
    }
public:
    ThreadPool(int num = defaultnum)
        :threads_(num) {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&cond_, nullptr);
    }

    //如果将线程执行函数放在类内,那么类的成员函数还会有个隐藏的this指针参数。所以参数个数不对
    // void* HandlerTask(void* args) { 
    
    //加static就没有this指针参数(但无法访类的非静态成员)
    static void* HandlerTask(void* args) { 
    ThreadPool<T>* tp = static_cast<ThreadPool<T>*>(args);
    string name = tp->GetThreadName(pthread_self());
        while (true) {
            tp->Lock();
            while (tp->IsQueueEmpty()) {
                tp->ThreadSleep();
            }
            T t = tp->Pop();
            tp->Unlock();
            t();
            cout << name << " run, result: " << t.GetResult() << endl;
        }
    }

    void Start() {
        int num = threads_.size();
        for (int i = 0; i < num; i++) {
            threads_[i].name = "thread-" + to_string(i+1);
            //编译不通过,因为HandlerTask函数是静态成员函数
            // pthread_create(&threads_[i].tid, nullptr, HandlerTask, nullptr);

            //传当前线程池对象(ThreadPool实例)的地址,有this指针可以访问类的成员
            pthread_create(&threads_[i].tid, nullptr, HandlerTask, this);
        }
    }

    T Pop() {
        T t = tasks_.front();
        tasks_.pop();
        return t;
    }

    //如果先解锁再唤醒。可能没有现成在等导致信号"丢失"。那线程就可能永远沉睡,再也没人通知它有任务来
    void Push(const T& t) {
        Lock();
        tasks_.push(t);
        Weakup();
        Unlock();
    }

    ~ThreadPool() {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&cond_);
    }
private:
    vector<ThreadInfo> threads_;//将封装的线程结构体放入vector
    queue<T> tasks_; //任务队列
    pthread_mutex_t mutex_;
    pthread_cond_t cond_;
};

Main.cpp

cpp 复制代码
#include <iostream>
#include <ctime>
#include "ThreadPool.hpp"
#include "Task.hpp"

using namespace std;

int main() {
    ThreadPool<Task>* tp = new ThreadPool<Task>(5);
    tp->Start();
    srand(time(nullptr));
    while (true) {
        //1. 构建任务
        int x = rand() % 10 + 1;
        usleep(10);
        int y = rand() % 5;
        char op = opers[rand() % opers.size()];
        Task t(x, y, op);
        tp->Push(t);
        //2. 交给线程池处理
        cout << "main thread make task: " << t.GetTask() << endl;
        sleep(1);
    }
    
    return 0;
}

3-3 线程安全的单例模式

3-3-1 什么是单例模式

3-3-2 单例模式的特点

某些类,只应该具有一个对象(实例),就称之为单例。

例如一个男人只能有一个媳妇。

在很多服务器开发场景中,经常需要让服务器加载很多的数据 (上百G) 到内存中。此时往往要用一个单例的类来管理这些数据。

3-3-3 饿汉实现方式和懒汉实现方式

洗碗的例子

吃完饭,立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。

吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。

懒汉方式最核心的思想是"延时加载"。从而能够优化服务器的启动速度。

3-3-4 饿汉方式实现单例模式

cpp 复制代码
template <typename T>
class Singleton {
	static T data;
public:
	static T* GetInstance() { return &data; }
};

只要通过 Singleton 这个包装类来使用 T 对象,则一个进程中只有一个 T 对象的实例。

3-3-5 懒汉方式实现单例模式

cpp 复制代码
template <typename T>
class Singleton {
	static T* inst;
public:
	static T* GetInstance() {
		if (inst == NULL) 
			inst = new T();	
		return inst;
	}
};

存在一个严重的问题,线程不安全。

第一次调用 GetInstance 的时候,如果两个线程同时调用,可能会创建出两份 T 对象的实例。

但是后续再次调用,就没有问题了。

3-3-6 懒汉方式实现单例模式(线程安全版本)

cpp 复制代码
// 懒汉模式, 线程安全
template <typename T>
class Singleton {
	volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
	static std::mutex lock;
public:
	static T* GetInstance() {
		if (inst == NULL) { // 双重判定空指针, 降低锁冲突的概率, 提⾼性能.
			lock.lock(); // 使⽤互斥锁, 保证多线程情况下也只调⽤⼀次 new.
			if (inst == NULL) 
				inst = new T();
			lock.unlock();
		}
		return inst;
	}
};

注意事项:

  1. 加锁解锁的位置
  2. 双重 if 判定, 避免不必要的锁竞争
  3. volatile关键字防止过度优化

3-4 单例式线程池

Task.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
using namespace std;

string opers = "+-*/%";

enum {
    DivZero = 1,
    ModZero,
    Unknown
};

class Task {
public:
    Task() {}
    Task(int x, int y, char op) 
        :data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0) {}

    void run() {
        switch (oper_)
        {
        case '+':
            result_ = data1_ + data2_;
            break;
        case '-':
            result_ = data1_ - data2_;
            break;
        case '*':
            result_ = data1_ * data2_;
            break;
        case '/':
            {
                if (data2_ == 0) exitcode_ = DivZero;
                else result_ = data1_ / data2_;
            }     
            break;
        case '%':
            {
                if (data2_ == 0) exitcode_ = ModZero;
                else result_ = data1_ % data2_;
            }
            break;
        default:
            exitcode_ = Unknown;
            break;
        }
    }

    void operator()() { run(); }

    string GetResult() {
        string r = to_string(data1_);
        r += oper_;
        r += to_string(data2_);
        r += "=";
        r += to_string(result_);
        r += "[code:";
        r += to_string(exitcode_);
        r += "]";
        return r;
    }

    string GetTask() {
        string r = to_string(data1_);
        r += oper_;
        r += to_string(data2_);
        r += "=?";
        return r;       
    }

    ~Task() {}
private:
    int data1_;
    int data2_;
    char oper_;
    int result_;
    int exitcode_;
};

ThreadPool.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>

using namespace std;

struct ThreadInfo { //封装线程ID和名字
    pthread_t tid;
    string name;
};

static const int defaultnum = 5;

template <class T>
class ThreadPool {
public:
    void Lock() { pthread_mutex_lock(&mutex_); }
    void Unlock() { pthread_mutex_unlock(&mutex_); }
    void Weakup() { pthread_cond_signal(&cond_); }
    void ThreadSleep() { pthread_cond_wait(&cond_, &mutex_); }
    bool IsQueueEmpty() { return tasks_.empty(); }
    string GetThreadName(pthread_t tid) {
        for (const auto& ti : threads_)
            if (ti.tid == tid)
                return ti.name;
        return "None";
    }
public:

    //如果将线程执行函数放在类内,那么类的成员函数还会有个隐藏的this指针参数。所以参数个数不对
    // void* HandlerTask(void* args) { 
    static void* HandlerTask(void* args) { //加static就没有this指针参数
    ThreadPool<T>* tp = static_cast<ThreadPool<T>*>(args);
    string name = tp->GetThreadName(pthread_self());
        while (true) {
            tp->Lock();
            while (tp->IsQueueEmpty()) {
                tp->ThreadSleep();
            }
            T t = tp->Pop();
            tp->Unlock();
            t();
            cout << name << " run, result: " << t.GetResult() << endl;
        }
    }

    void Start() {
        int num = threads_.size();
        for (int i = 0; i < num; i++) {
            threads_[i].name = "thread-" + to_string(i+1);
            // pthread_create(&threads_[i].tid, nullptr, HandlerTask, nullptr);//编译不通过,因为HandlerTask函数是静态成员函数
            pthread_create(&threads_[i].tid, nullptr, HandlerTask, this);//传当前线程池对象(ThreadPool实例)的地址
        }
    }

    T Pop() {
        T t = tasks_.front();
        tasks_.pop();
        return t;
    }

    //如果先解锁再唤醒。可能没有现成在等导致信号"丢失"。那线程就可能永远沉睡,再也没人通知它有任务来
    void Push(const T& t) {
        Lock();
        tasks_.push(t);
        Weakup();
        Unlock();
    }
    // //只在第一次创建单例对象时有并发问题,需要保护。
    // //如果对申请单例加锁,那么往后的线程都要经历申请锁->判断->释放锁。(即后续线程都要串行申请单例)
    // static ThreadPool<T>* GetInstance() {
    //     pthread_mutex_lock(&lock_);
    //     if (nullptr == tp_) {
    //         cout << "log: singleton create done first!" << endl;
    //         tp_ = new ThreadPool<T>;
    //     }
    //     pthread_mutex_unlock(&lock_);
    //     return tp_;
    // }
    static ThreadPool<T>* GetInstance() {
        // 外层检查:如果单例已存在,无需加锁,直接返回,减少锁竞争
        if (nullptr == tp_) { //开关,可以并发获取单例(第一次检查:避免已创建后仍频繁加锁,提高性能)
            pthread_mutex_lock(&lock_);
            if (nullptr == tp_) { //判断是否是单例
                cout << "log: singleton create done first!" << endl;
                tp_ = new ThreadPool<T>;
            }
            pthread_mutex_unlock(&lock_);
        }
        return tp_;
    }
private:
    ThreadPool(int num = defaultnum)
        :threads_(num) {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&cond_, nullptr);
    } 
    ~ThreadPool() {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&cond_);
    }
    ThreadPool(const ThreadPool<T>&) = delete; 
    ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;  
private:
    vector<ThreadInfo> threads_;//将封装的线程结构体放入vector
    queue<T> tasks_; //任务队列
    pthread_mutex_t mutex_;
    pthread_cond_t cond_;
    //第一次调用 GetInstance() 时才创建线程池对象
    //以后所有调用都返回同一份指针 tp_
    static ThreadPool<T>* tp_;//"单例模式"的核心静态对象指针,用于在整个程序运行期间唯一地存储线程池实例。
    static pthread_mutex_t lock_;//获取单例时需要加锁
};
template <class T>
ThreadPool<T>* ThreadPool<T>::tp_ = nullptr;
template <class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;

Main.cpp

cpp 复制代码
#include <iostream>
#include <ctime>
#include "ThreadPool.hpp"
#include "Task.hpp"

using namespace std;

pthread_spinlock_t slock;//自旋锁

int main() {
    // pthread_spin_init(&slock, 0);
    // pthread_spin_destroy(&slock);
    //如果获取单例对象的时候,也是多线程获取呢?
    cout << "process runnig..." << endl;
    sleep(2);
    ThreadPool<Task>::GetInstance()->Start();
    srand(time(nullptr));
    while (true) {
        //1. 构建任务
        int x = rand() % 10 + 1;
        usleep(10);
        int y = rand() % 5;
        char op = opers[rand() % opers.size()];
        Task t(x, y, op);
        ThreadPool<Task>::GetInstance()->Push(t);
        //2. 交给线程池处理
        cout << "main thread make task: " << t.GetTask() << endl;
        sleep(1);
    }
    
    return 0;
}

第四章:线程安全和重入问题

概念

**线程安全:**就是多个线程在访问共享资源时,能够正确地执行,不会相互干扰或破坏彼此的执行结果。一般而言,多个线程并发同一段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进行操作,并且没有锁保护的情况下,容易出现该问题。

**重入:**同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

学到现在,其实我们已经能理解重入其实可以分为两种情况

  • 多线程重入函数
  • 信号导致一个执行流重复进入函数

常见线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 不可重入函数体内使用了静态的数据结构

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都由函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

结论

不要被上面绕口令式的话语唬住,你只要仔细观察,其实对应概念说的都是一回事。

可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的(其实知道这一句话就够了)
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

注意:

  • 如果不考虑信号导致一个执行流重复进入函数这种重入情况,线程安全和重入在安全角度不做区分
  • 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
  • 可重入描述的是一个函数是否能被重复进入,表示的是函数的特点

第五章:常见锁概念

5-1 死锁

  • 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用的不会释放的资源而处于的一种永久等待状态。
  • 为了方便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进行后续资源的访问

申请一把锁是原子的,但是申请两把锁就不一定了

造成的结果是

5-2 死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用

    • 好理解,不做解释
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放

  • 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

5-3 避免死锁

  • 破坏死锁的四个必要条件
    • 破坏循环等待条件问题:资源一次性分配,使用超时机制、加锁顺序一致
cpp 复制代码
// 下面的C++不写了,理解就可以
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <unistd.h>
// 定义两个共享资源(整数变量)和两个互斥锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;
// 一个函数,同时访问两个共享资源
void access_shared_resources() {
	// std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
	// std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
	// // 使用 std::lock 同时锁定两个互斥锁
	// std::lock(lock1, lock2);
	// 现在两个互斥锁都已锁定,可以安全地访问共享资源
	int cnt = 10000;
	while (cnt) {
		++shared_resource1;
		++shared_resource2;
		cnt--;
	}
	// 当离开 access_shared_resources 的作用域时,lock1 和 lock2 的析构函数会被自动调用
	// 这会导致它们各自的互斥量被自动解锁
}
// 模拟多线程同时访问共享资源的场景
void simulate_concurrent_access() {
	std::vector<std::thread> threads;
	// 创建多个线程来模拟并发访问
	for (int i = 0; i < 10; ++i) 
		threads.emplace_back(access_shared_resources);
	
	// 等待所有线程完成
	for (auto& thread : threads) 
		thread.join();
	
	// 输出共享资源的最终状态
	std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;
	std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}
int main() {
	simulate_concurrent_access();
	return 0;
}
bash 复制代码
$ ./a.out // 不⼀次申请
Shared Resource 1: 94416
Shared Resource 2: 94536

$ ./a.out // ⼀次申请
Shared Resource 1: 100000
Shared Resource 2: 100000
  • 避免锁未释放的场景

第六章:STL, 智能指针和线程安全

6-1 STL中的容器是否是线程安全的?

不是。
原因是,STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。
而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。
因此 STL 默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。

6-2 智能指针是否是线程安全的?

对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于 shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效、原子地操作引用计数。

第七章:读者写者问题与读写锁

读者写者问题

  • 重点是 是什么

读者写者 vs 生产消费

  • 重点是有什么区别

读者写者问题如何理解

  • 重点理解读者和写者如何完成同步

下面是一段伪代码,帮助我们理解读者写者的逻辑

公共部分

cpp 复制代码
uint32_t reader_count = 0;
lock_t count_lock;
lock_t writer_lock;

Reader

cpp 复制代码
// 加锁
lock(count_lock);
if (reader_count == 0)
lock(writer_lock);
++reader_count;
unlock(count_lock);
// read;
//解锁
lock(count_lock);
--reader_count;

Writer

cpp 复制代码
lock(writer_lock);
// write
unlock(writer_lock);

读写锁

在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高得多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢?

有,那就是读写锁。

注意:写独占,读共享,读锁优先级高

读写锁接口

设置读写优先

cpp 复制代码
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t* attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,⽬前有 BUG,导致表现⾏为和
PTHREAD_RWLOCK_PREFER_READER_NP ⼀致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/

初始化

cpp 复制代码
int pthread_rwlock_init(pthread_rwlock_t* restrict rwlock, const
	pthread_rwlockattr_t* restrict attr);

销毁

cpp 复制代码
int pthread_rwlock_destroy(pthread_rwlock_t* rwlock);

加锁和解锁

cpp 复制代码
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);

读写锁案例:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <cstdlib>
#include <ctime>
// 共享资源
int shared_data = 0;
// 读写锁
pthread_rwlock_t rwlock;
// 读者线程函数
void* Reader(void* arg) {
	//sleep(1); //读者优先,⼀旦读者进⼊&&读者很多,写者基本就很难进⼊了
	int number = *(int*)arg;
	while (true) {
		pthread_rwlock_rdlock(&rwlock); // 读者加锁
		std::cout << "读者-" << number << " 正在读取数据, 数据是: " <<
			shared_data << std::endl;
		sleep(1); // 模拟读取操作
		pthread_rwlock_unlock(&rwlock); // 解锁
	}
	delete (int*)arg;
	return NULL;
}
// 写者线程函数
void* Writer(void* arg) {
	int number = *(int*)arg;
	while (true) {
		pthread_rwlock_wrlock(&rwlock); // 写者加锁
		shared_data = rand() % 100; // 修改共享数据
		std::cout << "写者- " << number << " 正在写⼊. 新的数据是: " <<
			shared_data << std::endl;
		sleep(2); // 模拟写⼊操作
		pthread_rwlock_unlock(&rwlock); // 解锁
	}
	delete (int*)arg;
	return NULL;
}
int main() {
	srand(time(nullptr) ^ getpid());
	pthread_rwlock_init(&rwlock, NULL); // 初始化读写锁
	// 可以更⾼读写数量配⽐,观察现象
	const int reader_num = 2;
	const int writer_num = 2;
	const int total = reader_num + writer_num;
	pthread_t threads[total]; // 假设读者和写者数量相等
	// 创建读者线程
	for (int i = 0; i < reader_num; ++i) {
		int* id = new int(i);
		pthread_create(&threads[i], NULL, Reader, id);
	}
	// 创建写者线程
	for (int i = reader_num; i < total; ++i) {
		int* id = new int(i - reader_num);
		pthread_create(&threads[i], NULL, Writer, id);
	}
	// 等待所有线程完成
	for (int i = 0; i < total; ++i) {
		pthread_join(threads[i], NULL);
	}
	pthread_rwlock_destroy(&rwlock); // 销毁读写锁
	return 0;
}

读者优先(Reader-Preference)

在这种策略中,系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据),而不会优先考虑写者。这意味着当有读者正在读取时,新到达的读者会立即被允许进入读取区,而写者则会被阻塞,直到所有读者都离开读取区。读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限),特别是当读者频繁到达时。

写者优先(Writer-Preference)

在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取。这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿(即读者长时间无法获得读取权限),特别是当写者频繁到达时。

第八章:自旋锁

概述

自旋锁是一种多线程同步机制,用于保护共享资源免受并发访问的影响。在多个线程尝试获取锁时,它们会持续自旋(即在一个循环中不断检查锁是否可用)而不是立即进入休眠状态等待锁的释放。这种机制减少了线程切换的开销,适用于短时间内锁的竞争情况。但是不合理的使用,可能会造成CPU的浪费。

原理

自旋锁通常使用一个共享的标志位(如一个布尔值)来表示锁的状态。当标志位为true时,表示锁已被某个线程占用;当标志位为false时,表示锁可用。当一个线程尝试获取自旋锁时,它会不断检查标志位:

  • 如果标志位为false,表示锁可用,线程将设置标志位为true,表示自己占用了锁,并进入临界区。
  • 如果标志位为true(即锁已被其他线程占用),线程会在一个循环中不断自旋等待,直到锁被释放。

优点与缺点

优点

  1. 低延迟:自旋锁适用于短时间内的锁竞争情况,因为它不会让线程进入休眠状态,从而避免了线程切换的开销,提高了锁操作的效率。
  2. 减少系统调度开销:等待锁的线程不会被阻塞,不需要上下文切换,从而减少了系统调度的开销。

缺点

  1. CPU资源浪费:如果锁的持有时间较长,等待获取锁的线程会一直循环等待,导致CPU资源的浪费。
  2. 可能引起活锁:当多个线程同时自旋等待同一个锁时,如果没有适当的退避策略,可能会导致所有线程都在不断检查锁状态而无法进入临界区,形成活锁。

使用场景

  1. 短暂等待的情况:适用于锁被占用时间很短的场景,如多线程对共享数据进行简单的读写操作。
  2. 多线程锁使用:通常用于系统底层,同步多个CPU对共享资源的访问。

纯软件自旋锁类似的原理实现

自旋锁的实现通常使用原子操作来保证操作的原子性,常用的软件实现方式是通过CAS(Compare-And-Swap)指令实现。以下是一个简单的自旋锁实现示例(伪代码):

cpp 复制代码
#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h>
#include <unistd.h>
// 使⽤原⼦标志来模拟⾃旋锁
atomic_flag spinlock = ATOMIC_FLAG_INIT; // ATOMIC_FLAG_INIT 是 0
// 尝试获取锁
void spinlock_lock() {
	while (atomic_flag_test_and_set(&spinlock)) {
		// 如果锁被占⽤,则忙等待
	}
}
// 释放锁
void spinlock_unlock() {
	atomic_flag_clear(&spinlock);
}
cpp 复制代码
typedef _Atomic struct {
#if __GCC_ATOMIC_TEST_AND_SET_TRUEVAL == 1
	_Bool __val;
#else
	unsigned char __val;
#endif
} atomic_flag;
  • 功能描述

atomic_flag_test_and_set 函数检查 atomic_flag 的当前状态。如果atomic_flag 之前没有被设置过(即其值为false或"未设置"状态),则函数会将其设置为true(或"设置"状态),并返回先前的值(在这种情况下为false)。如果atomic_flag 之前已经被设置过(即其值为true),则函数不会改变其状态,但会返回true。

  • 原子性

这个操作是原子的,意味着在多线程环境中,它保证了对 atomic_flag 的读取和修改是不可分割的。当一个线程调用此函数时,其他线程无法看到这个操作的任何中间状态,这确保了操作的线程安全性。

Linux提供的自旋锁系统调用

cpp 复制代码
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t* lock);
int pthread_spin_trylock(pthread_spinlock_t* lock);
int pthread_spin_unlock(pthread_spinlock_t* lock);
int pthread_spin_init(pthread_spinlock_t* lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t* lock);

注意事项

  • 在使用自旋锁时,需要确保锁被释放的时间尽可能短,以避免CPU资源的浪费。
  • 在多CPU环境下,自旋锁可能不如其他锁机制高效,因为它可能导致线程在不同的CPU上自旋等待。

结论

自旋锁是一种适用于短时间内锁竞争情况的同步机制,它通过减少线程切换的开销来提高锁操作的效率。然而,它也存在CPU资源浪费和可能引起活锁等缺点。在使用自旋锁时,需要根据具体的应用场景进行选择,并确保锁被释放的时间尽可能短。

样例代码

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 1000;
//pthread_spinlock_t lock;
void* route(void* arg) {
	char* id = (char*)arg;
	while (1) {
		//pthread_spin_lock(&lock);
		if (ticket > 0) {
			usleep(1000);
			printf("%s sells ticket:%d\n", id, ticket);
			ticket--;
			//pthread_spin_unlock(&lock);
		}
		else {
			//pthread_spin_unlock(&lock);
			break;
		}
	}
	return nullptr;
}
int main(void) {
	//pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE);
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, route, (void*)"thread 1");
	pthread_create(&t2, NULL, route, (void*)"thread 2");
	pthread_create(&t3, NULL, route, (void*)"thread 3");
	pthread_create(&t4, NULL, route, (void*)"thread 4");
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	//pthread_spin_destroy(&lock);
}

作业

1. 完成两个线程通过条件变量实现交替打印的控制

  1. 题目描述:线程A打印-我是线程A;线程B打印-我是线程B; 最终实现交替打印,不能出现连续的相同打印。
  2. 本题主要考察条件变量的基本使用流程
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

pthread_mutex_t mutex_ = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_ = PTHREAD_COND_INITIALIZER;
bool turnA = true; // true 表示轮到线程A打印

void* ThreadA(void* args) {
	while (true) {
		pthread_mutex_lock(&mutex_);

		while (turnA == false) {
			pthread_cond_wait(&cond_, &mutex_);
		}
		cout << "我是线程A" << endl;
		turnA = false;   // 打印完轮到 B
		pthread_cond_signal(&cond_);
		pthread_mutex_unlock(&mutex_);
		usleep(100000); // 为演示效果加的延时
	}
	return nullptr;
}

void* ThreadB(void* args) {
	while (true) {
		pthread_mutex_lock(&mutex_);
		while (turnA == true) {
			pthread_cond_wait(&cond_, &mutex_);
		}
		cout << "我是线程B" << endl;
		turnA = true;    // 打印完轮到 A
		pthread_cond_signal(&cond_);
		pthread_mutex_unlock(&mutex_);
		usleep(100000);
	}
	return nullptr;
}

int main() {
	pthread_t tA, tB;
	pthread_create(&tA, nullptr, ThreadA, nullptr);
	pthread_create(&tB, nullptr, ThreadB, nullptr);

	pthread_join(tA, nullptr);
	pthread_join(tB, nullptr);

	pthread_mutex_destroy(&mutex_);
	pthread_cond_destroy(&cond_);

	return 0;
}

2. 请简述什么是线程同步,为什么需要同步

线程同步指的是线程间对数据资源进行获取,有可能在不满足访问资源条件的情况下访问资源而造成程序逻辑混乱,因此通过进行条件判断来决定线程在不能访问资源时休眠等待或满足资源后唤醒等待的线程的方式实现对资源访问的合理性。

3. 关于条件变量以下描述正确的有 [多选]

A.条件变量可以单独使用

B.条件变量需要搭配互斥锁使用

C.条件变量在等待被唤醒时需要重新对条件进行判断,是否条件满足

D.在生产者与消费者模型中只需要一个条件变量就可以

答案:BC

A错误 条件变量进行同步的条件判断由外部的共享资源条件判断实现,因此需要搭配互斥锁使用

B正确

C正确 条件变量的控制判断需要使用循环进行,避免在多个线程同时被唤醒的情况下,A线程加锁成功访问资源,其他线程卡在锁处,而A线程一旦解锁,其他线程抢到锁在资源访问条件不满足的情况下访问资源,因此被唤醒后加锁成功则需要重新进行判断,条件满足则访问,不满足则需要重新陷入休眠。

D错误 条件变量的使用中不同的角色需要等待在不同的条件变量等待队列中,防止角色误唤醒,比如生产者唤醒生产者的情况,因此需要分开等待,分开唤醒

4. 信号量实现与条件变量有什么区别 [多选]

A.信号量既可以实现同步还可以实现互斥

B.条件变量既可以实现同步还可以实现互斥

C.条件变量需要搭配互斥锁使用,信号量不需要

D.信号量需要搭配互斥锁使用,条件变量不需要

答案:AC

  • 条件变量提供了一个pcb阻塞队列以及阻塞和唤醒线程的接口用于实现同步,但是什么时候该唤醒以及什么时候该阻塞线程由程序员进行控制,而这个控制通常需要一个共享资源的条件判断完成,因此条件变量还需要搭配互斥锁使用,来保护这个共享资源的条件判断及操作。
  • 信号量提供一个pcb等待队列,以及一个实现了原子操作的对资源进行计数的计数器,通过自身计数器实现同步的条件判断,因此不需要搭配互斥锁使用,而且信号量在初始化计数为1的情况下也可以模拟实现互斥操作。

基于以上理解,正确选项为A和C选项。

5. 以下哪几种方式可用来实现线程间通知和唤醒:( ) [多选]

A.互斥锁

B.条件变量

C.信号量

D.读写锁

答案:BC

线程间的通知和唤醒以及线程的等待这是线程间同步实现的基础,而信号量和条件变量通过提供的使线程等待和唤醒功能被用于实现线程间的同步,因此选择B和C

而A中的互斥锁,和D中的读写锁都是为了实现对共享资源安全访问操作的锁技术,并不包含有通知和唤醒线程的功能。

6. 下面有关进程间同步的几种方式的区别,描述正确的是? [多选]

A.因为使用互斥不仅仅能够在同一应用程序不同线程中实现资源的安全共享,而且可以在不同应用程序的线程之间实现对资源的安全共享。

B.任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么 在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开

C.信号量允许多个线程同时使用共享资源

D.如果只为了在进程内部使用的话使用临界区会带来速度上的优势并能够减少资源占用量

答案:ABCD

A正确,与谁能互斥主要取决于互斥锁共享于哪些执行

流之间,如果互斥锁为进程内的资源,则可以实现同一程序内的不同线程间互斥,而如果将共享内存作为互斥锁进行操作则可以实现不同进程之间的互斥

B正确,临界资源的访问操作需要加锁保护,不能同时访问,否则有可能会造成数据二义,因此如果有多个线程试图同时访问,则必然需要通过互斥的方式保证同一时间只有一个执行流能访问,其他线程阻塞

C正确,信号量主要用于实现同步操作,只要资源数大于0就表示可获取,可访问,因此上课时讲到若要使用信号量模拟实现互斥,则需要初始化资源计数为1,表示资源只有一个,则只有一个执行流能访问

D正确,我们上课说的临界区指的是访问临界资源的代码片段,而这里说的临界区指的是其他平台或语言中的一种锁技术,实现串行化来访问共享资源的代码片段。速度比较快但是只能用于同一进程的线程间

7. 我们提供了一个类:(编程题)

cpp 复制代码
public class Foo {
	public void one() { print("one"); }
	public void two() { print("two"); }
	public void three() { print("three"); }
}

三个不同的线程将会共用一个 Foo 实例。

线程 A 将会调用 one() 方法

线程 B 将会调用 two() 方法

线程 C 将会调用 three() 方法

请设计修改程序,以确保 two() 方法在 one() 方法之后被执行,three() 方法在 two() 方法之后被执行。

示例 1:

输入: [1, 2, 3]

输出 : "onetwothree"

解释 :

有三个线程会被异步启动。

输入[1, 2, 3] 表示线程 A 将会调用 one() 方法,线程 B 将会调用 two() 方法,线程 C 将会调用three() 方法。

正确的输出是 "onetwothree"。

答案:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

class Foo {
public:
    Foo() : state(1) {
        pthread_mutex_init(&mutex, nullptr);
        pthread_cond_init(&cond, nullptr);
    }

    ~Foo() {
        pthread_mutex_destroy(&mutex);
        pthread_cond_destroy(&cond);
    }

    void one() {
        pthread_mutex_lock(&mutex);
        while (state != 1)
            pthread_cond_wait(&cond, &mutex);

        print("one");
        state = 2;
        pthread_cond_broadcast(&cond);
        pthread_mutex_unlock(&mutex);
    }

    void two() {
        pthread_mutex_lock(&mutex);
        while (state != 2)
            pthread_cond_wait(&cond, &mutex);

        print("two");
        state = 3;
        pthread_cond_broadcast(&cond);
        pthread_mutex_unlock(&mutex);
    }

    void three() {
        pthread_mutex_lock(&mutex);
        while (state != 3)
            pthread_cond_wait(&cond, &mutex);

        print("three");
        pthread_mutex_unlock(&mutex);
    }

    static void print(const string& s) { cout << s; }

private:
    int state;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
};

void* threadOne(void* args) {
    Foo* foo = static_cast<Foo*>(args);
    foo->one();
    return nullptr;
}

void* threadTwo(void* args) {
    Foo* foo = static_cast<Foo*>(args);
    foo->two();
    return nullptr;
}

void* threadThree(void* args) {
    Foo* foo = static_cast<Foo*>(args);
    foo->three();
    return nullptr;
}


int main() {
    Foo foo;

    pthread_t t1, t2, t3;

    pthread_create(&t1, nullptr, threadOne, &foo);
    pthread_create(&t2, nullptr, threadTwo, &foo);
    pthread_create(&t3, nullptr, threadThree, &foo);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    cout << endl;
    return 0;
}

8. 请简述线程安全概念与实现(简答题)

线程安全指的是在多线程编程中,多个线程对临界资源进行争抢访问而不会造成数据二义或程序逻辑混乱的情况。

线程安全的实现,通过同步与互斥实现

具体同步的实现可以通过互斥锁和信号量实现、而同步可以通过条件变量与信号量实现。

9. 关于函数的重入于不可重入描述正确的是:

A.线程是安全的则线程中调用的函数一定是可重入函数

B.常见可重入的情况包括:不使用全局变量或静态变量,但是可以调用malloc/free等函数

C.函数可重入只是线程安全的一个要素

D.函数线程安全只是函数可重入的一个要素

答案:C

A错误 线程安全指的是当前线程中对各项操作时安全的,但不表示内部调用的函数是安全的,两个之间并没有必然关系

B错误 调用了malloc / free函数,因为malloc函数是用全局链表来管理堆的,所以是不可重入的。

C正确 线程中不仅仅会调用函数,有可能本身内部就进行了临界资源的操作,所以线程内调用的函数可重入只是线程安全的一个要素

D错误 一个函数一旦是线程安全的,则表示在多个线程内重入不会引发意外问题,因此也是可重入的

10. 已知如下代码,并在两个线程中同时执行f1和f2,待两个函数都返回后,a的所有可能值是哪些?[多选]

cpp 复制代码
int a = 2, b = 0, c = 0
void f1() {
    b = a * 2;
    a = b;
}
void f2() {
    c = a + 11;
    a = c;
}

A.4

B.13

C.15

D.26

答案:ABCD

因为a,b,c变量是全局变量,因此在不同的线程中调用f1和f2函数有可能会出现竞态执行同时对三个变量进行操作的情况

  • A正确 存在f1函数被执行 b = a * 2后b等于4, 然后时间片轮转到f2函数被执行完毕,时间片轮转到 f1中的a = b,则这时候a的值就可能是最后的赋值为4
  • B正确 跟上边类似,有可能f2韩式中的 c = a + 11,也就是c = 13执行完后, 时间片轮转到f1函数的执行,等到执行完毕后,轮转回来执行 a = c则最终赋值为13
  • C正确 两个函数串行执行,执行完f1之后a是4, 然后执行f2函数,c = a + 11 = 15, 然后a = c则赋值为15
  • D正确 先执行了f2函数,则a = 13, 然后时间片轮转到 f1函数的执行, b = a * 2, a = b则最后赋值为26

11. 死锁的处理都有哪些方法?[多选]

A.鸵鸟策略

B.预防策略

C.避免策略

D.检测与解除死锁

答案:ABCD

A 鸵鸟策略 对可能出现的问题采取无视态度,前提是出现概率很低

B 预防策略 破坏死锁产生的必要条件

C 避免策略 银行家算法,分配资源前进行风险判断,避免风险的发生

D 检测与解除死锁 分配资源时不采取措施,但是必须提供死锁的检测与解除手段

12. 关于死锁的说法正确的有?[多选]

A.竞争可剥夺资源会产生死锁

B.竞争临时资源有可能会产生死锁

C.在发生死锁时,必然存在一个进程---资源的环形链

D.如果进程在一次性申请其所需的全部资源成功后才运行,就不会发生死锁。

答案:BCD

死锁产生的必要条件:互斥,不可剥夺,请求与保持,环路等待

A 破坏了不可剥夺条件,因此不会产生死锁

B 这里的临时资源指的是(硬件中断,信号,消息...等),通常顺序不定,因此有可能会产生死锁

C 环形链也即是环路等待,这是死锁的必要条件

D 资源一次性分配,也就不存在请求与保持的情况以及环路等待情况了

根据以上理解,正确选项为:BCD

13. 在操作系统中,下列有关死锁的说法正确的是()[多选]

A.采用"按序分配"策略可以尽可能的破坏产生死锁的环路等待条件

B.产生死锁的现象是每个进程等待某一个不能得到且不可释放的资源

C.在资源动态分配过程中,防止系统进入安全状态,可避免发生死锁

D.银行家算法是最有代表性的死锁解除算法

答案:AB

A正确 环路等待条件的产生,很大原因是因为所资源分配顺序不一致导致的

B正确 死锁的产生就是因为程序因为获取资源而不可得的情况下程序卡死的情况

C错误 防止系统进入不安全状态可以避免发生死锁

D错误 银行家算法是最有代表性的死锁避免而并非解除算法

14. 下列关于银行家算法的叙述中,正确的是()

A.银行家算法可以预防死锁

B.当系统处于安全状态时, 系统中一定无死锁进程

C.当系统处于不安全状态时, 系统中一定会出现死锁进程

D.银行家算法破坏了死锁必要条件中的" 请求和保持" 条件

答案:A

银行家算法的思想在于将系统运行分为两种状态:安全 / 非安全,有可能出现风险的都属于非安全

A 银行家算法是避免出现死锁的一种算法(并非预防的方法)

C 处于不安全状态只是表示有风险,不代表一定发生

D 银行家算法的思想是为了避免出现"环路等待"条件

15. 下面哪些是死锁发生的必要条件?[多选]

A.互斥条件

B.请求和保持

C.不可剥夺

D.循环等待

答案:ABCD

死锁产生的四个必要条件;互斥条件,不可剥夺条件,请求与保持条件,环路等待条件

基于以上理解,正确选项为:ABCD

16. 请简述线程池的作用与实现原理(简答题)

线程池通过一个线程安全的阻塞任务队列加上一个或一个以上的线程实现,线程池中的线程可以从阻塞队列中获取任务进行任务处理,当线程都处于繁忙状态时可以将任务加入阻塞队列中,等到其它的线程空闲后进行处理。

可以避免大量线程频繁创建或销毁所带来的时间成本,也可以避免在峰值压力下,系统资源耗尽的风险;并且可以统一对线程池中的线程进行管理,调度监控。

17. 以下线程池的关键参数有哪些?[多选]

A.线程池中线程最大数量

B.线程安全的阻塞队列

C.线程池中线程的存活时间

D.线程池中阻塞队列的最大节点数量

答案:ABCD

A 防止资源耗尽,或线程过多性能降低

B 用于任务排队缓冲

C 长时间空闲则退出线程节省资源

D 防止任务过多,资源耗尽

18. 线程池都有什么作用?[多选]

A.降低资源消耗:通过重用已经创建的线程来降低线程创建和销毁的消耗

B.提高线程的可管理性:线程池可以统一管理、分配、调优和监控

C.程序性能更优:创建的线程越多性能越高

D.降低程序的耦合程度: 提高程序的运行效率

答案:ABD

多线程程序的运行效率, 是一个正态分布的结果, 线程数量从1开始增加, 随着线程数量的增加, 程序的运行效率逐渐变高, 直到线程数量达到一个临界值, 当在增加线程数量时, 程序的运行效率会减小(主要是由于频繁线程切换影响线程运行效率)

因此C选项错误。

A正确,线程池中更多是对已经创建的线程循环利用,因此节省了新的线程的创建与销毁的时间成本

B正确,线程池是一个模块化的处理思想,具有统一管理,资源分配,调整优化,监控的优点

D正确,线程池模块与任务的产生分离,可以动态的根据性能及任务数量调整线程的数量,提高程序的运行效率

19. 如何理解原语的原子性,在单机环境下如何实现原语的原子性,实现时应注意哪些问题?

所谓原语的原子性操作是指一个操作中的所有动作,要么成功完成,要么全不做。也就是说,原语操作是一个不可分割的整体。为了保证原语操作的正确性,必须保证原语具有原子性。在单机环境下,操作的原子性一般是通过关闭中断来实现的。由于中断是计算机与外设通信的重要手段,关闭中断会对系统产生很大的影响,所以在实现时一定要避免原语操作花费时间过长,绝对不允许原语中出现死循环。

20. CAS(CompareAndSwap),是用来实现lock-free编程的重要手段之一,多数处理器都支持这一原子操作,其用伪代码描述如下,

cpp 复制代码
template bool CAS(T* addr, T expected, T value) {
    if (*addr == expected) {
        *addr = value;
        return true;
    }
    return false;
}
int count = 0;
void count_atomic_inc(int* addr) {
    int oldval = 0;
    int newval = 0;
    do {
        oldval = *addr;
        newval = ______ + 1;
    }until CAS(_______, ________, _________)
}

A.newval,addr,*oldval, oldval

B.oldval,addr,oldval,newval

C.oldval,addr,oldval,*newval

D.oldval,addr,newval,oldval

答案:B

CAS(Compare - and -Swap):一种比较后数据若无改变则交换数据的一种无锁操作(乐观锁)

这个题里边注意各个参数的是否使用指针即可

CAS比较与交换的伪代码可以表示为:

do {

备份旧数据;

基于旧数据构造新数据;

} while (!CAS(内存地址,备份的旧数据,新数据))

正确选项 为B选项,可以将B选项的数值代入理解CAS锁的原理思想

21. 下列操作中,需要执行加锁的操作是()[多选]

A.x++;

B.x=y;

C.++x;

D.x=1;

答案:ABC

D 常量的直接赋值是一个原子操作

ABC选项中涉及到了数据的运算,则涉及从内存加载数据到寄存器,在寄存器中运算,将寄存器中数据交还内存的过程

因此需要加锁保护的操作中,正确选项为:ABC

22. 并发编程中通常会遇到三个问题 原子性问题,可见性问题,有序性问题,java/C/C++中volatile关键字可以保证并发编程中的

A.原子性, 可见性

B.可见性,有序性

C.原子性,有序性

D.原子性, 可见性,有序性

答案:B

原子性:一个操作不会被打断,要么一次完成,要么不做。

可见性:一个资源被修改后,是否对其他线程是立即可见的(一个变量的修改存在一个过程,将数据从内存加载的cpu寄存器,进行运算,完毕后交还内存,但是这个过程在代码优化中可能会被编译器优化,将数据放入寄存器,则后续运算只从寄存器取数据,就节省了从内存获取数据的时间)

有序性:简单理解,程序按照写代码的先后顺序执行,就是有序的。(编译器有时候会为了提高程序效率进行代码优化,进行指令重排,来提高效率,而有序性就是禁止指令重排)

而volatile关键字的作用是,防止编译器过度优化,因此具备 可见性与有序性 功能

基于以上理解,正确选项为:B

23. 无锁化编程有哪些常见方法?[多选]

A.针对计数器,可以使用原子加

B.只有一个生产者和一个消费者,那么就可以做到免锁访问环形缓冲区(Ring Buffer)

C.RCU(Read-Copy-Update),新旧副本切换机制,对于旧副本可以采用延迟释放的做法

D.CAS(Compare-and-Swap),如无锁栈,无锁队列等待

答案:ABCD

A正确,原子操作不涉及线程安全问题

B正确,环形队列本身具有同步的功能,在一对一的情况下,这种同步侧面的实现了互斥的效果

C正确,RCU锁机制 - 对读写锁的一种优化,读 - 拷贝 - 更新,读者可以同时读取数据,写者更新数据前先复制一份数据出来,对副本进行修改,修改完毕后更新数据,而旧版本数据等所有读者不再访问时释放

D正确,CAS - 比较并交换,是一种乐观锁,认为在使用数据的过程中其它线程不会修改这个数据,故不加锁直接访问

相关推荐
ha20428941949 小时前
Linux操作系统学习之---线程池
linux·c++·学习
gfdgd xi10 小时前
GXDE 内核管理器 1.0.1——修复bug、支持loong64
android·linux·运维·python·ubuntu·bug
deng-c-f11 小时前
Linux C/C++ 学习日记(43):dpdk(六):dpdk实现发包工具:UDP的发包,TCP的泛洪攻击
linux·dpdk·泛洪
我命由我1234511 小时前
Derby - Derby 服务器(Derby 概述、Derby 服务器下载与启动、Derby 连接数据库与创建数据表、Derby 数据库操作)
java·运维·服务器·数据库·后端·java-ee·后端框架
拥友LikT12 小时前
惠普DL380,Bios设置了U盘启动以后,读不到U盘(其他品牌服务器解决思路类似)
服务器·系统安装
我系真滴菜12 小时前
EMQX服务器调试
运维·服务器
Fuchsia13 小时前
Linux软件编程笔记五——进程Ⅰ
linux·c语言·笔记·操作系统·进程
含目的基因的质粒13 小时前
Python异常、模块、包
服务器·开发语言·python
AC是你的谎言13 小时前
HTTP和HTTPS
linux·网络·c++·网络协议·学习·http·https
c语言鹌鹑蛋14 小时前
【进程间通信】--- 匿名管道,命名管道
linux