线程的同步与互斥

在之前的“进程间通信—-信号量”一文中,有介绍过进程的同步与互斥。在本文中,着重来介绍线程的互斥与同步。

线程的互斥

我们知道,一个进程中的多个线程之间是共享进程所拥有的一个地址空间。它们除了私有上下文,私有栈等少量资源是独有的,其余大部分资源都是共享的。比如说,文件描述符表。当一个线程以只读的方式打开某一文件时,与它所在同一进程的其他线程也是可以读取该文件的。多个线程之间共享资源。这对于各线程之间的通信是非常方便的。它们可以通过改变共享资源来达到通信的目的。

但是,资源的共享同时也带来一个问题。例如,对于一个全局变量count初始为10,一个线程要对它实施加1操作。可分为下述几步:
(1)将count的值从内存中加载到cpu里
(2)cpu对count进行加1操作,变为11
(3)将计算完成的count即11再放入内存中。

在上述三个步骤中,如果线程1在执行完(2)之后,被切换至另一线程2。线程2的任务也是对count进行加1操作。因为线程1的步骤(3)还没完成,也就是说内存中count的值还是10。所以线程2在进行加1操作前看到的count为10,在进行完加操作后,此时内存中的count变为了11。然后在切换回线程1。此时,接着上次被切换的地方继续执行。也就是线程1继续执行(3),此时,线程1将11放入内存中。两个线程均完成任务后,count的值变为11。

在上述过程中,一共有两个线程对count进行了加1操作,最终的结果应该变为12,但是,我们最终看到的确实11。这就导致了数据的不一致。

出现上述错误的原因是:首先全局变量count可以被多个线程看到,其次对全局变量的操作不是原子性操作。

上述第一个原因是没有办法解决的。所以只能从第二个原因入手,如果将上述三个步骤合并为一个步骤。某一线程在操作时要么不做,要么做完了。就不会出现上述的问题。

所以,要想保证数据的准确性,就要确保上述操作的原子性。即要么不做,要么一次做完。不会出现中间只做了一部分的状态。但是对整数的操作确实有三部分组成,不能一次全部做完。此时,就需要保证在一个线程三个步骤没有完成之前,即使被切换到其他线程,也不能对count进行修改。这样就保证了对全局变量操作的原子性。

上述类似全局变量这种资源,可以被多个线程看到,但是在一次只允许一个线程对其进行访问就称为临界资源。包含临界资源的代码段称为临界区

在对临界资源进行访问时,一次只允许一个线程对其进行操作。即要求对临界资源进行互斥访问。访问临界资源的各线程之间互为互斥关系。

互斥量

可以通过互斥量来保证各线程对临界资源访问的互斥性。

本质上可以将互斥量理解为一把锁。在一个线程访问临界资源前,对临界资源上锁,此时只有该线程可以访问,即使切换到其他线程,因为临界资源被锁着,所以其他线程也不能访问。再次切回到原线程时,临界资源并没有做任何的修改,可以在被切换处继续进行操作。等所有操作均完成后,在解锁,然后其他线程才可以对其进行访问。

有关互斥量的一些接口函数:

1. 初始化互斥量
方法一:静态分配

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

方法二:动态分配

int pthread_mutex_init(pthread_mutex_t* mutex,const pthread_mutexattr_t* attr);

参数:mutex为要初始化的互斥量,attr一般设置为NULL

2. 互斥量加锁

int pthread_mutex_lock(pthread_mutex_t* mutex);

在加锁时:
(1)如果互斥量处于未锁状态,则加锁成功
(2)如果互斥量处于已锁状态,则该线程会被阻塞等待,直到互斥量解锁,此时返回错误码。
(3)如果同时有几个线程同时申请互斥量,没有竞争到互斥量的线程会被阻塞等待,等待互斥量解锁。此时,返回错误码。

3. 互斥量解锁

int pthread_mutex_unlock(pthread_mutex_t* mutex);

成功返回0,失败返回错误码。

4. 销毁互斥量

int pthread_mutex_destroy(pthread_mutex_t* mutex);

销毁时需注意:
(1)使用静态分配的互斥量不需要销毁
(2)不能对一个还没解锁的互斥量进行销毁
(3)已经销毁的互斥量,不能尝试再对其加锁

下面模拟实现一个售票系统,分别演示对临界资源未互斥访问和互斥访问时的两种现象:

创建四个线程来模拟四个人来进行买票ticket,票数初始为100。这四个人轮流买票,直到买完为止。

