线程与多线程(二)
本文为多线程相关的内容,线程相关的内容参见线程与多线程(一)。
一、线程互斥
1、相关概念
- 临界资源(互斥资源)为多线程执行流共享的资源。
- 临界区为每个线程内部,访问临界资源的程序段(代码)。
- 互斥为任何时刻都保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用。
- 原子性为所进行的操作不会被任何调度机制打断。该操作只有两态,即完成态和未完成态,而没有中间状态。
二、互斥锁
1、介绍
- 使用互斥锁的本质是用时间来换取安全,表现为线程对于临界区代码串行执行,原则上是尽量的要保证临界区代码越少越好。
- 因为锁本身就是共享资源,所以,申请互斥锁和释放互斥锁的操作被设计成原子性操作。
- 在纯互斥环境下,如果锁分配不够合理就容易导致其他线程的饥饿问题。
- 在临界区中,线程可以被切换,但在线程被切换出去的时候,其是持有锁被切换的。在这期间没有谁能进入该互斥锁所锁定的临界区去访问临界资源。
- 持有互斥锁的线程访问临界区的过程,对于其他线程来说是原子的。
2、使用场景
3、初始化
(1)函数
(2)概念
- pthread_mutex_init函数将使用attr指定的属性初始化mutex引用的互斥锁。如果attr为NULL,则使用默认的互斥属性,其效果与传递默认互斥属性对象的地址的效果相同。
- 只有互斥锁本身可用于执行同步。在调用pthread_mutex_lock函数、pthread_mutex_trylock函数、pthread_mutex_unlock函数和pthread_mutex_destroy函数时引用互斥锁副本的结果是未定义的。
- 在默认互斥锁属性适用的情况下,宏PTHREAD_MUTEX_INITIALIZER可用于初始化静态分配的互斥锁。该效果等同于通过调用pthread_mutex_init函数,并将参数attr指定为NULL进行动态初始化,但不执行错误检查。
- 尝试初始化已初始化的互斥锁的行为是未定义的。
- 初始化成功后,互斥锁的状态将被初始化且为解锁状态。
4、销毁
(1)函数
(2)概念
- pthread_mutex_destroy函数的作用为将mutex引用的互斥对象销毁。
- 互斥对象实际上会变成未初始化。即pthread_mutex_destroy函数会将mutex引用的对象设置为无效值。
- 被销毁的互斥对象可以使用pthread_mutex_init函数重新初始化。而在对象被销毁后,以其他方式引用该对象的结果是未定义的。
- 销毁已解锁且初始化的互斥锁是安全的,而试图销毁锁定的互斥锁会导致未定义的行为。
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥锁不需要调用pthread_mutex_destroy函数销毁。
5、加锁
(1)函数
(2)概念
- mutex引用的互斥对象可通过调用pthread_mutex_lock函数来锁定。如果该互斥锁已被锁定,则调用的线程会进行阻塞等待,直到互斥锁可用。此操作(申请锁)成功将返回处于锁定状态的互斥对象,调用线程作为其所有者。
- 申请互斥锁成功后,尝试重新锁定互斥锁会导致死锁。如果一个线程试图解锁它没有锁定的互斥锁或处于解锁状态的互斥锁,则会导致未定义的行为,返回错误。
- pthread_mutex_trylock函数等效于pthread_mutex_lock函数,但如果mutex引用的互斥对象当前被锁定(已被任何线程,包括当前线程申请成功),则调用会立即返回。
6、解锁
(1)函数
(2)概念
- pthread_mutex_unlock函数释放mutex引用的互斥对象。而互斥锁的释放方式取决于互斥锁的类型属性。
- 如果在调用pthread_mutex_unlock函数时,mutex引用的互斥对象上有线程被阻塞,则当互斥对象可用时,调度策略应确定哪个线程应获取该互斥对象。
- 如果一个信号被传递给等待互斥锁的线程,那么在信号处理程序返回时,线程应继续等待之前等待的互斥锁,就像该线程没有被中断一样。
7、示例代码
cpp
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int count = 0;
class threadData
{
public:
threadData(int number/*, pthread_mutex_t *lock*/)
//:_lock(lock)
{
_threadName = "thread-" + to_string(number);
}
string _threadName;
//pthread_mutex_t *_lock;
};
void *Routine(void *args)
{
threadData *td = static_cast<threadData*>(args);
while(true)
{
pthread_mutex_lock(&lock);
//pthread_mutex_lock(td->_lock);
cout << td->_threadName << ", counting: " << count++ << endl;
pthread_mutex_unlock(&lock);
//pthread_mutex_unlock(td->_lock);
if(count >= 6)
break;
sleep(1);
}
cout << td->_threadName << " quit" << endl;
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData*> tds;
// pthread_mutex_t lock;
// pthread_mutex_init(&lock, nullptr);
for(int i = 0; i < 4; ++i)
{
pthread_t tid;
threadData *td = new threadData(i/*, &lock*/);
tds.push_back(td);
pthread_create(&tid, nullptr, Routine, td);
tids.push_back(tid);
}
for(auto tid : tids)
{
pthread_join(tid, nullptr);
}
cout << "main thread wait thread over" << endl;
for(auto td : tds)
{
delete td;
}
cout << "main thread delete thread over" << endl;
//pthread_mutex_destroy(&lock);
return 0;
}
8、运行结果
三、安全
1、线程安全
- 如果线程是安全的,则多个线程并发执行同一段代码时,不会出现不同的结果。
- 在没有锁保护的情况下,对全局变量或者静态变量进行操作,会出现线程安全问题。
2、重入
- 重入为同一个函数被不同的执行流调用时,当前一个流程还没有执行完,就有其他的执行流再次进入。
- 一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数为可重入函数。否则则为不可重入函数。
3、常见的线程不安全的情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
四、死锁
1、概念
- 两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时系统处于死锁状态或系统产生了死锁。这些永远在互相等待的线程为死锁线程。
- 在死锁情况下,所占用的资源或者需要它们进行某种合作的其它线程会相继陷入死锁,最终可能导致整个系统处于瘫痪状态。
2、产生的必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。这是产生死锁的前提。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。这是产生死锁的原则。
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。这是产生死锁的原则。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。这是产生死锁的重要条件。
3、避免方法
- 破坏四个必要条件。
- 加锁的顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
五、条件变量
1、背景概念
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
- 竞态条件:因为时序问题导致程序异常。
- 条件变量是一种线程同步的基本机制,用于线程之间发信号通知和等待。 它通常和互斥锁一起使用,以防止竞态条件和确保线程安全。即条件变量必须依赖于锁的使用。
- 条件变量的优点是可以以原子方式阻塞线程,直到某个特定条件为真为止。
2、初始化与销毁
3、等待
(1)函数
(2)参数意义
- 参数cond表示调用该函数的线程要在这个条件变量上等待。
- 参数mutex表示当线程申请不到mutex时,在条件变量cond下等待。
- 在等待时,如果不能申请到条件变量,则会释放锁mutex。
(3)互斥锁的作用
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,会一直等下去,所以必须要有一个线程通过某些操作改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足,这必然会牵扯到共享数据的变化。所以,需要用互斥锁来保护,没有
互斥锁就无法安全的获取和修改共享数据。
4、唤醒
(1)函数
(2)概念
- pthread_cond_broadcast函数可解除当前阻塞在指定条件变量cond上的所有线程。
- 如果在cond上有任何线程被阻塞,pthread_cond_signal函数可解除当前阻塞在指定条件变量cond上的至少一个线程。
- 如果当前没有线程阻塞在条件变量cond上,则pthread_cond_broadcast和pthread_cond_signal函数将无效。
5、示例代码
cpp
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int cnt = 0;
void *Rountine(void *args)
{
pthread_detach(pthread_self());
uint64_t number = (uint64_t)args;
cout << "thread " << number << " created" << endl;
while(true)
{
pthread_mutex_lock(&lock);
//此处可加判断语句,不满足再在条件变量下等待
pthread_cond_wait(&cond, &lock);
cout << "thread " << number << " count, cnt = " << cnt++ << endl;
pthread_mutex_unlock(&lock);
}
}
int main()
{
for(uint64_t i = 0; i < 4; ++i)
{
pthread_t tid;
pthread_create(&tid, nullptr, Rountine, (void*)i);
usleep(1000);
}
while(true)
{
sleep(2);
//pthread_cond_signal(&cond);
//cout << "main thread open one cond's" << endl;
pthread_cond_broadcast(&cond);
cout << "main thread open all cond's" << endl;
}
return 0;
}
6、运行结果
- 释放pthread_cond_signal以及下一行的注释,注释pthread_cond_broadcast以及下一行
六、生产者消费者模型
1、概念
- 生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
- 生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯。所以,生产者生产完数据之后不用等待消费者处理,而是直接扔给阻塞队列;消费者不找生产者要数据,而是直接从阻塞队列里取。
- 阻塞队列相当于一个缓冲区,平衡生产者和消费者的处理能力和用来给生产者和消费者解耦。
- 生产者和生产者之间是互斥关系、消费者和消费者之间是互斥关系、生产者和消费者之间是互斥且同步关系。
- 模型包括两种角色,生产者和消费者。
- 模型需要一个交易场所,即特定结构的内存空间。
2、示意图
3、优点
- 解耦
- 支持并发
- 支持忙闲不均
4、环形队列
(1)示意图
(2)说明
- 当环形队列满和空时,head和tail指向的是同一个位置。此时只能一方进行访问,当队列空时,生产者进行访问;当队列满时,消费者进行访问。
- 消费者不能超过生产者,生产者不能超过消费者一个环形队列的长度
七、POSIX信号量
1、概念
- 信号量是一个计数器,用于限制对共享资源的访问数量。
- 当多个线程或进程需要访问共享资源时,它们会先尝试获取信号量。如果信号量的值大于0,则允许一个线程或进程获取信号量并访问共享资源,然后将信号量的值减1。否则则会等待。
- 信号量把判断资源是否就绪放在了临界区之外,当申请信号量时,其实间接得在判断资源是否就绪。
2、初始化
(1)函数
(2)概念
- sem_init函数的作用为在sem指向的地址初始化未命名的信号量。
- value参数指定信号量的初始值。
- pshared参数指示此信号量是在进程的线程之间共享,还是在进程之间共享。
- 如果pshared的值为0,则信号量在进程的线程之间共享,并且应该位于所有线程可见的某个地址。如全局变量或在堆上动态分配的变量。
- 如果pshared为非零,则信号量在进程之间共享,并且应该位于共享内存区域中。任何可以访问共享内存区域的进程都可以使用sem_post、sem_wait等对信号量进行操作。
- 初始化已初始化的信号量的行为是未定义。
3、销毁
(1)函数
(2)概念
- sem_destroy函数的作用为销毁sem指向的地址处的未命名信号量。
- 只有由sem_init函数初始化的信号量才应该使用sem_destroy函数销毁。
- 销毁当前有其他进程或线程被阻塞的信号量(即其在sem_wait中)会产生未定义的行为。
- 使用已被销毁的信号量会产生未定义的结果,除非使用sem_init函数重新初始化该信号量。
4、等待
(1)函数
(2)概念
- sem_wait函数递减(锁定)sem指向的信号量。
- 如果信号量的值大于零,则递减继续进行,函数立即返回。
- 如果信号量当前的值为零,则调用会阻塞,直到可以执行递减(即信号量值升至零以上),或者信号处理程序中断该调用。
5、发布
(1)函数
(2)概念
- sem_post函数递增(解锁)sem指向的信号量。
- 如果信号量的值因此变得大于零,则sem_wait调用中阻塞的另一个进程或线程将被唤醒并继续进行锁定信号量的操作。
八、单例模式
1、概念
- 单例模式(Singleton Pattern)是一种设计模式。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
- 这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。
- 这个类提供了一种访问其唯一的对象的方式(全局访问点),可以直接访问,不需要实例化该类的对象。
2、实现方式
(1)饿汉模式
- 饿汉模式是一种设计模式,它在类加载时就完成了实例化操作。因此,在类加载时比较慢,但在获取对象时速度比较快。
- 它的优点是实现简单,线程安全。因为实例在类加载时就已经创建,所以不存在多线程下的同步问题。
- 它的缺点是在类加载时就完成了实例化,如果这个类在程序运行过程中从未被使用到,那么就会造成资源的浪费。
(2)懒汉模式
- 懒汉模式是一种在类加载时不进行实例化,只有在首次调用时才创建实例的设计模式。它的实现通常需要使用到同步机制来保证线程安全。
- 它的优点是可以延迟实例化,只有在实际需要时才会创建对象。
- 它的缺点是线程不安全,在多线程环境下,如果没有正确的同步机制,就可能会出现多个实例的情况。
九、其他常见的锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改。所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,会被阻塞挂起。
- 乐观锁:在每次取数据的时,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式,即版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不相等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 读写锁:专门处理多读少写的情况,写时独占锁,读时共享锁,读锁的优先级比写锁高。写者之间是互斥竞争关系,写者与读者之间是互斥同步关系,读者之间是共享关系。
本文到这里就结束了,如有错误或者不清楚的地方欢迎评论或者私信
创作不易,如果觉得博主写得不错,请点赞、收藏加关注支持一下💕💕💕