C++线程同步

线程同步

为什么需要线程同步

对于下面的代码:

#include 
#include 
#include 
#include 
using namespace std;

#define  NUM_THREAD 100

long long num = 0;

void* thread_inc(void* arg){
    for (int i = 0; i < 5e5; i++)
        num++;
    return nullptr;
}

void* thread_des(void* arg){
    for (int i = 0; i < 5e5; i++)
        num--;
    return nullptr;
}

int main(int argc, char *argv[]){
    pthread_t pthread_id[NUM_THREAD];

    for (int i = 0; i < NUM_THREAD; i++){
        if (i%2)
            pthread_create(&pthread_id[i], NULL, thread_inc, NULL);
        else
            pthread_create(&pthread_id[i], NULL, thread_des, NULL);
    }

    for (int i = 0; i < NUM_THREAD; i++)
        pthread_join(pthread_id[i], NULL);

    cout << "result: " << num << endl;

    return 0;
}

上述示例中共创建了100线程,其中一半执行 thread_inc 函数中的代码,另一半执行 thread_des 函数中的代码,全部变量经过增减过程后应该结果为0,但是实际的结果为:

sizeof long long : 8
result : -23731416	//可能每次运行完之后的结果都不相同

多个线程访问同一变量是问题

多个线程访问同一变量可能会导致以下问题:

  1. 竞争条件:当多个线程同时读取、写入同一变量时,可能会发生竞争条件,导致程序的行为出现不可预期的错误。例如,多个线程同时读取某个计数器变量的值,进行加一操作后再写回该变量,如果不加同步措施,就可能会导致计数器值的错误累加。
  2. 互斥问题:当多个线程需要对同一资源进行修改时,需要保证在某一时刻只有一个线程可以访问该资源,否则就可能会发生互斥问题。例如,多个线程需要向同一文件中写入数据,如果不加同步措施,就可能会导致数据被覆盖或者混乱。
  3. 死锁问题:当多个线程需要互相等待对方释放资源时,就可能会发生死锁问题,导致程序无法继续执行。例如,线程A需要获取资源1和资源2,线程B需要获取资源2和资源1,如果不加同步措施,就可能会导致A获取资源1后等待B释放资源2,B获取资源2后等待A释放资源1,从而导致死锁。

综上所述,多个线程访问同一变量可能会导致一系列问题,需要通过使用同步措施来避免这些问题的发生。常用的同步措施包括互斥锁、条件变量、信号量等,可以有效地避免多线程访问同一变量导致的问题。

上述代码的问题如下:

2 个线程正在同时访问全局变量 num

任何内存空间,只要被同时访问,都有可能发生问题。

因此,线程访问变量 num 时应该阻止其他线程访问,直到线程 1 运算完成。这就是同步(Synchronization)

临界区

临界区位置

临界区是指一段同时只能被一个线程执行的代码段。

那么在刚才代码中的临界区位置是:

函数内同时运行多个线程时引发问题的多条语句构成的代码块

全局变量 num 不能视为临界区,因为他不是引起问题的语句,只是一个内存区域的声明。下面是刚才代码的两个 main 函数

void *thread_inc(void *arg)
{
    for (int i = 0; i < 50000000; i++)
        num += 1;		//临界区
    return NULL;
}

void *thread_des(void *arg)
{
    for (int i = 0; i < 50000000; i++)
        num -= 1;		//临界区
    return NULL;
}

由上述代码可知,临界区并非 num 本身,而是访问 num 的两条语句,这两条语句可能由多个线程同时运行,也是引起这个问题的直接原因。

产生问题的原因可以分为以下三种情况:

  • 2 个线程同时执行 thread_inc 函数
  • 2 个线程同时执行 thread_des 函数
  • 2 个线程分别执行 thread_incthread_des 函数

比如发生以下情况:

线程 1 执行 thread_inc 的 num+=1 语句的同时,线程 2 执行 thread_des 函数的 num-=1 语句