未互斥访问时:

#include<stdio.h>                                                                                                                     
#include<pthread.h>
#include<unistd.h>

int ticket = 100;

void* buy_ticket(void* arg)
{
    while(1)//当还有票时,就一直买票。
    {   
        if(ticket > 0)
        {   
            usleep(500);//当线程很多时,可能在此处被切换
            printf("ticket remains %d\n",ticket);//当前还有余票多少张
            printf("pthread %d buy one\n",(int)arg);//线程arg买一张票
            ticket--;//买完后票数减1
        }   
        else//票数为0,停止买票
        {   
            break;
        }
    }
    return (void*)1;
}
int main()
{
    pthread_t tid[4];//定义四个用户级线程id
//创建四个线程模拟四个买票的人
    int i = 0;
    for(;i < 4;++i)
    {
        pthread_create(&tid[i],NULL,buy_ticket,(void*)i);
    }

    //主线程等待四个新线程退出
    for(i = 0;i < 4;++i)
    {
        pthread_join(tid[i],NULL);
    }
    return 0;
}

运行结果如下:

ticket remains 3
pthread 1 buy one
ticket remains 2
pthread 0 buy one
ticket remains 1
pthread 2 buy one
ticket remains 0
pthread 3 buy one
ticket remains -1
pthread 1 buy one
ticket remains -2
pthread 0 buy one

在上述结果中,票数还存在着负数,这显然是不可能的。造成这样的结果主要是:

比如说线程1看到的票数为1时,它进入if(ticket > 0)中,进入后,被切换至线程2(实际中有很多线程时有可能在此处被切换,所以这里用usleep模拟被切换)。线程2购票后将ticket减为0。再次切换回线程1,线程1在切换处继续往下进行,将ticket减为-1。

同时,对ticket–操作不是原子操作,此时可能会造成可能买了两张票,但实际数据只记录只买了一张票的情形,与上述count的例子相同。

因此,在对临界资源ticket进行操作时,要保证对其进行互斥访问。所以,下面利用互斥量来实现对临界资源ticet的互斥操作:

#include<stdio.h>
#include<pthread.h>
#include<unistd.h>

int ticket = 100;

//对共享资源进行访问时,要申请互斥量对共享资源上锁
pthread_mutex_t mutex;//定义互斥量

void* buy_ticket(void* arg)
{
    while(1)//当还有票时,就一直买票。
    {   
        pthread_mutex_lock(&mutex);//对共享资源上锁                                                                                   
        if(ticket > 0)
        {
            usleep(500);
            printf("ticket remains %d\n",ticket);//当前还有余票多少张
            printf("pthread %d buy one\n",(int)arg);//线程arg买一张票
            ticket--;//买完后票数减1
            pthread_mutex_unlock(&mutex);//对共享资源解锁
        }
        else//票数为0,停止买票
        {
            pthread_mutex_unlock(&mutex);//对共享资源解锁
            break;
        }
        usleep(500);//在加了互斥量后,上述if语句中中的usleep就看不到效果了.因此,要在这里加usleep,模拟测试线程很多时,在此处被切换给其他进程的情况
        //pthread_mutex_unlock;//如果将上述两个分支语句中的解锁操作合并在该处
        //若执行else语句时,会直接跳出while循环,线程结束,而互斥量还未解锁的问题,
        //再主线程中就会出现对未解锁的互斥量销毁的问题,从而造成阻塞
        //所以,解锁互斥来量的语句不能加在该处
    }
    return (void*)1;
}
int main()
{
    pthread_mutex_init(&mutex,NULL);//初始化互斥量
    pthread_t tid[4];//定义四个用户级线程id
    //创建四个线程模拟四个买票的人
    int i = 0;
    for(;i < 4;++i)
    {
        pthread_create(&tid[i],NULL,buy_ticket,(void*)i);
    }
    //主线程等待四个新线程退出
    for(i = 0;i < 4;++i)
    {
        pthread_join(tid[i],NULL);
    }
    pthread_mutex_destroy(&mutex);//销毁互斥量
    return 0;
}

运行结果如下:

ticket remains 3
pthread 2 buy one
ticket remains 2
pthread 1 buy one
ticket remains 1
pthread 0 buy one

此时,利用互斥量保证了各线程对临界资源的互斥访问,结果正确。

线程的同步

通过对临界资源的互斥访问,可以保证数据的安全性。但是互斥访问同时可能造成死锁等效率问题。

