文章目录

  • 1. 引进
  • 2. 前导概念
    • 2.1 同步与异步
    • 2.2 互斥与并发
    • 2.3 原子性操作
    • 2.4 临界资源和临界区
      • 临界资源
      • 临界区
      • 怎么办理
  • 3. 互斥锁
    • 3.1 引进
    • 3.2 概念
    • 3.3 示例
      • pthread_mutex函数宗族
      • 用法
      • 大局锁
      • 部分锁
    • 3.4 功用损耗
    • 3.5 串行履行
    • 3.6 弥补
  • 4. 互斥锁的完结原理
    • 4.1 线程的履行和堵塞
    • 4.2 自旋锁与互斥锁
      • 概念
      • xchg指令
      • 自旋锁的加解锁
      • 互斥锁的加解锁
      • 互斥锁的恳求
      • 线程切换
    • 4.3 互斥锁的实质
  • 5. 可重入和线程安全
    • 5.1 可重入函数
    • 5.2 线程安全
    • 5.3 常见线程不安全的状况
    • 5.4 常见线程安全的状况
    • 5.5 常见的不行重入的状况
    • 5.6 常见的可重入的状况
    • 5.7 可重入和线程安全的联系
  • 6. 死锁
    • 6.1 概念
    • 6.3 比方
    • 6.4 堵塞、挂起和等候
      • 小结
    • 6.4 死锁的必要条件
      • 防止死锁
        • 损坏死锁必要条件
        • 运用 trylock 函数
  • 7. 线程同步
    • 7.1 前导概念
      • 同步
      • 竞态条件
    • 7.2 引进
    • 7.3 线程同步
    • 7.4 条件变量
      • 准则
      • cond 族函数
        • pthread_cond_init
        • pthread_cond_destroy
        • pthread_cond_wait
        • pthread_cond_broadcast 和 pthread_cond_signal
      • 示例
        • 结构
        • 互斥锁、条件变量
        • 扩大信息
        • 唤醒线程
          • 条件变量唤醒
          • 条件变量播送

1. 引进

多线程安满是指在多个线程一同拜访同享资源时,确保资源的正确性和一致性的能力。多线程安满是并发编程中的一个重要概念,由于假如不考虑多线程安全,或许会导致数据丢失、过错或死锁等问题。

多人在同一时刻段抢固定数量的票是一个很好的多线程编程比方。在这个比方中,每个人能够被视为一个线程,票的数量能够被视为同享资源,那么它将会被设置为一个大局变量,被一切线程同享。下面是它的简略完结:

#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 10000; // 票数
// 线程函数
void* getTickets(void* args)
{
	(void)args;
	while(1)
	{
		if(tickets > 0)
		{
			usleep(1000);
			printf("[%p]线程:%d号\n", pthread_self(), tickets--);
		}
		else break;
	}
	return nullptr;
}
int main()
{
	pthread_t t1, t2, t3;
	// 多线程抢票
	pthread_create(&t1, nullptr, getTickets, nullptr);
	pthread_create(&t2, nullptr, getTickets, nullptr);
	pthread_create(&t3, nullptr, getTickets, nullptr);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    return 0;
}

输出:

线程同步与互斥【Linux】

可是成果却呈现了负数。这是由于代码中存在竞态条件。在多线程环境中,当多个线程一同拜访同享数据(即大局的tickets变量)时,或许会呈现竞态条件。在当一个线程检查tickets > 0时,另一个线程或许会修正tickets的值。这或许导致多个线程一同进入临界区域并履行tickets–操作,然后导致tickets的值变为负数。

实际上,--操作尽管从C/C++代码来看只需1行,可是它关于CPU(及其寄存器)而言,是3条指令,这能够经过汇编代码验证,截图源自https://godbolt.org/

线程同步与互斥【Linux】

因而,tickets–(自增和自减)操作并不是原子的,它实际上包括三个进程:从内存中读取tickets的值到CPU的寄存器中,CPU将其减1,然后将成果写回内存–这些进程一般是经过寄存器来完结的。

由于这些进程并不是原子的,在多线程环境中或许会呈现竞态条件。为什么呢?(经过下面这段话就能了解原子操作的重要性)。

首先要明确线程的调度是不确认的,线程随时有或许被切换。也便是说,线程在履行--操作的恣意3个进程的恣意或许的时机,都或许被切换。

例如,假定线程A和线程B履行之前tickets的值为1,阐明它们都现现已过了tickets > 0这一分支。线程还没从内存中读取tickets的值就被调度器给切换了;此刻,线程B被调度,可是它没有被打断,而是完整地履行完3个进程,所以在内存中tickets的值现已被线程B更新为0了。然后在某个时刻线程A再次被调度回来持续履行,线程A从中止的当地持续履行(留意此刻现现已过if判别),从内存中读取的tickets值便是被线程B更新后的0了,那么最终tickets的值便是-1。

因而,呈现-1的成果也不是必定的,假如将tickets的初始值设置的比较小,那么最终或许会得到0(取决于调度器),呈现这样的成果也是不对的,由于现实日子中一般不会把0作为票的编号。同样地,当线程A在从内存中读取tickets的值后马上就被切换为线程B履行--操作,即便tickets的值被线程B更新为0,现已不满意持续取票的条件了;可是操作体系会在线程A被切换时保存它的上下文数据(被load到寄存器中的数据都叫上下文),被切换回来时,线程A看到的依然是原先的tickets值1,所以线程A依然会将tickets值更新为0。

由于多个线程一同拜访同享资源,因而需求运用互斥锁或其他同步机制来确保数据的一致性。能够运用互斥锁或条件变量同等步机制来维护对同享数据的拜访。本文将介绍部分同步机制。

弥补:假如在Linux下运用g++编译器编译含有thread库函数的C++源文件,有必要加上-lthread选项,即便<thread>是C++内置的线程库。

原因:

C++内置的线程库是依据pthread的封装,供给了更高层次的笼统和接口,使得编写多线程程序愈加方便和安全。C++内置的线程库包括了一些类和函数,如std::thread, std::mutex, std::condition_variable等,它们都是对pthread的功用的封装或扩展。

咱们之前在Linux渠道中用的pthread库是一种跨渠道的线程规范,界说了一系列的函数和数据类型,用于创立和办理线程。pthread是POSIX规范的一部分,因而在支撑POSIX的操作体系中,都能够运用pthread。也便是说,假如这段代码在Windows环境下编译运转,那么C++的内置thread库会链接到Windows内置的线程库中。

在用g++编译器编译含有pthread库函数的C++源文件时,需求加上-lthread选项,是由于pthread不是C++规范库的一部分,而是一个独立的库。因而,在链接阶段,需求告诉编译器去寻找pthread库,并将其链接到可履行文件中。-lthread选项便是用于指定链接pthread库的选项,它会在体系中搜索名为libthread.so或libthread.a的文件,并将其链接到可履行文件中。

2. 前导概念

在多线程编程中,一个常见的问题是怎么处理多个线程一同拜访和修正同一个大局变量的状况,假如不规范地编写代码,很简略呈现线程安全问题。

为了解决线程安全问题,一种常用的办法是运用同步机制,例如锁、信号量、互斥量等。同步机制能够确保在恣意时刻只需一个线程能够拜访和修正同享数据,然后防止数据的纷歧致和过错。

2.1 同步与异步

在了解同步机制之前,需求明确线程间同步的概念,同步和异步是相对的,能够放在一同了解。以上课为例,假定上课时小明有事出去了:

  • 同步:全班暂停,直到小明回来今后才持续上课;
  • 异步:持续上课,各忙各的,互不影响。

同步和异步一般用来描绘两个或多个事情之间的联系。同步是指两个或多个事情依照必定的次序产生,一个事情的产生依赖于另一个事情的完结。异步则是指两个或多个事情之间没有固定的先后次序,它们能够独立产生。

2.2 互斥与并发

互斥与并发相对。互斥是指同一个资源同一时刻只需一个拜访者能够进行拜访,其他拜访者需求等前一个拜访者拜访完毕才干够开端拜访该资源。并发是指在操作体系中,同个处理机上有多个程序一同运转。

举个比方,假定电影院有一部电影正在上映,这部电影的座位便是资源。

  • 互斥:假如这部电影的座位悉数售罄,那么就没有人能够再买到这部电影的票了。
  • 并发:假如电影院一同上映多部电影,观众能够挑选购买其他电影的票,这便是并发的概念。

在这儿,咱们先了解了互斥,经过后续的深入,便能逐渐了解并发。首先能够从调集的视点看待并发:电影院一切看电影的人是全集C,买到这部电影票的人归于调集A,没有买到这部电影票的人归于调集B,调集A+调集B=全集C。那么这种“非此即彼”的联系便是互斥(要么你,要么我)。

2.3 原子性操作

原子性操作是指不行中止的一个或一系列操作。这些操作只能在一个线程履行完之后,另一个线程才干开端履行该操作,也便是说这些操作是不行分割的,线程不能在这些操作上替换履行,例如比方中的--操作就不是原子的,由于它需求3个进程。

因而,要在多线程编程中削减呈现相似上例中的过错,就要运用原子性操作。原子性操作只需两种状况,即完结前和完结后,没有中间状况(履行中)。

汇编指令对应着CPU寄存器硬件的操作,因而在汇编的视点上看,某个操作只对应着一条汇编指令,那么这个操作便是原子性的。对CPU而言,原子性指令便是由CPU直接履行的操作。

2.4 临界资源和临界区

临界资源和临界区是操作体系中的两个重要概念,它们与进程的同步和互斥有密切的联系。本

临界资源

  • 临界资源是指在多进程环境下,不能被多个进程一同运用或拜访的资源。

例如打印机、磁带机、文件等。假如多个进程一同运用或拜访临界资源,或许会导致数据的纷歧致或过错。因而,关于临界资源,有必要完结进程之间的互斥拜访,即在恣意时刻,只能有一个进程运用或拜访该资源,其他需求运用或拜访该资源的进程有必要等候。

临界区

  • 临界区是指在多进程环境下,拜访临界资源的那段代码。

由于临界区触及到对临界资源的操作,因而有必要确保在恣意时刻,只能有一个进程履行临界区的代码,其他需求履行临界区的代码的进程有必要等候。假如多个进程一同履行临界区的代码,或许会导致数据的纷歧致或过错。

怎么办理

为了维护临界资源和办理临界区,操作体系供给了一些机制,例如信号量、互斥锁、条件变量、管程等。这些机制的基本思想是:在进入临界区之前,进程有必要先获取一个标志或锁,表明该进程具有对临界资源的拜访权;在退出临界区之后,进程有必要开释该标志或锁,表明该进程抛弃对临界资源的拜访权;假如一个进程企图获取一个现已被其他进程占用的标志或锁,那么该进程将被堵塞,直到其他进程开释该标志或锁中止。

经过这些机制,能够完结对临界资源和临界区的有效维护和办理,然后确保多进程环境下的数据一致性和正确性。

3. 互斥锁

3.1 引进

承受上面的抢票程序,判别tickets > 0实质也是核算的一种办法。在CPU核算之前要先将内存中的数据加载(load)CPU的寄存器中,数据从内存流到了寄存器仅仅体现在数据传递的层面,这是了解成果犯错的难点。从履行流的视点看,当时CPU正在履行哪个履行流的指令,它的寄存器中存放的便是哪个履行流的数据。当多个线程拜访同一个大局变量(同享资源),或许会导致上下文数据中的这个大局变量的值原本现已到极限了,却线程眼中的却是它被修正之前的值,这是由于线程在紊乱的时序下切换构成的成果。