也就是说,两条不同的语句由不同的线程执行时,也有可能构成临界区。前提是这 2 条语句访问同一内存空间。

前面讨论了线程中存在的问题,下面就是解决方法,线程同步。

同步的两面性

线程同步用于解决线程访问顺序引发的问题。需要同步的情况可以从如下两方面考虑。

  • 同时访问同一内存空间时发生的情况
  • 需要指定访问同一内存空间的线程顺序的情况

情况一之前已经解释过,下面讨论情况二。这是「控制线程执行的顺序」的相关内容。假设有 A B 两个线程,线程 A 负责向指定的内存空间内写入数据,线程 B 负责取走该数据。所以这是有顺序的,不按照顺序就可能发生问题。所以这种也需要进行同步。

互斥量

互斥量(mutex)是一种用于实现多线程同步的机制,主要用于保护共享资源,防止多个线程同时访问和修改同一共享资源,从而避免产生数据竞争等问题。

互斥锁(英语:英语:Mutual exclusion,缩写 Mutex)是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全域变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。一个程序、进程、线程可以拥有多个临界区域,但是并不一定会应用互斥锁。

通俗的说就互斥量就是一把优秀的锁,当临界区被占据的时候就上锁,等占用完毕然后再放开。

下面是互斥量的创建及销毁函数。

#include 

int pthread_mutex_init(pthread_mutex_t *mutex,
                       const pthread_mutexattr_t *attr);	//创建互斥量
                       
int pthread_mutex_destroy(pthread_mutex_t *mutex);		    //销毁互斥量
/*
成功时返回 0,失败时返回其他值
mutex : 创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址
attr : 传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL
*/

从上述函数声明中可以看出,为了创建相当于锁系统的互斥量,需要声明如下 pthread_mutex_t 型变量:

pthread_mutex_t mutex

该变量的地址值传递给 pthread_mutex_init 函数,用来保存操作系统创建的互斥量(锁系统)。调用 pthread_mutex_destroy 函数时同样需要该信息。如果不需要配置特殊的互斥量属性,则向第二个参数传递 NULL 时,可以利用 PTHREAD_MUTEX_INITIALIZER 进行如下声明:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

推荐尽可能的使用 pthread_mutex_init 函数进行初始化,因为通过宏进行初始化时很难发现发生的错误。

下面是利用互斥量锁住或释放临界区时使用的函数。

#include 

int pthread_mutex_lock(pthread_mutex_t *mutex);		//互斥量加锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);	//互斥量解锁

//成功时返回 0 ,失败时返回其他值

函数本身含有 lock unlock 等词汇,很容易理解其含义。进入临界区前调用的函数就是 pthread_mutex_lock 。调用该函数时,发现有其他线程已经进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。

接下来利用互斥量来解决示例[thread4.cpp]中遇到的问题:

#include 
#include 
#include 
#include 
using namespace std;

#define NUM_THREAD 100

long long num = 0;
pthread_mutex_t mutex;  //互斥量

void* thread_inc(void *arg){
    pthread_mutex_lock(&mutex);  	//加锁
    for (int i = 0; i < 500000000; i++)
        num++;  		//临界区
    pthread_mutex_unlock(&mutex);	//解锁
    return NULL;
}