比如:线程1对链表进行插入节点的操作,线程2对链表进行循环删除节点的操作。通过上述的互斥量保证每次对临界资源链表进行操作时可以互斥访问。假设线程2的优先级比线程1高。

某一时刻,链表中的节点删除完了。由于线程2比线程1优先级高,所以当两个线程共同申请互斥量对临界资源进行访问时,必定线程2先申请到,但是链表已经空了,线程2即使申请到了无事可做。所以,它解锁互斥量。之后二者还是共同竞争互斥量,还是线程2申请到。线程1由于优先级问题一直申请不到互斥量,进而不能向链表中插入节点。所以线程2一直做着申请互斥量->无事可做->解锁循环量的操作。

这样线程2便一直占着锁而做着无用的事情,线程1因为想做有用的事情而申请不到锁。二者便陷入了低效的工作状态。

为了能够高效的工作,就要保证二者之间的协同性。当链表为空时,就必须让线程1先运行。链表不为空时,二者竞争运行。

这种通过对多个线程在执行次序上进行协调,使并发执行的诸线程间能够按照一定的规则(或时序)共享临界资源,并能够高效合作的机制就称为线程的同步。

线程间的同步可以有以下两种方式来保证:
(1)条件变量
(2)信号量

条件变量

可以通过设置条件变量来保证线程间的同步问题。

例如在上述的例子中,当链表为空时,要使线程1先运行,就要使线程2挂起等待。这样就不会因为优先级问题而导致线程1不能执行。

所以,可以在链表为空这个条件下,设置一个条件变量来使线程2在该条件下挂起等待。进而切换到线程1使线程1先运行。

下面介绍条件变量的一些接口函数:
1. 初始化条件变量:

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

参数:cond为要初始化的条件变量,attr一般为NULL。

2. 在条件变量下挂起等待

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

参数:cond表示在哪个条件变量下等待
mutex:互斥量。

该函数在使用时,应注意:
(1)要进入临界区对临界资源进行操作,所以首先要申请互斥量。
(2)如果发现等待条件满足,则使用wait使线程挂起等待;如果等待条件不满足,就不执行wait。而是对临界资源进行操作
(3)当对临界资源操作完成后,释放互斥量,退出临界区。

因此,上述过程可以描述为:

pthread_mutex_lock(&mutex);
if(等待条件满足)
    pthread_cond_wait(&cond,&mutex);
//对临界资源进行操作
pthread_mutex_unlock(&mutex);

注意:在执行wait时实际做了以下事情:
(1)当等待条件满足时,挂起调用它的线程
(2)然后释放互斥量
(3)当再一次被唤醒并且切换到该线程后,会重新自动获得互斥量,并在wait处继续向下执行。

3. 唤醒等待

int pthread_cond_signal(pthread_cond_t* cond);//唤醒一个
int pthread_cond_broadcast(pthread_cond_t* cond);//唤醒一群

当条件满足之后,就要唤醒在条件变量下等待的线程。

例如,在上述的链表操作中,链表为空时,线程2在该条件下等待并释放互斥量退出临界区。当线程1插入结点是链表非空后,使线程2等待的条件就不满足了,此时就用该函数来唤醒等待中的线程2。当重新切换回线程2时,从等待处继续往下运行。

因此,该函数在使用时:
(1)进入临界区对临界资源进行操作,所以要先申请互斥量
(2)使等待条件为假,如插入线程1向链表中插入节点
(3)调用signal。如果此时有线程在等待,则唤醒它。若没有等待的线程,则该函数什么也不做
(4)解锁互斥量,退出临界区

上述过程可以描述为:

pthread_mutex_lock(&mutex);
//使等待条件为假
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

4. 销毁条件变量

int pthread_cond_destroy(pthread_cond_t* cond);

该函数成功返回0,失败返回错误码。

下面,通过一个例子来使用条件变量:

要求:1s输出一条语句,但sleep语句和printf语句必须在两个执行流中。

分析:因为两个语句位于两个执行流中,所以此时需要两个线程来实现。同时,在一个线程中执行printf之后,必须去另一个线程中执行sleep,也就是说,一个线程执行完了,必须去执行另一线程,不能一个线程同时执行两次。这里就涉及到线程时序上的问题,也就是涉及到线程的同步问题。所以这里用条件变量来实现线程间的同步。即当在执行完printf之后,要在该条件下等待,等到执行完sleep之后,在来执行printf。之后,这样循环执行。