这和(不)可重入函数是相似的,C/C++中对变量进行--操作,在线程切换时时有危险的。而这仅仅一个独立的示例,实际状况要杂乱得多,线程被调度(被切换)也是不确认的。

怎么防止这样的问题?

  • 对大局变量(同享资源)进行维护。

它呈现负数的原因是--操作被打断了,线程A还没让CPU核算就被切换了,而线程B看到的还是本来的值,当线程B更新今后,大局变量的值就现已不合法了,可是线程A被切换回来,「恢复线程上下文,上下文中大局变量的值是旧的值,经过了if判别」,所以多减了一次。

也便是说底子原因是--操作被打断了,假如有一个机制能够让线程在履行相似--这样非原子操作时,其他线程不能履行,就能确保这个同享资源最终必定是合法的。

这种机制怎么完结?

在抢票的比方中,能够用一个符号完结互斥机制,这个符号关于某个同享资源是仅有的,也便是说,当一切线程拜访同一个同享资源之前,操作体系只会让那一个被符号的线程拜访。

3.2 概念

互斥锁(mutex)是一种用于完结多线程之间的同步机制的东西,它能够确保在任一时刻,只需一个线程能够拜访同享的资源或代码段。互斥锁能够防止多线程程序中呈现数据竞赛(data race)或许死锁(deadlock)等问题,进步程序的正确性和稳定性。

互斥锁的基本用法是:

  1. 创立一个互斥锁目标,然后在需求拜访临界区域的代码前,调用互斥锁的lock()函数,以获取锁的一切权。
  2. 在拜访完临界区域后,调用互斥锁的unlock()函数,以开释锁的一切权。

当线程履行完使命开释锁今后,锁会被传递给其他等候获取锁的线程,它们会重复以上操作以安全地完结使命。

弥补:

C++规范库供给了std::mutex类来完结互斥锁的功用,以及std::lock_guard和std::unique_lock两种辅助类,用于简化互斥锁的办理和反常安全。–不过在本文中暂不介绍C++中的互斥锁,而依然以pthread库中的锁为例,如上所说,C++内置的线程库函数也是经过pthread库函数完结的。

在pthread库中,供给了互斥锁的相关函数,用于创立、初始化、加锁、解锁和毁掉互斥锁。互斥锁能够分为大局锁和部分锁,它们的用法有所不同,但都要进行初始化、加锁和解锁操作。

  • 大局锁是指在程序的大局变量区界说的互斥锁,它能够被程序中的任何线程运用。大局锁的长处是简略易用,不需求传递参数,也不需求动态分配内存。大局锁的缺陷是或许构成资源糟蹋,由于不同的线程或许需求拜访不同的同享资源,可是只能运用同一个互斥锁,这会导致不用要的等候和堵塞。其他,大局锁也不利于模块化编程,由于它损坏了数据的封装性。
  • 部分锁是指在程序的部分变量区或堆区界说的互斥锁,它只能被界说它的函数或结构体中的线程运用。部分锁的长处是能够依据需求创立多个互斥锁,每个互斥锁只维护一个同享资源,这样能够进步并发性和功率。其他,部分锁也有利于模块化编程,由于它坚持了数据的封装性。部分锁的缺陷是需求传递参数,或许动态分配内存,这会增加编程的杂乱度和开支。

什么是加锁和解锁?

加锁和解锁是一种完结临界区互斥性的办法。加锁是指在进入临界区之前,线程需求获取一个锁目标,假如锁目标现已被其他线程占用,就有必要等候或许堵塞,直到锁目标被开释。解锁是指在退出临界区之后,线程需求开释锁目标,然后让其他等候的线程有时机获取锁目标并进入临界区。–最重要的一点便是没有拿到锁的线程假如被分配去履行使命,那么它会堵塞等候

3.3 示例

pthread_mutex函数宗族

pthread_mutex 函数宗族是 POSIX 线程库中用于操作互斥锁的一组函数。它们包括:

  • pthread_mutex_init:初始化互斥锁。它承受两个参数,第一个参数是指向 pthread_mutex_t 类型变量的指针,第二个参数是指向 pthread_mutexattr_t 类型变量的指针,用于设置互斥锁的特点。假如运用默许特点,能够将第二个参数设置为 NULL
  • pthread_mutex_destroy:毁掉互斥锁。它承受一个指向 pthread_mutex_t 类型变量的指针作为参数。在运用完互斥锁后,应调用该函数来开释资源。
  • pthread_mutex_lock:加锁互斥锁。它承受一个指向 pthread_mutex_t 类型变量的指针作为参数。假如互斥锁现已被确认,调用该函数的线程将堵塞,直到互斥锁被解锁。
  • pthread_mutex_trylock:测验加锁互斥锁。它承受一个指向 pthread_mutex_t 类型变量的指针作为参数。假如互斥锁现已被确认,该函数会当即回来而不会堵塞。
  • pthread_mutex_unlock:解锁互斥锁。它承受一个指向 pthread_mutex_t 类型变量的指针作为参数。在运用完同享资源后,应调用该函数来解锁互斥锁,以便其他线程能够拜访同享资源。

以上是 pthread_mutex 函数宗族中常用的几个函数,它们都承受一个指向 pthread_mutex_t 类型变量的指针作为参数,并在成功时回来 0,失利时回来过错码。

用法

pthread中的互斥锁(pthread_mutex_t)是一个结构体类型,这个结构体包括了一些内部变量,用来表明互斥锁的状况和特点。咱们暂时不需求关怀这些变量的详细含义,只需求知道它是用来完结线程间的互斥操作的。进程如下:

要运用pthread_mutex_t类型的变量,首先要对它进行初始化。初始化有两种办法:

  • 静态初始化:在编译时就给互斥锁赋值为一个常量,表明它是一个默许特点的互斥锁(咱们暂时不需求关怀默许特点是什么),这种办法只能用于大局或静态变量。例如:

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER // 它是一个宏
    

    这样就创立了一个默许特点的互斥锁变量mutex。静态初始化的好处是简略方便,不需求调用函数,可是缺陷是只能运用默许特点,不能指定其他特点,例如是否递归、是否健壮等。

  • 动态初始化:在运转时调用函数来初始化变量,这种办法能够用于大局、静态和部分变量。例如:

    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, NULL);
    

    这样也创立了一个默许特点的互斥锁变量mutex。


    注:关于特点,暂时不用关怀,一般设置为nullptr/NULL。

    可是与静态初始化不同的是,动态初始化能够指定第二个参数为一个pthread_mutexattr_t类型的变量,该变量能够用来设置互斥锁的特点,例如:

    pthread_mutex_t mutex;
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    pthread_mutex_init(&mutex, &attr);
    

这样就创立了一个递归特点的互斥锁变量mutex。递归特点意味着同一个线程能够屡次确认同一个互斥锁而不会构成死锁。动态初始化的好处是能够灵敏地设置互斥锁的特点,可是缺陷是需求调用多个函数,而且要留意开释互斥锁和特点变量的内存,例如:

pthread_mutex_destroy(&mutex);
pthread_mutexattr_destroy(&attr);

总归,pthread_mutex_t类型的变量是一种重要的线程同步机制,它能够用来维护同享资源不被多个线程一同修正。依据不同的需求,能够挑选静态初始化或许动态初始化来创立互斥锁变量,而且要留意正确地运用和开释它们。

大局锁

  1. pthread_mutex_t界说一个大局锁;
  2. 在对大局变量操作之前运用pthread_mutex_lock()加锁;
  3. 在操作大局变量后运用pthread_mutex_unlock()解锁。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 界说一个大局锁
int tickets = 10000; // 票数
// 线程函数
void* getTickets(void* args)
{
	(void)args;
	while(1)
	{
		pthread_mutex_lock(&mtx); // 加锁
		if(tickets > 0)
		{
			usleep(1000);
			printf("线程[%p]:%d号\n", pthread_self(), tickets--);
			pthread_mutex_unlock(&mtx); // 解锁
		}
		else
		{
			pthread_mutex_unlock(&mtx); // 解锁
			break;
		} 
	}
	return nullptr;
}
int main()
{
	pthread_t t1, t2, t3;
	// 多线程抢票
	pthread_create(&t1, nullptr, getTickets, nullptr);
	pthread_create(&t2, nullptr, getTickets, nullptr);
	pthread_create(&t3, nullptr, getTickets, nullptr);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    return 0;
}

输出

线程同步与互斥【Linux】

能够看见,最终大局变量tickets的值不会是0或1,而且同样是3个线程,10000张票,运用互斥锁今后时刻就变长了。尽管截图中都是同一个线程,在运转进程中也能够看到其他线程在运转。经常呈现某个线程的或许原因是这个线程的优先级比较高,会优先被调度器调度。–这是归于调度器的行为,而不取决于用户写的代码。

在加锁之前,由于线程的调度是不确认的,各个线程对临界资源的拜访是互不影响的,可是加锁之后只答应一个线程拜访临界资源,确保了大局变量最终必定是合法的,与此一同会带来必定程度上的功用损耗。

部分锁

假如是部分界说的锁,有必要调用对应的初始化函数对锁进行初始化,一同也要在对应的当地毁掉锁。

int main()
{
	pthread_mutex_t mtx; // 界说部分锁
	pthread_mutex_init(&mtx, NULL); // 初始化锁
	pthread_t t1, t2, t3;
	// 多线程抢票
	pthread_create(&t1, nullptr, getTickets, nullptr);
	pthread_create(&t2, nullptr, getTickets, nullptr);
	pthread_create(&t3, nullptr, getTickets, nullptr);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
	pthread_mutex_destory(&mtx); // 毁掉锁
    return 0;
}

可是这样的话线程函数getTickets()就看不到在main函数界说的部分锁了,可是能够经过传参完结,由于线程函数的参数是void*类型,能够接纳任何类型的实参,例如一个数组,乃至能够是一个目标,只需以(void*)传参,在函数内部再转回去就能得到参数中的内容了。数据类型的不同仅仅看待内存的视角不同,束缚的是拜访内存数据的权限,而数据自身是不变的。这就好像有些网盘会检测不让上传的资源,可是咱们能够修正一下后缀再上传,今后要用的话再改回来就好了,里面的内容是不会被改变的。

能够将线程的信息(例如线程的别号)和锁的地址打包成一个目标传递给线程函数,这个目标的类型能够界说为ThreadData

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <ctime>
#include <chrono>
using namespace std;
#define THREAD_NUM 5
int tickets = 10000; 		// 票数
class ThreadData
{
public:
	// 结构函数
	ThreadData(const string& tname, pthread_mutex_t* pmtx)
	: _tname(tname)
	, _pmtx(pmtx)
	{}
public:
	string _tname;			// 线程名
	pthread_mutex_t* _pmtx;	// 锁的地址
};
// 线程函数
void* getTickets(void* args)
{
	ThreadData* td = (ThreadData*)args; 	// 获取参数传递的数据
	while(1)
	{
		pthread_mutex_lock(td->_pmtx); 		// 加锁
		if(tickets > 0)
		{
			usleep(1000);
			printf("线程[%p]:%d号\n", pthread_self(), tickets--);
			pthread_mutex_unlock(td->_pmtx); // 解锁
		}
		else
		{
			pthread_mutex_unlock(td->_pmtx); // 解锁
			break;
		} 
		usleep(rand() % 1500);				// 抢完票的后续操作, 用sleep替代
	}
	delete td; 								// 毁掉数据
	return nullptr;
}
int main()
{
	auto start = std::chrono::high_resolution_clock::now(); // 计时开端
	pthread_mutex_t mtx; 					// 界说部分锁
	pthread_mutex_init(&mtx, NULL); 		// 初始化锁
	srand((unsigned long)time(nullptr) ^ 0x3f3f3f3f ^ getpid());
	pthread_t t[THREAD_NUM];
	for(int i = 0; i < THREAD_NUM; i++)		// 多线程抢票
	{
		string tname = "thread["; 			// 线程名
		tname += to_string(i + 1); tname += "]";
		ThreadData* td = new ThreadData(tname, &mtx); 			// 创立保存数据的目标
		pthread_create(t + i, nullptr, getTickets, (void*)td); 	// 创立线程的一同将姓名和数据目标传递
	}
	for(int i = 0; i < THREAD_NUM; i++)		// 等候线程
	{
   		pthread_join(t[i], nullptr);
	}
	pthread_mutex_destroy(&mtx); 			// 毁掉锁
	auto end = std::chrono::high_resolution_clock::now();    // 计时完毕
	cout << "THREAD_NUM = " << THREAD_NUM << endl;
	cout << "共花费: " << chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms" << endl; 
    return 0;
}