void* thread_des(void *arg){
    for (int i = 0; i < 500000000; i++){
        pthread_mutex_lock(&mutex);
        num--;          //临界区
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main(int argc, char* argv[]){
    pthread_t pthread_id[NUM_THREAD];

    pthread_mutex_init(&mutex, NULL);    //互斥量的创建

    cout << "sizeof long long : " << sizeof(long long) << endl;

    for (int i = 0; i < NUM_THREAD; i++){
        if (i%2)
            pthread_create(&pthread_id[i], NULL, thread_inc, NULL);
        else
            pthread_create(&pthread_id[i], NULL, thread_des, NULL);
    }

    for (int i = 0; i < NUM_THREAD; i++)
        pthread_join(pthread_id[i], NULL);

    cout << "result : " << num << endl;

    pthread_mutex_destroy(&mutex);   	//互斥量的销毁

    return 0;
}

输出:

sizeof long long : 8
result : 0

从运行结果可以看出,通过互斥量机制得出了正确的运行结果。

在代码中:

void *thread_inc(void *arg)
{
    int i;
    pthread_mutex_lock(&mutex); 	//上锁
    for (i = 0; i < 50000000; i++)
        num += 1;
    pthread_mutex_unlock(&mutex); //解锁
    return NULL;
}

以上代码的临界区划分范围较大,但这是考虑如下优点所做的决定:

最大限度减少互斥量 lock unlock 函数的调用次数

信号量

Linux中的信号量是一种线程同步机制,它可以协调多个线程对共享资源的访问。

Linux中的信号量分为两种类型**:二进制信号量和计数信号量。**

二进制信号量是一种二元信号量,只有两个值:0和1。它用于解决对共享资源的独占访问问题。当一个线程对二进制信号量加锁时,如果该信号量的值为1,则该线程可以继续执行;如果该信号量的值为0,则该线程会被阻塞,直到其他线程释放该信号量。

计数信号量是一种计数型信号量,它可以有多个值,用于控制多个线程对共享资源的访问。当一个线程对计数信号量加锁时,如果该信号量的值大于0,则该线程可以继续执行;如果该信号量的值等于0,则该线程会被阻塞,直到其他线程释放该信号量。

Linux中的信号量使用系统调用 sem_initsem_waitsem_postsem_destroy进行创建、加锁、解锁和销毁操作。其中,sem_init 用于初始化信号量,sem_wait 用于加锁操作,sem_post 用于解锁操作,sem_destroy 用于销毁信号量。

信号量(英语:Semaphore)又称为信号标,是一个同步对象,用于保持在0至指定最大值之间的一个计数值。当线程完成一次对该semaphore对象的等待(wait)时,该计数值减一;当线程完成一次对semaphore对象的释放(release、post)时,计数值加一。当计数值为0,则线程等待该semaphore对象不再能成功直至该semaphore对象变成signaled状态。semaphore对象的计数值大于0,为signaled状态;计数值等于0,为 nonsignaled 状态.

semaphore对象适用于控制一个仅支持有限个用户的共享资源,是一种不需要使用忙碌等待(busy waiting)的方法。

信号量的概念是由荷兰计算机科学家艾兹赫尔·戴克斯特拉(Edsger W. Dijkstra)发明的,广泛的应用于不同的操作系统中。在系统中,给予每一个进程一个信号量,代表每个进程当前的状态,未得到控制权的进程会在特定地方被强迫停下来,等待可以继续进行的信号到来。如果信号量是一个任意的整数,通常被称为计数信号量(Counting semaphore),或一般信号量(general semaphore);如果信号量只有二进制的0或1,称为二进制信号量(binary semaphore)。

在Linux系统中,二进制信号量(binary semaphore)又称互斥锁(Mutex)。

下面介绍信号量,在互斥量的基础上,很容易理解信号量。此处只涉及利用「二进制信号量」(只用 0 和 1)完成**「控制线程顺序」**为中心的同步方法。下面是信号量的创建及销毁方法:

#include 

int sem_init(sem_t *sem, int pshared, unsigned int value);	// 信号量的创建
int sem_destroy(sem_t *sem);						      							// 信号量的销毁
/*
成功时返回 0 ,失败时返回其他值
sem : 创建信号量时保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值
pshared : 传递其他值时,创建可由多个继承共享的信号量;传递 0 时,创建只允许 1 个进程内部使用的信号量。需要完成同一进程的线程同步,故为0. 如果pshared参数为非零,则信号量可以在不同进程之间共享。
value : 指定创建信号量的初始值
*/

上述的 shared 参数超出了我们的关注范围,故默认向其传递为 0 。下面是信号量中相当于互斥量 lock unlock 的函数。

#include 

int sem_wait(sem_t *sem);	// sem_wait函数用于获取一个信号量,信号量-1
int sem_post(sem_t *sem);	// sem_post函数用于释放一个信号量,信号量+1
/*
成功时返回 0 ,失败时返回其他值
sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增1,传递给 sem_wait 时信号量减一
*/

调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录这「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1 ,调用 wait_wait 函数时减一。但信号量的值不能小于 0 ,因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1 ,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1

sem_wait(&sem);		//信号量变为0...
/* 临界区的开始
...
临界区的结束*/
sem_post(&sem);		//信号量变为1...

上述代码结构中,调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。接下来的代码是信号量机制的代码。下面代码并非是同时访问的同步,而是关于控制访问顺序的同步,该场景为:

线程 A 从用户输入得到值后存入全局变量 num ,此时线程 B 将取走该值并累加。该过程一共进行 5 次,完成后输出总和并退出程序。

[semaphore.cpp]代码:

#include 
#include 
#include 
using namespace std;

static int num;
static sem_t sem_one, sem_two;  //使用两个信号量

void* read(void *arg){
    for (int i = 0; i < 5; i++){
        cout << "Input num : ";
        sem_wait(&sem_two);     //对sem_two信号量加锁
        cin >> num;
        sem_post(&sem_one);     //对sem_one进行释放
    }
    return NULL;
}

void* accu(void *arg){
    int sum = 0;
    for (int i = 0; i < 5; i++){
        sem_wait(&sem_one);		//对sem_one进行加锁
        sum += num;
        sem_post(&sem_two);		//对sem_two进行解锁
    }
    cout << "result : " << sum << endl;
    return NULL;
}

int main(int argc, char *argv[]){
    sem_init(&sem_one, 0, 0);  //创建sem_one信号量只允许一个进程内部使用的信号量,初始值为0
    sem_init(&sem_two, 0, 1);

    pthread_t id_t1, id_t2;
    pthread_create(&id_t1, NULL, read, NULL);
    pthread_create(&id_t2, NULL, accu, NULL);

    pthread_join(id_t1, NULL);
    pthread_join(id_t2, NULL);

    sem_destroy(&sem_one);
    sem_destroy(&sem_two);
    return 0;
}

输出:

Input num : 1
Input num : 2
Input num : 3
Input num : 4
Input num : 5
result : 15

如果将上述代码进行修改

sem_init(&sem_one, 0, 0);  //两个信号量的初值都为0
sem_init(&sem_two, 0, 0);
         
//accu函数         
sem_post(&sem_two);   	  //对sem_two信号量加锁 0 -> 1
sum += num; //临界区
sem_wait(&sem_one);       //对sem_one进行释放   1 -> 0        

如果将sem_init(&sem_one, 0, 0);sem_init(&sem_two, 0, 0);中的信号量初始值都设置为0,那么这两个信号量在初始化时没有可用的资源,也就是说它们都被锁住了。在这种情况下,代码将会发生死锁。

具体来说,当读取线程首先执行时,它会对sem_two进行加锁操作,由于sem_two的初始值为0,所以该线程会被阻塞,一直等待sem_two变为可用。然而,由于计算线程还没有开始执行,它也无法对sem_one进行解锁操作,因此读取线程会一直等待,最终导致程序无法继续执行下去。

因此,如果将sem_init(&sem_one, 0, 0);sem_init(&sem_two, 0, 0);中的信号量初始值都设置为0,应该在程序中进行必要的修改以避免死锁的发生。例如,可以将其中一个信号量的初始值设置为1,以确保程序一开始就有可用的资源。

会导致得不到正确的结果

Input num : 1
Input num : 2
Input num : 3
Input num : 4
Input num : 5
result : 10

结果为10不是我们想要的

条件变量

条件变量类型:pthread_cond_t

  1. int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
    • 作用:初始化条件变量。
    • 用法:通过该函数来初始化一个条件变量,将一个已分配的pthread_cond_t结构体对象与之关联。attr参数通常可以设置为NULL,以使用默认属性。
  2. int pthread_cond_destroy(pthread_cond_t cond);
    • 作用:销毁条件变量。
    • 用法:用于释放已初始化的条件变量,一旦不再需要条件变量,应该使用该函数进行清理。在销毁之前,确保没有线程在等待该条件变量。
  3. int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t mutex);
    • 作用:等待条件变量满足。
    • 用法:在使用条件变量的线程中,当某个条件不满足时,线程可以调用此函数将自己阻塞,等待其他线程通过pthread_cond_signalpthread_cond_broadcast来通知条件已满足。在调用此函数之前,必须获取互斥锁(mutex)。
  4. int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
    • 作用:等待条件变量满足,但有超时限制。
    • 用法:与pthread_cond_wait类似,但允许设置一个超时时间,如果超过指定时间条件仍未满足,则函数会返回。abstime参数指定了等待的最长时间。
  5. int pthread_cond_signal(pthread_cond_t *cond);
    • 作用:通知一个等待条件变量的线程。
    • 用法:当某个线程满足了条件,可以调用此函数通知一个等待条件变量的线程继续执行。通常用于唤醒单个等待线程。
  6. int pthread_cond_broadcast(pthread_cond_t *cond);
    • 作用:通知所有等待条件变量的线程。
    • 用法:与pthread_cond_signal不同,此函数用于唤醒所有等待条件变量的线程,使它们都能继续执行。

这些函数一起用于实现多线程程序中的条件同步。通常,它们与互斥锁一起使用,以确保在访问共享资源之前线程能够等待特定的条件满足。

#include 
#include 
#include 
using namespace std;

pthread_cond_t cond;
pthread_mutex_t m_mutex;  // 利用条件变量来实现线程同步

struct ListNode{
    int val;
    ListNode* next;

    ListNode():val(-1), next(nullptr){}
    ListNode(int v):val(v), next(nullptr){}
};

ListNode* head = nullptr;

void* producer(void* arg){
    // 生产者
    while (1){
        pthread_mutex_lock(&m_mutex);
        ListNode* temp = new ListNode(rand()% 100); // 生成一个随机数节点
        temp->next = head;
        head = temp;

        cout << "add node, num: " << temp->val << " tid: " << pthread_self() << endl;

        // 只要新建了一个节点,就可以通知消费者消费
        pthread_cond_signal(&cond);

        pthread_mutex_unlock(&m_mutex);
        usleep(100);
    }

    return nullptr;
}

void* customer(void* arg){
    while (1){
        pthread_mutex_lock(&m_mutex);
        ListNode* temp = head;
        if (temp){
            head = head->next;
            cout << "delete node: " << temp->val << " tid: " << pthread_self() << endl;
            free(temp);
            usleep(100);
        }else
            pthread_cond_wait(&cond, &m_mutex);

        pthread_mutex_unlock(&m_mutex);

        return nullptr;
    }
}

int main(int argc, char* argv[]){
    pthread_mutex_init(&m_mutex, NULL);
    pthread_cond_init(&cond, NULL);

    pthread_t p_tids[5], c_tids[5];

    for (int i = 0; i < 5; i++){
        pthread_create(&p_tids[i], NULL, producer, NULL);
        pthread_create(&c_tids[i], NULL, customer, NULL);
    }

    for (int i = 0; i < 5; i++){
        pthread_detach(p_tids[i]);
        pthread_detach(c_tids[i]);
    }

    while (1){
        usleep(10);
    }

    return 0;
}

你可能感兴趣的:(c++,线程同步,多线程)