#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
//定义条件变量,用于使一个线程在指定条件下等待,然后切换到另一线程
//在另一线程中,条件满足后,切换到刚刚在等待中的线程
pthread_cond_t cond;
pthread_mutex_t mutex;//因为条件变量的使用必须用到互斥量

//在下面的两个线程中,由于调度器的选择,使sleep线程先运行

//该线程用于打印信息
void* print(void* arg)
{
    while(1)
    {
        printf("hello\n");
        pthread_cond_wait(&cond,&mutex);
        printf("i am pthread %d\n",(int)arg);
    }
}                                                                                                                                     

//该线程用于sleep
void* mysleep(void* arg)
{
    while(1)
    {   
        pthread_cond_signal(&cond);//signal之后先执行在其之后的语句,等到切换到另一线程后,在从wait处开始执行
        printf("i am pthread %d\n",(int)arg);
                sleep(1);
    }
}

int main()
{
    //初始化条件变量,互斥量
    pthread_cond_init(&cond,NULL);
    pthread_mutex_init(&mutex,NULL);

    pthread_t tid[2];//定义两个用户级线程id

    //创建两个用户级线程
    pthread_create(&tid[0],NULL,print,(void*)0);
    pthread_create(&tid[1],NULL,mysleep,(void*)1);

    //主线程等待新线程
    pthread_join(tid[0],NULL);
    pthread_join(tid[1],NULL);

    //销毁条件变量,互斥量
    pthread_cond_destroy(&cond);
    pthread_mutex_destroy(&mutex);

    return 0;
}                     

运行结果:

[admin@localhost condition]$ ./a.out 
i am pthread 1
hello
i am pthread 1
i am pthread 0
hello
i am pthread 1
i am pthread 0
hello
^C

在上面两个线程中,sleep线程先运行,所以,会先输出1。sleep时,切换到0线程,输出hello,然后wait进入等待,切换回1线程,sleep1s后,执行signal,wait被唤醒。但是,还是会接着signal之后继续运行。所以1s后会输出1。执行sleep时,切换到0线程,接着wait继续运行,输出0,在输出hello。在进入wait,切换到1线程。1s后,signal使wait解锁,但会接着signal运行,输出1。进入sleep后,切换到0线程,输出0,hello,之后就这样循环运行。

另外,关于条件变量的更多应用,见生产者消费者模型

POSIX信号量

在“进程间通信—–信号量”一文中,有介绍过System V信号量。这里的POSIX信号量与其作用相同,本质上都是一种记录临界资源个数的计数器。都是用来进行保证同步操作,不过POSIX信号量可以用于线程间同步。

比如在上述的条件变量的链表例子中,也可以通过信号量来保证线程间同步。当链表为空时,也就是说链表中的结点个数为0时,线程1要先运行。此时,便可以设置一个信号量来表示临界资源链表中的节点个数。当线程2要删除结点时,要先申请信号量来申请临界资源,申请上之后,信号量个数减1。当结点个数即信号量减为0时,线程2要挂起等待,线程1要先运行。这样就保证了线程间的同步操作。

下面介绍有关信号量的一些接口函数:
1. 信号量本质是一结构体

struct
{
    int value;//信号量的值即它维护的临界资源的个数
    queue;//等待队列
}

2. 初始化信号量

int sem_init(sem_t* sem,int pshared,unsigned int value);

参数:
sem:设置的信号量
pshared:0表示线程间同步,1表示进程间同步
value:信号量的值即该信号量维护的临界资源的个数

3. 等待信号量/申请资源

int sem_wait(sem_t* sem);

该函数的内部可以描述为:

sem->value--;
if(sem->value < 0) 将调用该函数的线程挂起等待,放入等等待队列中

一般wait操作与互斥量上锁操作时同时出现的,在同时出现时需要注意二者的先后顺序:
首先:wait申请信号量
然后:mutex_lock申请互斥量

因为如果申请到了互斥量进入临界区,却发现没有临界资源,此时就会在临界区中挂起等待造成死锁问题。

所以,要先确保有临界资源,在进入临界区进行互斥访问。

4. 发布信号量/归还资源

int sem_post(sem_t* sem);

该函数的内部实现可以描述为:

s->value++;
if(s->value <= 0)唤醒等待队列中的线程

5. 销毁信号量

int sem_destroy(sem_t* sem);

关于POSIX信号量的使用见生产者消费者模型一文。

猜你喜欢

转载自blog.csdn.net/sandmm112/article/details/80049771