增加的逻辑:

  1. ThreadData类的成员包括线程的信息(为了方便只用了线程姓名,实际上线程还有其他信息),还有线程函数getTickets()要用到的在main函数中界说的部分锁的地址;
  2. 在线程函数getTickets()函数中,加锁、拜访临界资源、解锁后线程还需求做其他作业,例如处理数据等,这儿用usleep一个随机数替代。在main中的随机数种子异或上了几个数字(是恣意取的),意在让随机数更随机;
  3. 在循环中创立线程,并将线程的姓名和编号绑定,和锁的地址打包进ThreadData目标中。留意这个目标是new出来的,因而在线程函数getTickets()的最终要delete它;在函数内部,需求用目标提取它的成员变量,以运用线程信息和锁;
  4. 为了稍后用时刻替代功用上的分析,所以在main函数的始末增加了计时逻辑,运用了 <chrono> 头文件中的 high_resolution_clock 类(归于std)来完结高精度计时(毫秒),在此不需求关怀它的运用。

输出:

线程同步与互斥【Linux】

3.4 功用损耗

在上面的比方中,假如将THREAD_NUM改成100,运转时刻会不会缩短呢(注释了线程函数中的usleep,增加了打印句子)?

线程同步与互斥【Linux】

从成果上看,即便没有让线程休眠,也快不了多少,是毫秒级的。

增加线程数量或许会缩短程序的履行时刻,但这并不是绝对的。程序的履行时刻取决于许多因素,包括硬件功用、操作体系调度战略、程序结构和算法杂乱度等。在多核处理器体系中,增加线程数量能够充分运用多核处理器的并行核算能力,然后缩短程序的履行时刻。可是,假如线程数量过多,线程之间的调度和同步开支也会增加,然后影响程序的履行功率(也便是说线程调度也是需求耗费时刻的)。

此外,假如程序中存在大量的串行核算或 I/O 操作,增加线程数量或许并不能显著缩短程序的履行时刻。

互斥锁尽管能维护同享资源的安全,但一同也会带来一些功用上的开支,首要有以下几个方面:

  • 互斥锁的创立和毁掉需求调用操作体系的API,这会耗费必定的时刻和内存资源。
  • 互斥锁的加锁和解锁需求进行原子操作(atomic operation),这会增加CPU的指令数和内存拜访次数。
  • 互斥锁的等候和唤醒需求进行上下文切换(context switch),这会导致CPU缓存(cache)的失效和线程调度(scheduling)的推迟。
  • 互斥锁的竞赛会构成线程的堵塞(blocking)或许忙等候(busy waiting),这会下降线程的运用率和并发度。

因而,互斥锁在必定程度上会下降多线程程序的功率,尤其是在互斥锁维护的代码段或资源:

  • 非常频频地被拜访,导致锁的竞赛很剧烈。
  • 非常耗时地被履行,导致锁的持有时刻很长。
  • 非常简略地被处理,导致锁的开支占比很高。

那么,怎么削减互斥锁对多线程程序功率的影响呢?一般来说,有以下几个主张:

  • 尽量削减互斥锁的数量和规模,只维护必要的同享数据或临界区(critical section),防止过度同步(oversynchronization)。
  • 尽量缩短互斥锁的持有时刻,尽快开释锁,防止在持有锁的状况下进行I/O操作或其他耗时操作。
  • 尽量运用更高效的同步机制,如读写锁(read-write lock)、自旋锁(spin lock)、条件变量(condition variable)等,依据不同场景挑选合适的东西。

总归,互斥锁是一种有利有弊的同步机制,它能够确保多线程程序的正确性和稳定性,但也会下降程序的功率。因而,在运用互斥锁时,需求权衡利弊,合理规划和优化代码,以到达最佳的功用表现。

3.5 串行履行

在多线程程序中,假如多个线程需求拜访同享资源,一般需求运用同步机制(例如互斥锁)来维护同享资源。当一个线程取得锁并进入临界区时,其他企图进入临界区的线程将被堵塞,直到锁被开释。这样,多个线程在临界区内就会串行(xin,2)履行。

串行履行能够用来描绘单个线程中句子的履行次序,也能够用来描绘多个线程之间的履行次序。在程序中,指的是指令按次序顺次履行,每条指令的履行有必要在前一条指令履行完结后才干开端。下面是一个简略的 C++ 程序,它演示了串行履行的进程:

#include <iostream>
int main() 
{
    std::cout << "Step 1" << std::endl;
    std::cout << "Step 2" << std::endl;
    std::cout << "Step 3" << std::endl;
    return 0;
}

在这个程序中,三条 std::cout 句子按次序顺次履行。程序的输出成果如下:

Step 1
Step 2
Step 3

能够看到,程序中的指令按次序顺次履行,这便是串行履行。这段代码的目标是每条句子,串行履行的目标也能够是线程。

串行履行是一种完结多线程安全的办法,它指的是让多个线程依照必定的次序顺次履行,而不是一同履行。串行履行能够防止多个线程对同一个资源的竞赛,然后确保资源的完整性和正确性。–说白了便是让线程一个一个排队履行使命,功率天然比不过多个线程一同履行。

串行履行的长处是简略易了解,不需求额外的同步机制,也不会产生死锁等问题。串行履行的缺陷是功率低下,不能充分运用多核处理器的功用,也不能完结真实的并行。死锁有关的内容在本文第六节。

串行履行能够经过以下几种办法完结:

  • 运用单线程:假如只需一个线程履行一切的使命,那么就不存在多线程安全的问题,也便是串行履行。这种办法最简略,但也最低效。
  • 运用互斥锁:互斥锁是一种同步机制,它能够确保在恣意时刻只需一个线程能够拜访同享资源。其他想要拜访资源的线程有必要等候锁被开释后才干持续履行。这种办法能够完结部分并行,但也会增加开支和杂乱度。
  • 运用行列:行列是一种数据结构,它能够依照先进先出(FIFO)的准则存储和处理数据。假如将一切需求拜访同享资源的使命放入一个行列中,然后由一个专门的线程依照行列中的次序顺次履行这些使命,那么就能够完结串行履行。这种办法能够削减锁的运用,但也会增加推迟和内存耗费。

总归,在多线程安全中,串行履行是一种简略但低效的办法,它合适用于对功用要求不高、对正确性要求高的场景。

加锁便是串行履行了吗?

加锁会使多个线程在临界区内串行履行。但这并不意味着整个程序都是串行履行的。在临界区外,多个线程依然能够并行履行。加锁仅仅一种同步机制,它并不改变程序的并行性质。它仅仅确保多个线程在拜访同享资源时不会产生冲突。

3.6 弥补

加锁之后线程在履行临界区的代码时会被切换吗?

答案是必定的。加锁之后线程在临界区中或许会被切换,这是操作体系调度机制决议的。加锁只能确保线程在进入临界区之前不会被切换,可是在临界区中履行的进程中,线程依然或许由于各种原因而被切换,例如时刻片用完、产生中止、主动让出CPU等。当线程被切换时,它依然持有锁目标,直到它再次被调度并履行完临界区代码后才会开释锁目标。

从头审视上面的代码,当某个拿到锁的线程被切换后,其他线程无法恳求到锁,其他一切线程不能履行临界区的代码,也就确保了临界资源的数据一致性。(哥不在江湖,但江湖仍旧有我的传说~)其实没啥安全影响,便是让其他线程等了一会,下降了功率[也蛮恶劣,有办法缓解]。

那么,线程在临界区中被切换会有什么影响吗?(留意「线程在临界区中」等价于「线程在履行临界区的代码」)

影响首要有两方面:

一方面,线程在临界区中被切换会导致其他等候的线程无法及时进入临界区,也便是说,线程被切换时拿着锁跑了,可是关于某个临界资源只需一个锁。然后下降了程序的并发功用和呼应速度。因而,在规划临界区时,应该尽量削减临界区的长度和杂乱度,防止在临界区中进行耗时的操作或许调用或许堵塞的函数。

另一方面,线程在临界区中被切换也或许导致一些逻辑过错或许死锁的状况。例如,假如一个线程在获取了一个锁目标后,在临界区中又企图获取另一个锁目标,而这个锁目标恰好被另一个线程占用,而且这个线程又在等候第一个线程开释的锁目标,那么就会构成一个循环等候的死锁。因而,在规划临界区时,应该遵从一些规范和准则,例如防止嵌套运用多个锁目标、依照固定的次序获取和开释锁目标、运用超时机制或许死锁检测机制等。实际上,有个重要的规矩便是能不用锁就不用锁,由于查错非常费事。

假如临界区有许多个句子,会呈现问题吗?

尽管临界区的代码有许多,可是互斥锁确保了临界区的代码在同一时刻只需一个线程能拜访,在代码自身满意要求的状况下,不会有问题。

这取决于临界区的代码是否满意以下几个准则:

  • 原子性:临界区的代码应该是不行分割的,即要么悉数履行,要么悉数不履行。假如临界区的代码中有或许抛出反常或许被中止,那么就需求运用反常处理或许信号处理机制,确保临界区的代码在任何状况下都能正确地退出,并开释锁。
  • 互斥性:临界区的代码应该只能由一个线程履行,即不能有其他线程一同进入临界区。这需求运用同步机制,如互斥锁、信号量、条件变量等,来确保只需一个线程能够取得对同享资源的拜访权。
  • 有序性:临界区的代码应该依照预期的次序履行,即不能有指令重排或许内存可见性问题。这需求运用内存屏障或许原子操作,来确保临界区的代码在不同的处理器或许内存模型下都能正确地履行。

什么是正确的多线程编码办法?

咱们无法操控调度器调度线程的战略,只能人为地经过加锁和解锁束缚在同一时刻段对同享资源的拜访权限,这个操作必定需求程序员手动完结。而同享资源关于在同一个进程地址空间的一切线程而言是暴露的,它们能够直接拜访同享资源。加锁仅仅运用了句子履行的次序是从上到下的特点,假如在临界区中或后恳求锁,那锁也没啥用了。

可是一个线程在不恳求锁的状况下拜访临界资源,是一种过错的多线程编程办法。详细原因现已在上面解说过不止一次了。为了防止这种状况产生,应运用同步机制(例如互斥锁)来维护同享资源。当一个线程需求拜访同享资源时,应先恳求锁,然后再进入临界区。在运用完同享资源后,应开释锁,以便其他线程能够拜访同享资源。

要拜访临界资源,每一个线程都要恳求锁,也便是说这个锁有必要被一切线程同享,因而锁自身也是一种同享资源。锁确保了临界资源的安全,那么谁来确保锁自身的安全?

锁自身的安满是由操作体系的原子操作确保的。原子操作能够确保在多线程环境下在任何时刻只需一个线程能够拜访锁,而且恳求锁和开释锁的操作也是原子的,这样就确保了锁自身的安全。

4. 互斥锁的完结原理

互斥锁的完结原理能够分为两个方面:硬件层面和软件层面,在这儿只评论CPU及寄存器的部分操作,以软件层面为例。

互斥锁的实质便是一个符号作用,它在内存中是一个数字。

4.1 线程的履行和堵塞

无锁的线程被分配使命后会挂起等候锁的分配。

软件层面的完结原理首要依赖于操作体系供给的调度机制,即操作体系能够操控线程或进程的履行和堵塞。操作体系能够维护一个互斥锁的状况和等候行列,当一个线程或进程想要拜访同享资源时,先检查互斥锁的状况,假如互斥锁未被占用,能够持续拜访,并将互斥锁的状况设为占用;假如互斥锁已被占用,需求将自己加入到等候行列中,并堵塞自己;当拜访完同享资源后,再将互斥锁的状况设为未占用,并唤醒等候行列中的一个线程或进程。这种互斥锁也称为睡觉锁(sleeplock),由于等候的线程或进程需求睡觉等候被唤醒。

4.2 自旋锁与互斥锁

概念

在Linux内核中,加锁和解锁是经过运用原子指令来完结的。原子指令是由CPU直接履行的操作,它们能够确保原子性。

互斥锁(mutex)和自旋锁(spinlock)是两种常见的同步机制,用于维护临界区的拜访。它们的差异在于,当一个线程企图获取一个现已被占用的锁时,互斥锁会让该线程进入睡觉状况,等候锁的开释;而自旋锁则会让该线程不断地循环检查锁的状况,直到获取到锁中止。因而,互斥锁能够防止糟蹋CPU资源,可是会增加上下文切换的开支;而自旋锁能够削减上下文切换的开支,可是会占用CPU资源。

在Linux中,咱们能够用自旋锁的完结原理从汇编视点了解互斥锁,事实上,Linux内核中的互斥锁便是依据自旋锁完结的。

详细来说,Linux内核中界说了一个结构体mutex,其间包括了一个自旋锁和一个等候行列。自旋锁经过不断检查锁的状况来防止多个线程一同拜访同享资源。假如锁被占用,线程会一直等候,直到锁被开释。当一个线程企图获取一个互斥锁时,它首先会测验获取该互斥锁内部的自旋锁。假如成功,阐明该互斥锁没有被占用,那么该线程就能够进入临界区;假如失利,阐明该互斥锁现已被占用,那么该线程就会将自己加入到等候行列中,并开释自旋锁,然后进入睡觉状况。当一个线程开释一个互斥锁时,它首先会检查等候行列是否为空。假如为空,阐明没有其他线程在等候该互斥锁,那么该线程就能够直接开释自旋锁;假如不为空,阐明有其他线程在等候该互斥锁,那么该线程就会从等候行列中取出一个线程,并唤醒它,并将自旋锁转移给它。

从汇编视点来看,Linux内核中运用了一些特殊的指令来完结自旋锁和互斥锁。例如,在x86架构下,Linux内核运用了lock前缀来确保指令的原子性;运用了xchg指令来交流两个操作数的值(本节最重要的指令);运用了cmpxchg指令来比较并交流两个操作数的值;运用了test_and_set_bit指令来测验并设置一个位;运用了test_and_clear_bit指令来测验并铲除一个位;运用了pause指令来优化自旋循环等。这些指令都是运用了CPU的硬件支撑来完结原子操作和内存屏障

lock是一种常用的同步机制,用于确保多个线程对同享资源的互斥拜访。可是,lock的完结并不简略,需求凭借一些底层的原子操作,比方xchgb/xchg指令。xchgb/xchg指令是一种交流两个操作数的值的指令,它具有原子性,即在履行进程中不会被其他线程或中止打断。xchgb指令能够用来完结一种简略的lock,称为自旋锁(spinlock)。

xchg指令

在x86架构的处理器中,有一条指令叫做xchgb(exchange byte),它能够原子地交流两个字节巨细的内存地址的值,其间一个操作数有必要是寄存器,另一个操作数能够是寄存器或内存地址。xchgb指令的履行进程是不行被中止的,也便是说,在它履行期间,其他线程或进程无法拜访它所触及的内存地址。这样就能够确保对互斥锁的操作是原子的,即不会呈现竞态条件。原子性意味着这条指令在履行进程中不会被其他指令打断,也不会遭到其他处理器或总线的搅扰,这是硬件支撑的。xchgb指令的格局如下:

xchgb %al, (%ebx)

这条指令的含义是,将寄存器al中的值和内存地址ebx中的值交流,并将交流后的值分别存回寄存器al和内存地址ebx。例如,假如寄存器al中的值为0x01,内存地址ebx中的值为0x00,那么履行这条指令后,寄存器al中的值变为0x00,内存地址ebx中的值变为0x01。

xchgb和xchg指令都用于交流两个操作数的值。它们的首要差异在于操作数的巨细。

xchgb指令能够用来完结互斥锁的加锁和解锁操作。关于互斥锁变量mutex,它是一个字节巨细的内存地址,初始值为0。当mutex为0时,表明互斥锁是闲暇的,当mutex为1时,表明互斥锁是占用的。

自旋锁的加解锁

下面是一个简略的x86汇编版其他自旋锁示例。这个示例运用xchgb指令来完结自旋锁:

spin_lock:
movb $1, %al # 将1放入寄存器al
xchgb %al, (lock) # 交流al和内存中lock变量的值,并将本来的值放入al
testb %al, %al # 测验al是否为0
jnz spin_lock # 假如不为0,阐明锁现已被占用,跳回spin_lock持续等候
ret # 假如为0,阐明锁现已取得,回来
spin_unlock:
movb $0, (lock) # 将0放入内存中lock变量,开释锁
ret # 回来

这样,咱们就能够用spin_lock和spin_unlock来维护临界区(critical section),即需求互斥拜访的同享资源。例如:

spin_lock # 调用spin_lock获取锁
# ...临界区代码...
spin_unlock # 调用spin_unlock开释锁

下面是上面示例中每条指令的解说:

  • spin_lock函数会测验获取锁。它运用xchgb指令将锁的值与1交流,并检查交流后的值。假如值为0,则表明该线程成功获取了锁;不然会持续等候并重试。

    • mov al, 1:将1移动到al寄存器中。
    • xchgb al, [lock]:将al寄存器中的值与内存中的锁值交流。
    • test al, al:测验al寄存器中的值是否为0。
    • jnz spin_lock:假如al寄存器中的值不为0,则跳转到spin_lock标签处,即第一条mov al, 1指令之前[重试]。
    • ret:从函数回来。
  • spin_unlock函数用于开释锁。它将锁的值设置为0,以便其他线程能够获取锁。

    • mov byte [lock], 0:将内存中的锁值设置为0。
    • ret:从函数回来。

在其他版别中寄存器的姓名或许是eax,这不重要。

互斥锁的加解锁

下面是一个运用xchgb指令完结互斥锁加锁和解锁操作的代码片段,它描绘了xchgb指令怎么在CPU和内存之间操作,以完结互斥锁。假定咱们有一个字节变量lock,它用作互斥锁。初始值为0,表明锁未被占用。当一个线程测验获取锁时,它会履行以下操作:

; 加锁操作
mov al, 1
lock xchgb [mutex], al
test al, al
jnz try_again
; 解锁操作
mov [mutex], 0

这段汇编代码片段的意思是:

  1. 将1移动到al寄存器中。

  2. 运用xchgb指令将al寄存器中的值与内存中的锁值交流。

    a. 将内存中的锁值读取到CPU中。

    b. 将al寄存器中的值写入内存中的锁方位。

    c. 将读取到的锁值写入al寄存器中。

  3. 假如交流后al寄存器中的值为0,则表明加锁成功;不然,表明加锁失利,需求再次测验加锁。解锁操作很简略,只需将内存中的锁值设为0即可。

留意:try_again不是汇编代码的关键字。它是一个标签,用来符号代码中的一个方位。在上面的示例代码中,jnz try_again指令表明假如前面的test al, al指令的成果为非零,则跳转到try_again标签地点的方位持续履行。这样就能够完结循环测验加锁的操作。

这些操作是原子的,也便是说,在整个进程中,其他线程无法拜访或修正内存中的锁值。因而,当线程检查交流后的锁值时,它能够确认自己是否成功获取了锁。

互斥锁的恳求

假定有一个互斥锁变量lock,它的初始值为0,表明锁是闲暇的。当一个线程想要恳求这个锁时,它能够履行以下汇编代码:

movl $1, %eax  # 将1放入寄存器eax
xchgb %al, lock  # 交流eax的低字节和lock的值,并将成果存入lock
testb %al, %al  # 测验eax的低字节是否为0
jnz busy  # 假如不为0,阐明锁现已被占用,跳转到busy标签,线程挂起堵塞
		   # 假如为0,阐明锁现已成功恳求,持续履行临界区代码		

这段代码的作用是,假如lock的值为0,那么将它和1交流,并将1存入lock,表明锁现已被恳求;假如lock的值为1,那么将它和1交流,并将1存入eax的低字节,表明锁现已被占用。然后经过测验eax的低字节是否为0来判别是否成功恳求了锁。假如成功,就能够进入临界区;假如失利,就需求等候或重试,一般状况下线程恳求锁失利后会挂起堵塞(即睡觉)。

弥补:

  1. $1表明当即数(常量或操作数)1,而%eax表明寄存器eax。这条指令的作用是将当即数1移动到寄存器eax中。不同的汇编语法或许会运用不同的符号来表明当即数和寄存器。例如,在Intel语法的汇编代码中,一般不运用特殊符号来表明当即数和寄存器。
  2. 在x86架构中,eax寄存器是一个32位寄存器,它的低8位能够经过al寄存器来拜访。在这种状况下,咱们只关怀锁值是否为0,而不关怀锁值的其他位。

线程切换

当一个线程在恳求互斥锁的一同被切换后,它的上下文(包括寄存器的值和程序计数器的值)会被保存到内存中。当线程被切换回来持续履行时,它的上下文会被恢复,使得线程能够从之前被切换出去的方位持续履行。

假如一个线程在履行完xchg指令后被切换出去,那么它的上下文(包括al寄存器的值)会被保存到内存中。当线程被切换回来持续履行时,它的上下文会被恢复,al寄存器中的值也会被恢复。这样,线程就能够持续履行test al, al指令,检查锁值是否为0等操作。

每个线程都有自己的一组寄存器值,但这些值并不是独立存在于CPU内的,而是经过上下文切换来完结的。

在履行流的眼中,CPU的寄存器便是保存和切换不同线程上下文的“东西人”,有限的寄存器被一切履行流同享,可是它指向的每个线程的上下文是归于线程私有的,那么在线程看来,寄存器便是当时履行流的上下文(由于寄存器保存了上下文的地址)。

4.3 互斥锁的实质

互斥锁的实质便是一个数字,它关于同享资源是仅有的,是线程能否拜访同享资源的一种标志。只需具有这个标志的线程才干对同享资源操作。而原子指令使得互斥锁的传递也是安全的,因而互斥锁也就能够确保同享资源数据仅有。

原子性操作是从硬件层面支撑的,关于线程而言,原子性操作的两种状况对应着对它们而言最有含义的两种状况(假定有线程A和线程B):

  1. 什么都没做:线程A无锁,阐明对方线程恳求锁失利,那么线程A就能自己恳求锁;
  2. 要做就做完:线程A开释锁,线程B就能恳求锁。

对持有锁的线程而言,其他线程无法与它竞赛锁,这取决于调度器;关于恳求锁的线程而言,假如恳求失利了,那么阐明它现在正在跟其他线程竞赛锁,调度器还未决议让它拿锁;关于其他线程而言,这两种状况便是原子的。

5. 可重入和线程安全

在多线程环境下,可重入和线程安全的差异是一个常见的编程问题。简略地说,可重入函数是指一个函数能够在履行进程中被中止,而且在中止后能够再次被调用而不影响本来的履行状况。线程安全函数是指一个函数能够在多个线程一同调用时,不会引起数据竞赛或许逻辑过错。

5.1 可重入函数

关于可重入函数的比方,能够戳这儿。

可重入是指一个函数能够被多个使命或线程安全地调用,即便在函数履行的进程中被中止或切换,也不会影响函数的正确性和一致性。可重入函数一般遵从以下准则:

  • 不运用大局变量或静态变量,只运用部分变量或传入的参数;
  • 不调用malloc()、free()等或许修正堆的函数;
  • 不调用printf()、scanf()等或许修正规范输入输出的函数;
  • 不调用其他不行重入的函数,如rand()、time()等;
  • 假如有必要拜访同享资源,如硬件设备或文件,要运用互斥锁或封闭中止来维护。

可重入函数在多使命或多线程的环境中非常重要,特别是在中止处理函数中,由于中止或许随时产生,假如中止处理函数不行重入,就或许导致数据过错或体系崩溃。可重入函数也有利于进步程序的模块化和复用性。

可重入是针对函数而言的,假如一个函数被多个线程履行,那么它便是可重入的。例如抢票的比方中,线程函数getTickets()函数便是不行重入函数,由于它操作了大局变量。

这也是咱们在测验多线程代码时,假如不加以操控,在线程函数中打印出来的符号有时会跑到上一行,很紊乱,原因不仅在于调度器调度的战略是不确认的,而且还在于cout、prinntf不是可重入的,即它们不是线程安全的。显示器关于线程而言,是一个同享资源,咱们当然能够对输出操作加锁,但一般咱们不这么做,由于咱们运用打印句子仅仅为了显示内容,而不是操作数据。安全问题首要是要确保数据不能被修正。

5.2 线程安全

在Linux中,假如多个线程并发拜访同一段代码,而且这段代码对大局变量或静态变量进行了操作,那么在没有锁维护的状况下,或许会呈现线程安全问题。

线程安全问题一般是由于多个线程并发拜访同一块数据而导致的。假如这些线程对数据进行了修正操作,那么它们之间或许会相互搅扰,导致数据纷歧致或其他过错。

为了防止这种状况,能够运用锁来维护临界区。锁能够确保同一时刻只需一个线程能够拜访临界区中的数据,然后防止了线程安全问题。

5.3 常见线程不安全的状况

  1. 对大局变量或静态变量进行操作:假如多个线程并发拜访同一个大局变量或静态变量,而且对它进行了修正操作,那么或许会呈现线程安全问题。
  2. 运用非线程安全的函数:一些函数(如strtokgmtime)在多线程环境中运用时或许会呈现线程安全问题。这些函数一般都有线程安全的替代版别(如strtok_rgmtime_r),应该尽量运用这些替代版别。
  3. 没有正确运用锁:假如多个线程需求并发拜访同一块数据,那么应该运用锁来维护这块数据。假如没有正确运用锁,或许锁的粒度不够细,那么或许会呈现线程安全问题。
  4. 没有正确处理信号:在多线程程序中,信号处理函数应该尽量简略,而且防止对大局变量或静态变量进行操作。假如信号处理函数没有正确处理这些问题,那么或许会呈现线程安全问题。

5.4 常见线程安全的状况

其实便是防止线程不安全的状况。

  1. 对部分变量进行操作:部分变量是每个线程独有的,因而多个线程并发拜访同一个函数中的部分变量时不会呈现线程安全问题。
  2. 运用线程安全的函数:一些函数(如strtok_rgmtime_r)是线程安全的,能够在多线程环境中安全地运用。
  3. 正确运用锁:假如多个线程需求并发拜访同一块数据,那么应该运用锁来维护这块数据。假如正确运用了锁,而且锁的粒度满意细,那么程序便是线程安全的。
  4. 正确处理信号:在多线程程序中,假如信号处理函数能够正确处理信号,而且防止对大局变量或静态变量进行操作,那么程序便是线程安全的。

总归,线程安全一般是经过防止同享数据、运用线程安全的函数、正确运用锁和正确处理信号等办法来完结的。

5.5 常见的不行重入的状况

  1. 运用大局变量或静态变量:假如一个函数运用大局变量或静态变量来保存状况,那么它一般不是可重入的。这是由于大局变量和静态变量会在屡次调用之间坚持状况,或许会影响函数的成果。
  2. 调用非可重入函数:假如一个函数调用了非可重入的函数,那么它一般也不是可重入的。这是由于非可重入的函数或许会影响大局状况,然后影响其他函数的成果。

5.6 常见的可重入的状况

  1. 运用部分变量:假如一个函数只运用部分变量来保存状况,那么它一般是可重入的。这是由于部分变量不会在屡次调用之间坚持状况,每次调用都会创立一个新的部分变量。
  2. 不调用非可重入函数:假如一个函数只调用可重入的函数,那么它一般也是可重入的。这是由于可重入的函数不会影响大局状况,因而不会影响其他函数的成果。

总归,可重入性一般是经过防止运用大局变量或静态变量、只调用可重入的函数等办法来完结的。

5.7 可重入和线程安全的联系

可重入性和线程安全性之间的差异在于,可重入性只重视单个线程内部的行为,而线程安全性则重视多个线程之间的交互。可重入函数是线程安全函数的一种。

可重入函数必定是线程安全的,反之纷歧定;被正确加解锁的函数是线程安全的,但纷歧定能确保函数可重入。这是由于可重入函数不会运用任何同享数据或许大局变量,因而不会遭到其他线程的搅扰。而线程安全函数或许会运用同享数据或许大局变量,可是会经过同步机制(如锁、信号量等)来确保数据的一致性和正确性。

举例来说,malloc函数便是一个线程安全但不行重入的函数。它会运用一个大局变量来办理内存分配,因而在多个线程一同调用时,需求加锁来防止数据竞赛。可是假如一个线程在调用malloc时被中止,而且中止处理程序也调用了malloc,那么就会构成死锁,由于同一个线程企图获取现已持有的锁。所以malloc函数不是可重入的。

另一个比方是printf函数,它既不是线程安全也不是可重入的函数。它会运用一个同享的缓冲区来输出字符串,因而在多个线程一同调用时,或许会导致输出紊乱或许丢失。而且假如一个线程在调用printf时被中止,而且中止处理程序也调用了printf,那么就会构成缓冲区溢出或许其他过错。所以printf函数既不是线程安全也不是可重入的。

再例如,一个运用静态变量来保存状况的函数或许是线程安全的(假如它运用了锁来维护静态变量),但它并不是可重入的(由于静态变量会在屡次调用之间坚持状况)。相反,一个运用部分变量来保存状况的函数或许是可重入的(由于部分变量不会在屡次调用之间坚持状况),但它并纷歧定是线程安全的(假如它没有正确处理多线程并发拜访的状况)。

编写可重入和线程安全的函数是一种杰出的编程习惯,它能够进步程序的稳定性和功率。为了完结可重入和线程安全的函数,咱们需求遵从以下准则:

  • 尽量防止运用同享数据或许大局变量,而运用部分变量或许参数传递。
  • 假如有必要运用同享数据或许大局变量,那么需求运用同步机制来维护它们,而且尽量缩短锁的持有时刻。
  • 假如有必要在中止处理程序中调用其他函数,那么需求确保这些函数是可重入的,而且不会与主程序产生死锁或许递归。
  • 假如有必要输出信息到屏幕或许文件,那么需求运用原子操作或许缓冲机制来防止输出紊乱或许丢失。

6. 死锁

6.1 概念

死锁是指两个或多个线程在履行进程中,由于竞赛资源而构成的一种相互等候的现象,若无外力作用,它们都将无法持续履行。死锁一般产生在多个线程一同恳求多个资源时,由于资源分配的不当,导致线程之间相互等候,无法持续履行。

例如线程A和线程B各自具有锁a和锁b,可是它们有了锁还要恳求对方的锁,由于它们恳求的锁现已被占用,最终会导致代码无法推动。

线程同步与互斥【Linux】

留意:线程个数包括但不仅限于2个,实际状况或许会有许多个锁,最终构成环路。在核算机中,或许会由于1个锁产生死锁,即自己恳求自己的锁,这种状况很少见,一般是代码写错了,了解即可。

[自傲]这可是我写的代码,这种状况或许呈现吗?

  1. 代码中或许不止一个锁;
  2. 锁a和锁b的代码或许离得特别远,写代码的时分或许会忘记某个当地现已加过锁。

6.3 比方

例如在之前抢票的线程函数中,假如把开释锁的操作不小心写成了恳求锁,这便是一个锁构成死锁的状况,一个线程自己恳求自己持有的锁,这个线程就会一直无法开释锁,而且会导致正在等候行列中的线程一直挂起。从终端看,便是光标一直在闪烁。

// 线程函数
void* getTickets(void* args)
{
	ThreadData* td = (ThreadData*)args; 	// 获取参数传递的数据
    // ...
	pthread_mutex_lock(td->_pmtx); 		// 加锁
	// ...
	// pthread_mutex_unlock(td->_pmtx); // 解锁
	pthread_mutex_lock(td->_pmtx); 		// 原本是解锁,写成恳求锁
    // ...
}

线程同步与互斥【Linux】

经过 ps 指令检查进程的状况:

[外链图片转存失利,源站或许有防盗链机制,主张将图片保存下来直接上传(img-sPFaTHzT-1681489006786)(…/…/…/Application Support/typora-user-images/image-20230414131715204.png)]

Sl+中的l是lock,表明这个进程处于死锁状况。

6.4 堵塞、挂起和等候

在多线程编程中,堵塞、挂起和等候都是指线程暂时中止履行。它们的差异在于:

  • 堵塞:线程在等候某个条件满意时被堵塞,比方等候 I/O 操作完结或等候获取锁。当条件满意时,线程会主动恢复履行。
  • 挂起:线程被挂起时,它不会主动恢复履行,而是需求其他线程显式地唤醒它。
  • 等候:线程在等候某个条件满意时进入等候状况,比方调用 wait() 办法等候某个条件变量。当条件满意时,线程会被唤醒并持续履行。

在锁的完结中,假如一个线程企图获取一个现已被占用的锁,那么这个线程会被堵塞,并加入到锁的等候行列中。当锁被开释时,操作体系会从等候行列中取出一个或多个线程,唤醒它们,让它们持续履行。

在 Linux 操作体系中,线程被称之为轻量级进程。线程和进程都是经过 task_struct 结构来表明的,它们都能够运用相同的等候行列机制,它们的完结办法和运用办法基本相同。不过,由于线程和进程在操作体系中的办理办法不同,它们运用的等候行列也或许有所不同。

CPU是履行使命的底子,所以关于一切要履行使命的线程和进程,它们需求的资源都是CPU的算力。体系中有许多不同的等候行列,它们等候的是其他资源,例如锁、磁盘、网卡等资源。

例如,当某一个进程在被CPU调度时,该进程需求用到锁的资源,可是此刻锁的资源正在被其他进程运用,那么此刻该进程的状况就会由 R 状况变为某种堵塞状况,比方 S 状况,那么该进程会被移出运转等候行列,被链接到等候锁的资源的资源对应等候行列,而 CPU 则持续调度运转等候行列中的下一个进程。尔后若还有进程需求用到这一个锁的资源,那么这些进程也都会被移出运转等候行列,顺次链接到这个锁的资源等候行列傍边。

直到运用锁的进程现已运用完毕,也便是锁的资源现已安排妥当,此刻就会从锁的资源等候行列中唤醒一个进程,将该进程的状况由 S 状况改为 R 状况,并将其从头链接到运转等候行列,等到 CPU 再次 调度该进程时,该进程就能够运用到锁的资源了。

小结

  • 从操作体系的视点来看,堵塞、挂起和等候都是指线程暂时中止履行。操作体系会将这些线程从 CPU 调度行列中移除,以便为其他安排妥当线程腾出 CPU 时刻。
  • 从用户的视点来看,堵塞、挂起和等候都会导致线程暂时中止呼应。用户或许会感觉到程序运转变慢或卡顿。不过,这些状况一般都是暂时的,当条件满意时,线程会主动恢复履行。

「资源」不限于硬件资源和软件资源。锁的实质是一种软件资源,当咱们恳求锁时,锁或许正在被其他线程所占用,此刻当其他线程再来恳求锁就会失利,那么它会被放到这个锁的资源等候行列中。

既然加锁解锁的进程中或许会呈现问题,何不在线程履行线程函数之前和之后加锁和解锁,而不在线程函数中加解锁,这样就会削减问题呈现的概率了。

在整个线程函数履行期间坚持确认状况或许会导致功用问题。确认的目的是维护同享资源,防止多个线程一同拜访和修正它们。假如一个线程在整个履行期间都坚持确认状况,那么其他线程将无法拜访这些同享资源,即便当时线程并没有实际运用它们。

假如临界区的长度过长,或许会导致功率问题。例如,假如一个线程在临界区内花费了很长时刻,那么其他企图进入临界区的线程将被堵塞,这或许会导致功用下降和呼应时刻变长。尽管在抢票的比方中,临界区现已够短了,可是它依然会大幅下降功率。因而,一般主张尽量缩短临界区的长度,只在临界区内履行必要的操作。

因而,一般主张只在需求拜访同享资源时才确认互斥锁,并在拜访完结后当即解锁,严厉束缚临界区的长度。这样能够最大限度地削减确认时刻,进步程序的并发功用。

6.4 死锁的必要条件

咱们知道,死锁是指一组进程或线程由于相互等候对方占用的资源而无法持续履行的状况。死锁是一个严峻的问题,由于它会导致体系的功用下降,乃至无呼应。因而,了解死锁的原因和解决办法是非常重要的。

在Linux中,死锁的产生需求满意以下四个必要条件:

  1. 互斥条件:每个资源要么现已分配给一个进程或线程,要么便是可用的,不能一同被多个进程或线程占用。
  2. 占有和等候条件(恳求与坚持):现已占有资源的进程或线程能够再恳求新的资源,而不用开释现已占有(坚持)的资源。
  3. 不行抢占条件:现已分配给一个进程或线程的资源不能被其他进程或线程强行夺走,只需该进程或线程自愿开释才干够。
  4. 循环等候条件:存在一个进程或线程的调集,其间每个进程或线程都在等候下一个进程或线程占用的资源,构成一个循环链。

防止死锁

损坏死锁必要条件

假如这四个条件中的任何一个不建立,那么死锁就不会产生。因而,防止或防止死锁的办法有:

  • 损坏这四个必要条件中的一个或多个:

    • 运用信号量或互斥锁来完结对资源的互斥拜访,防止多个进程或线程一同竞赛同一个资源。
    • 运用银行家算法或许预分配算法来分配资源,防止进程或线程在占有资源的一同恳求新的资源,导致资源不足。
    • 运用优先级机制或许超时机制来完结对资源的抢占,为不同类型的锁分配不同的优先级,依照优先级次序获取锁。防止低优先级的进程或线程长时刻占用资源,堵塞高优先级的进程或线程。
    • 运用拓扑排序或许有序分配法来分配资源,防止进程或线程之间构成循环等候的链条,资源一次性分配。或许拜访完临界资源今后,就马上干净地开释锁。
  • 设置锁超时:为每个锁设置一个超时时刻,假如在超时时刻内无法获取锁,则抛弃获取并开释现已获取的锁,防止锁未开释的状况。

  • 运用死锁检测算法:定期运转死锁检测算法,检测体系中是否存在死锁。假如检测到死锁,则采取相应措施进行免除。

咱们暂时只需从理论上了解损坏死锁这四个必要条件即可,其他办法咱们会在实践中学习。

运用 trylock 函数

在Linux中,trylock是一个非堵塞的函数(Immediately),它用于测验确认互斥锁。假如互斥锁当时未被任何线程确认,则调用线程将其确认。假如互斥锁当时被另一个线程确认,则该函数将失利并当即回来,而不会堵塞。也便是说,在一个线程恳求锁之前,它会测验将自己的锁开释掉,相当于自己抛弃了之前恳求的锁,其他线程拿到这个锁后运转完毕今后,我就又能恳求到这个锁了。因而这样就损坏了构成死锁的第二个必要条件,便是让trylock函数赋予指定线程以“推让”的态度,先让对方运用锁。

例如,在pthread库中,能够运用pthread_mutex_trylock函数来测验确认互斥锁。假如成功确认,则回来0;不然回来过错代码。

经过man pthread_mutex_trylock能够检查它的描绘:

线程同步与互斥【Linux】

这段话描绘了pthread_mutex_trylock函数的行为。它与pthread_mutex_lock函数相似,但有一个重要的差异:假如互斥锁当时被任何线程(包括当时线程)确认,则该函数将当即回来,而不会堵塞。

此外,假如互斥锁的类型为PTHREAD_MUTEX_RECURSIVE而且当时由调用线程具有,则互斥锁的确认计数将增加1,而且pthread_mutex_trylock函数将当即回来成功。

简而言之,pthread_mutex_trylock函数用于测验确认互斥锁,假如互斥锁当时被确认,则该函数将当即回来,而不会堵塞。假如互斥锁的类型为PTHREAD_MUTEX_RECURSIVE而且当时由调用线程具有,则该函数将增加互斥锁的确认计数并当即回来成功。

7. 线程同步

7.1 前导概念

同步

在第2点中说到,同步便是多个事情依照必定的次序履行。那么关于线程而言,线程同步指的是和谐多个线程依照某种特定的次序履行,以确保它们能够正确地拜访同享资源。这一般需求运用一些同步机制,如互斥锁、信号量和条件变量等,来操控线程之间的履行次序。

竞态条件

竞态条件是指在多线程程序中,多个线程一同拜访和修正同享资源,导致程序的履行成果依赖于线程的调度次序。这或许会导致程序呈现不确认的行为,乃至产生过错的成果。

为了防止竞态条件,需求运用同步机制来和谐多个线程之间的履行次序。例如,在拜访同享资源之前,能够运用互斥锁来维护对同享资源的拜访。这样,在恣意时刻,都只需一个线程能够拜访同享资源,然后防止了竞态条件。

7.2 引进

就抢票的比方而言,机制一个线程在加锁和解锁之间把10000张票一次性抢完了,这是有或许产生的,或许这个线程的优先级比较高,这种状况是答应存在的,是没错的,可是它是不合理的。为啥说它没错但不合理呢?

例如小明到手机专卖店看手机,假如他第一次去店员说这款手机下个月才上市,第二天小明又去问,第三天…这样做没错,可是每次去问店员都要抽出时刻敷衍小明,明显这是无含义的。而且日子中不会有这么大病的操作,为什么说这个做法没错呢?由于这是符合同步机制的(请看上面的概念),由于同享资源和锁正在被占用,因而它只能不断地问询。经过这个比方能协助咱们了解线程同步机制的含义。

那么关于线程,假如它每次要恳求锁拜访临界资源,操作体系都跟它说:“其他线程正在里面忙呢,一边呆着去(去等候行列里)。”可是这个线程有点大病,无时无刻地都在恳求锁,这样对线程而言是无含义的操作。这便是单纯加锁时线程调度不合理的当地:

  • 假如个别线程的优先级很高,每次都能恳求到锁,但恳求锁之后什么也不做,一直在无含义地恳求锁和开释锁,这就或许导致其他线程长时刻竞赛不到锁,引起饥饿问题。

加锁能够确保在同一时刻只需一个线程履行临界区代码拜访临界资源,但它不能确保让每一个线程都能拜访临界资源。所以咱们需求一个同步机制,使得加锁能更有含义,然后完结高效的线程同步。

由于恳求锁的目的是拜访临界资源,没有锁就不能拜访,所以在描绘中「恳求锁」和「恳求拜访临界资源」是等价的,从代码视点看,它们是有先后联系的。

7.3 线程同步

线程同步一般触及运用一些同步机制,例如咱们抢票比方中的互斥锁,除此之外还有信号量和条件变量等,来操控多个线程之间的履行次序,线程的行为取决于所运用的同步机制。以互斥锁为例:

  • 线程恳求锁失利:当一个线程调用pthread_mutex_lock函数恳求锁失利时,它将被堵塞,直到其他线程开释锁中止。假如运用pthread_mutex_trylock函数,则当恳求锁失利时,该函数将当即回来过错代码。
  • 线程开释锁:它会唤醒等候该锁的其他线程。例如一个线程调用pthread_mutex_unlock函数开释锁时,等候该锁的其他线程将被唤醒,并持续竞赛获取锁。

互斥锁的操作和原理现已介绍过,下面将介绍条件变量。

7.4 条件变量

在恳求锁对临界资源拜访时,条件是临界资源是存在的,所以要首先检测临界资源是否存在。检测操作自身便是拜访临界资源,因而,要检测临界资源也要在加锁和解锁之间(临界区)进行,以确保临界资源的安全。常规办法是检测资源是否安排妥当,假如资源不安排妥当,那么恳求锁就会失利,假如对线程的行为不加以束缚,那么它会一直频频地恳求和开释锁,履行无含义的操作,所以要给这个条件设置一个标志,以表征条件是否安排妥当,就能束缚线程的行为。这个标志叫做条件变量。

怎么束缚线程的行为?跟手机店的比方联系起来:

  1. 不要让线程自己频频地检测临界资源是否安排妥当,让它等着;
  2. 当条件现已安排妥当,告诉这个正在等候的线程,让它恳求锁和拜访临界资源。

条件变量是运用线程间同享的大局变量进行同步的一种机制,条件变量是用来描绘某种资源是否安排妥当的一种数据化描绘。条件变量答应一个或多个线程等候某个同享状况的改变,一同开释现已获取的互斥锁,然后让其他线程有时机修正该状况。当同享状况产生改变时,一个或多个等候的线程能够被唤醒,从头获取互斥锁,并持续履行。

条件变量的首要操作有两个:

  • 等候操作:表明一个线程等候条件变量的“条件”建立而挂起,它需求供给一个互斥锁和一个条件变量作为参数。

  • 等候操作: 表明另一个线程使“条件”建立后唤醒正在等候“条件”的线程。

    1. 开释互斥锁,然后答应其他线程拜访同享资源。
    2. 堵塞当时线程,将其加入到条件变量的等候行列中。
    3. 当收到信号时,唤醒当时线程,并从头获取互斥锁。
    4. 检查条件是否真实建立,假如不建立,则重复上述进程。

唤醒操作表明一个线程告诉其他线程某个条件现已建立,它需求供给一个条件变量作为参数。唤醒操作能够分为两种:单发和播送。单发信号只唤醒一个等候的线程,而播送信号唤醒一切等候的线程。唤醒操作不需求持有互斥锁,但一般在修正同享状况后履行。

唤醒操作也叫信号(signal)操作。

准则

条件变量的运用需求遵从以下准则:

  • 条件变量有必要和互斥锁配合运用,以维护同享状况的一致性。
  • 等候操作有必要在持有互斥锁的状况下履行,以防止竞态条件。
  • 信号操作能够在任何时分履行,但最好在持有互斥锁的状况下履行,以防止误唤醒或漏唤醒。
  • 等候操作有必要运用while循环来检查条件,以应对虚假唤醒或屡次唤醒。
  • 条件变量有必要用pthread_cond_init函数初始化,并用pthread_cond_destroy函数毁掉。

条件变量是一种强大而灵敏的同步东西,能够用于完结各种杂乱的场景,例如生产者-顾客模型、读者-写者模型、线程池等。运用条件变量时,需求留意正确地设置和检查条件,以及合理地分配信号和等候的职责。

cond 族函数

pthread_cond族函数是Linux下的一组用于线程同步的函数。它们包括:

  • pthread_cond_init:初始化条件变量。
  • pthread_cond_wait:堵塞等候条件变量满意。
  • pthread_cond_signal:唤醒一个等候条件变量的线程。
  • pthread_cond_broadcast:唤醒一切等候条件变量的线程。
  • pthread_cond_timedwait:堵塞等候条件变量满意,直到指定时刻。
  • pthread_cond_destroy:毁掉条件变量。

这些函数的回来值都是相同的:当函数履行成功时,它们都回来0。任何其他回来值都表明过错。

pthread_cond_init

原型:

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

参数:

  • cond:需求初始化的条件变量。
  • attr:初始化条件变量的特点,一般设置为 NULL/nullptr 表明默许特点。

和界说互斥锁相似,调用 pthread_cond_init 函数初始化条件变量叫做动态分配,除此之外,还能够静态分配(一般在大局运用):

cpthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 它是一个宏

留意:静态分配的条件变量不需求手动调用函数毁掉。

pthread_cond_destroy

原型:

int pthread_cond_destroy(pthread_cond_t *cond);

参数:

  • cond:需求毁掉的条件变量。

pthread_cond_wait

原型:

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

参数:

  • cond:需求等候的条件变量。
  • mutex:当时线程所处临界区对应的互斥锁。

pthread_cond_broadcast 和 pthread_cond_signal

原型:

int pthread_cond_broadcast(pthread_cond_t cond);
int pthread_cond_signal(pthread_cond_t cond);

参数:

  • cond:唤醒在 cond 条件变量下等候的线程。

差异:

  • pthread_cond_signal 函数用于唤醒等候行列中首个线程。
  • pthread_cond_broadcast 函数用于唤醒等候行列中的悉数线程。

示例

结构

下面的比方将有多个线程履行不同的使命,当它们正在履行使命时,其他线程正在等候。运用了一个函数指针数组保存不同线程函数,同样地,传递给线程的信息能够保存在一个目标中。在加锁之前,首先写好结构,示例创立了三个线程,每个线程履行一个不同的使命,并运用一个函数指针类型作为参数传递给线程函数:

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define THREAD_NUM 3						// 线程数量
typedef void (*func_t)(const string& name); // 界说一个函数指针类型
class ThreadData
{
public:
	// 结构函数
	ThreadData(const string& tname, func_t func)
	: _tname(tname)
	, _func(func)
	{}
public:
	string _tname;			// 线程名
	func_t _func;			// 线程函数指针
};
// 线程函数1
void tFunc1(const string& tname)
{
	while(1) 
	{
		cout << tname << "正在运转使命A..." << endl;
		sleep(1);
	}
}
// 线程函数2
void tFunc2(const string& tname)
{
	while(1) 
	{
		cout << tname << "正在运转使命B..." << endl;
		sleep(1);
	}
}
// 线程函数3
void tFunc3(const string& tname)
{
	while(1) 
	{
		cout << tname << "正在运转使命C..." << endl;
		sleep(1);
	}
}
// 跳转函数
void* Entry(void* args)
{
	ThreadData* td = (ThreadData*)args; 	// 强转获取参数传递的数据
	td->_func(td->_tname); 					// 调用线程函数
	delete td; 								// 毁掉数据
	return nullptr;
}
int main()
{
	pthread_t t[THREAD_NUM];				// 创立线程ID
	func_t f[THREAD_NUM] = {tFunc1, tFunc2, tFunc3}; 		//保存线程函数地址
	for(int i = 0; i < THREAD_NUM; i++)		
	{
		string tname = "thread["; 			// 线程名
		tname += to_string(i + 1); tname += "]";
		ThreadData* td = new ThreadData(tname, f[i]); 		// 创立保存数据的目标
		pthread_create(t + i, nullptr, Entry, (void*)td); 	// 创立线程的一同将姓名和数据目标传递
	}
	for(int i = 0; i < THREAD_NUM; i++)		// 等候线程
	{
   		pthread_join(t[i], nullptr);
   		cout << "thread[" << t[i] << "]已退出..." << endl;
	}
    return 0;
}

进程:

  1. 界说了一个函数指针类型func_t,它承受一个const string&类型的参数,并回来void。这样,咱们能够将不同的函数作为参数传递给线程函数。

  2. 界说了一个类ThreadData,它用来封装线程的数据,包括线程名和线程函数指针。它有一个结构函数,用来初始化这两个成员变量。

  3. 界说了三个线程函数tFunc1、tFunc2和tFunc3,它们分别履行使命A、B和C,并打印出线程名和使命信息。这儿咱们运用了sleep(1)函数,让每个线程暂停一秒钟,以便调查输出成果。

  4. 接着,咱们界说了一个跳转函数Entry,它是pthread_create函数的第三个参数,用来启动线程。它获取传递的数据。调用目标中的线程函数,并传入线程名作为参数。最终,它delete掉td目标(由于它在main函数中是new出来的)。

    函数地址+()操作符,相当于调用这个地址的函数。

  5. 在main函数中,用数组保存线程ID和线程函数的地址。在循环中顺次创立三个线程,调用pthread_create函数,将线程信息传递给它。这样,就将姓名和数据目标传递给了跳转函数Entry。假如创立线程失利,则打印过错信息并退出程序。最终在循环中等候三个线程。

需求留意的是,在运用pthread_create函数时,需求将参数强制转换为void*类型,并在跳转函数中再转换回本来的类型。这在之前有强调过。

线程同步与互斥【Linux】

可是,这个程序并不完善,由于没有指定线程函数要履行的使命,只能手动终止,这仅仅一个结构。

互斥锁、条件变量

在开释互斥锁和条件变量时,开释的次序应该与恳求的次序相反。也便是说,假如你先恳求了互斥锁,然后再恳求条件变量,那么在开释时,应该先开释条件变量,然后再开释互斥锁。也便是说,先恳求的资源应该后开释。

这样做是为了防止死锁。死锁是指两个或多个线程在等候对方开释资源,而导致它们都无法持续履行的状况。假如一切线程都依照相同的次序恳求和开释资源,那么就能够防止死锁的产生。例如:

int main()
{
    pthread_mutex_t mtx;
    pthread_cond_t cond;
    pthread_mutex_init(&mtx, nullptr);
    pthread_cond_init(&cond, nullptr);
	// ... 
    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);
    return 0;
}

注:条件变量一般与互斥锁一同运用,以确保线程安全。

扩大信息

结构中的ThreadData类现已不满意需求了,由于咱们界说了条件变量和互斥锁,要让每个线程被条件变量和互斥锁束缚,就要让它们看到这两个东西。所以需求扩大要传递给线程的信息内容。

typedef void (*func_t)(const string& name, // 界说一个函数指针类型
					   pthread_mutex_t* pmtx, 
					   pthread_cond_t* pcond); 
class ThreadData
{
public:
	// 结构函数
	ThreadData(const string& tname, func_t func, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
	: _tname(tname)
	, _func(func)
	, _pmtx(pmtx)
	, _pcond(pcond)
	{}
public:
	string _tname;			// 线程名
	func_t _func;			// 线程函数指针
	pthread_mutex_t* _pmtx; // 互斥锁指针
	pthread_cond_t* _pcond; // 条件变量指针
};

Entry作为main函数和线程函数之间的软件层,需求多传几个参数;线程函数也需求运用互斥锁地址和条件变量的地址。

// 跳转函数
void* Entry(void* args)
{
    // ...
	td->_func(td->_tname, td->_pmtx, td->_pcond); // 调用线程函数
    // ... 
}

这样每个线程就能拿到同一个内存中的锁,而且能够调用不同的线程函数。这儿能够设置成大局锁,就不用费劲地传参数了,可是大局变量自身假如操控欠好的话也有安全问题。

以其间一个线程函数为例:

void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while(1) 
    {
        pthread_mutex_lock(pmtx);		// 加锁
        pthread_cond_wait(pcond, pmtx);	// 等候条件(失利就进入等候行列)
        cout << tname << "正在运转使命A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
        sleep(1);
    }
}
int main()
{
	// ...
    ThreadData* td = new ThreadData(tname, f[i], &mtx, &cond); 		// 创立保存数据的目标
    // ...
    return 0;
}

调用pthread_cond_wait函数的线程会被当即堵塞,就像进程相同从R->S。被堵塞的线程将会在一开端就放在等候行列中。在同一个条件变量下,上面的代码不加束缚地直接让每个线程从一开端就被堵塞,尽管调度器调度战略是不确认的,可是当一切线程都在等候行列里时,它们的履行次序就现已被确认了(咱们知道,行列是FIFO的)。这个履行次序便是行列中的次序(例如abcd),只需使命还未完结,后续线程被调度的次序必定是固定的,由于调度器只会取行列头部的线程履行使命,这个次序是由行列这种数据结构决议的,而不受调度器调度战略影响。

唤醒线程

条件变量唤醒

在main函数的创立和等候逻辑的中间,能够增加操控线程的逻辑。例如运用 pthread_cond_signal 函数唤醒正在等候的线程,它的参数是条件变量地址,条件变量的作用是用来指定要发送信号的条件变量。

假如没有线程处在堵塞等候状况,pthread_cond_signal 也会成功回来 1。

pthread_cond_signal叫做“条件变量信号”,信号的作用是唤醒,所以我习惯将 signal 称之为唤醒。

int main()
{
	// 创立线程
	sleep(5);
	while(1)
	{
		cout << "唤醒线程..." << endl;
		pthread_cond_signal(&cond);			// 唤醒线程
		sleep(1);
	}
	// 等候线程
    return 0;
}

sleep(5)的作用是确保在创立线程今后,线程有满意的时刻履行到pthread_cond_signal函数,确保一切线程都处于等候状况。

sleep(1)的所用是有节奏地唤醒线程,以更好地调查现象。

输出:

线程同步与互斥【Linux】

输出的成果和上一次没有加锁比起来规整了许多,打印内容也不会混在一同。而且,线程被调度是有必定次序的。

为什么前三轮是ABC,后边是CBA?

尽管一开端每个线程都在等候条件变量cond被触发。在main函数中,运用了pthread_cond_signal来唤醒一个等候cond的线程。这个函数会依照FIFO次序唤醒等候行列中的第一个线程。可是,被唤醒的线程并纷歧定是第一个履行的。上面的代码中运用了sleep(1)来操控唤醒线程的时刻距离。可是,这并不能确保每次唤醒的线程都能取得mtx锁并履行。假如在这个时分有另一个线程现已持有了mtx锁,那么被唤醒的线程依然需求等候。因而,即便依照次序唤醒了等候行列中的线程,它们履行的次序依然是不确认的。

怎么确保被唤醒的必定是等候行列头部的线程?

想要确保打印出来的次序始终是ABCABCABC,那么能够运用一个计数器来操控线程的履行次序。例如,能够界说一个大局变量int turn,并初始化为0。然后,在每个线程函数中,你能够检查turn的值来确认当时是否应该履行。

例如,在tFunc1中,能够在while循环中增加一个while句子,只需当turn == 0时才退出循环并履行打印操作。相似地,在tFunc2和tFunc3中,增加相似的while句子,分别检查turn == 1和turn == 2。

在每个线程函数履行完打印操作后,需求运用互斥锁来维护对turn的拜访,并将其递增1。然后,需求运用pthread_cond_broadcast来唤醒一切等候条件变量的线程。这样,每个线程都会依照预订的次序履行。

下面是一个修正后的tFunc1函数的示例:

void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while(1) 
    {
        pthread_mutex_lock(pmtx);
        while(turn != 0)
        {
            pthread_cond_wait(pcond, pmtx);
        }
        cout << tname << "正在运转使命A..." << endl;
        turn = (turn + 1) % 3;
        pthread_cond_broadcast(pcond);
        pthread_mutex_unlock(pmtx);
        sleep(1);
    }
}

线程同步与互斥【Linux】

在这个示例中,每个线程都会在履行打印操作之前检查turn的值。假如turn的值不等于预订值,那么线程将会等候条件变量。当一个线程履行完打印操作后,它会更新turn的值并唤醒一切等候条件变量的线程。这样,其他线程就能够持续履行。

这儿是一个操控线程调度的一个办法,除此之外,想要依照固定的次序调度线程,也能够运用一个信号量来操控线程的履行次序。信号量是一种用于同步多个线程或进程的东西,它能够用来确保多个线程依照预订的次序履行。

信号量将在下一节学习。

例如,能够界说一个大局变量sem_t sem,并在main函数中运用sem_init(&sem, 0, 1)来初始化它。然后在每个线程函数中运用sem_wait(&sem)来等候信号量,只需当信号量的值大于0时才干持续履行。在履行完打印操作后需求运用sem_post(&sem)来开释信号量,以便其他线程能够持续履行。

下面是一个修正后的tFunc1函数的示例:

void tFunc1(const string& tname)
{
    while(1) 
    {
        sem_wait(&sem);
        cout << tname << "正在运转使命A..." << endl;
        sem_post(&sem);
        sleep(1);
    }
}

在这个示例中,每个线程都会在履行打印操作之前等候信号量。由于信号量的初始值为1,因而只需一个线程能够取得信号量并持续履行。其他线程将会被堵塞,直到当时线程履行完打印操作并开释信号量后才干持续履行。

这样就能够确保每次唤醒的都是等候行列头部的线程,而且它们会依照预订的次序履行。

条件变量播送

下面是一个运用条件变量播送的修正后的代码示例,在大局设置一个bool符号位quit,默许是false。当唤醒线程的逻辑完毕后,再将bool置位true,表明线程现已履行完使命退出了。

那么在线程函数中的while条件应该改成while(!quit),表明线程还未完毕时才会履行它的逻辑。

// 为了阅读体验,省掉了未修正的部分
// 并省掉了tFunc2和tFunc3,它们是相似的。
volatile bool quit = false;
// 线程函数1
void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while(!quit) 
    {
        pthread_mutex_lock(pmtx);		// 加锁
        pthread_cond_wait(pcond, pmtx);	// 等候条件(失利就进入等候行列)
        cout << tname << "正在运转使命A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
        sleep(1);
    }
}
// ...
int main()
{
	// ...
    for(int i = 0; i < THREAD_NUM; i++)
    {
        string tname = "thread[";
        tname += to_string(i + 1); tname += "]";
        ThreadData* td = new ThreadData(tname, f[i], &mtx, &cond);
        pthread_create(t + i, nullptr, Entry, (void*)td);
    }
    sleep(5);
    cout << "----线程操控逻辑开端----" << endl;
    int count = 5;
    while(count)
    {
        cout << "唤醒线程..." << count-- << endl;
        pthread_cond_broadcast(&cond);
        sleep(1);
    }
    cout << "----线程操控逻辑完毕----" << endl;
    quit = true;
    for(int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
        cout << "thread[" << t[i] << "]已退出..." << endl;
    }
	// ...
    return 0;
}

在这个示例中,在每个线程函数中,在调用pthread_cond_wait之前,都会将ready递增1。在main函数中,运用了一个while循环来等候一切的线程都进入等候行列。当ready的值等于线程数量时,就会调用pthread_cond_broadcast来一次性唤醒一切的线程。

线程同步与互斥【Linux】

可是履行了一会就卡住了,而且每轮线程被调度的次序也是纷歧样的。假如多运转几回,乃至还会有纷歧样的成果[取决于调度器]:

线程同步与互斥【Linux】

假如把线程函数中的sleep删掉:

线程同步与互斥【Linux】

看起来有序了。假如把pthread_cond_broadcast换成pthread_cond_signal:

线程同步与互斥【Linux】

  1. 换成pthread_cond_signal后,每次只会打印一条句子,这验证了pthread_cond_broadcast会一同唤醒等候行列中的一切线程。
  2. 当调用pthread_cond_broadcast时,一切等候条件变量的线程都会被唤醒。可是,它们并纷歧定会依照预订次序履行。这是由于,当一个线程被唤醒时,它依然需求取得互斥锁才干持续履行。假如在这个时分有另一个线程现已持有了互斥锁,那么被唤醒的线程依然需求等候。

假如想要确保线程依照预订次序履行,那么能够运用前面说到的办法来操控线程的履行次序。这段代码最大的问题便是不论运用何种办法唤醒线程履行使命,即便它们履行完毕并退出后,程序也无法退出。构成这个问题的原因是线程函数是不完善的。

关于这段代码:

while(!quit)
{
    pthread_mutex_lock(pmtx);		// 加锁
    pthread_cond_wait(pcond, pmtx);	// 等候条件(失利就进入等候行列)
    cout << tname << "正在运转使命A..." << endl;
    pthread_mutex_unlock(pmtx);		// 解锁
    sleep(1);
}

在调用pthread_cond_wait之前,有必要首先要检测临界资源是否安排妥当,这个检测的动作自身便是在拜访临界资环。假如临界资源不安排妥当,那么才会调用pthread_cond_wait函数让线程进入堵塞状况,进入等候行列等候唤醒。换句话说,pthread_cond_wait有必要要在加锁和解锁之间进行,由于咱们规则临界区尽或许短,且完整地包括一切拜访临界资源的代码,因而pthread_cond_wait函数被调用时,线程此刻必定在临界资源中,由于检测这个操作自身就在临界资源中。

在恳求临界资源之前,线程是不知道临界资源时何种状况的,只需它进入了临界资源检测了才知道。假如检测到资源未安排妥当,那么线程会等候,它不会无含义地一直恳求锁和开释锁,由于这样会下降功率。

因而,咱们能够依据详细需求,在调用pthread_cond_wait函数之前判别临界资源是否安排妥当,可是此处很难找到一个描绘描述临界资源安排妥当是何种状况,因而咱们也能够用一个大局变量ready替代检测的操作,它的初始值是false,只需当ready为false时才会调用pthread_cond_wait函数。

void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while(!quit) 
    {
        pthread_mutex_lock(pmtx);		// 加锁
        if(!ready)						// 等候条件(失利就进入等候行列)
        	pthread_cond_wait(pcond, pmtx);	
        cout << tname << "正在运转使命A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
    }
    sleep(1);
}

在main函数中,计数器count的起始值是3,当count==1时,便让ready的值为false,一同运用count在循环中运用pthread_cond_wait函数一次性唤醒一切线程,检查现象:

线程同步与互斥【Linux】

这个程序的代码:

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#define THREAD_NUM 3						// 线程数量
typedef void (*func_t)(const string& name, 	// 界说一个函数指针类型
					   pthread_mutex_t* pmtx, 
					   pthread_cond_t* pcond); 
volatile bool quit = false;
volatile bool ready = false;
class ThreadData
{
public:
	// 结构函数
	ThreadData(const string& tname, func_t func, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
	: _tname(tname)
	, _func(func)
	, _pmtx(pmtx)
	, _pcond(pcond)
	{}
public:
	string _tname;			// 线程名
	func_t _func;			// 线程函数指针
	pthread_mutex_t* _pmtx; // 互斥锁指针
	pthread_cond_t* _pcond; // 条件变量指针
};
// 线程函数1
void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while(!quit) 
    {
        pthread_mutex_lock(pmtx);		// 加锁
        if(!ready)						// 等候条件(失利就进入等候行列)
        	pthread_cond_wait(pcond, pmtx);	
        cout << tname << "正在运转使命A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
    }
    sleep(1);
}
// 线程函数2
void tFunc2(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while(!quit) 
    {
        pthread_mutex_lock(pmtx);
         if(!ready)	pthread_cond_wait(pcond, pmtx);
        cout << tname << "正在运转使命B..." << endl;
        pthread_mutex_unlock(pmtx);
    }
    sleep(1);
}
// 线程函数3
void tFunc3(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    while(!quit) 
    {
        pthread_mutex_lock(pmtx);
         if(!ready)	pthread_cond_wait(pcond, pmtx);
        cout << tname << "正在运转使命C..." << endl;
        pthread_mutex_unlock(pmtx);
    }
    sleep(1);
}
void* Entry(void* args)
{
    ThreadData* td = (ThreadData*)args;
    td->_func(td->_tname, td->_pmtx, td->_pcond);
    delete td;
    return nullptr;
}
int main()
{
    pthread_mutex_t mtx;
    pthread_cond_t cond;
    pthread_mutex_init(&mtx, nullptr);
    pthread_cond_init(&cond, nullptr);
    pthread_t t[THREAD_NUM];
    func_t f[THREAD_NUM] = {tFunc1, tFunc2, tFunc3};
    for(int i = 0; i < THREAD_NUM; i++)
    {
        string tname = "thread[";
        tname += to_string(i + 1); tname += "]";
        ThreadData* td = new ThreadData(tname, f[i], &mtx, &cond);
        pthread_create(t + i, nullptr, Entry, (void*)td);
    }
    sleep(3);
    cout << "----线程操控逻辑开端----" << endl;
    int count = 3;
    while(count)
    {
    	if(count == 1) ready = true; 
        cout << "唤醒线程..." << count-- << endl;
        pthread_cond_broadcast(&cond);
        sleep(1);
    }
    cout << "----线程操控逻辑完毕----" << endl;
    // pthread_cond_broadcast(&cond);
    quit = true;
    for(int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
        cout << "thread[" << t[i] << "]已退出..." << endl;
        sleep(1);
    }
    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);
    return 0;
}

订正,GIF中的代码中,在循环外部也调用了pthread_cond_broadcast,这不影响成果,我在循环内部调用pthread_cond_broadcast的目的是调查资源检测成功后,不调用pthread_cond_wait的线程的行为。

当然pthread_cond_broadcast也能够换成pthread_cond_signal实验,成果是这样的:

线程同步与互斥【Linux】