Linux C多任务编程(线程篇2)

本文介绍了线程在访问共享资源时可能出现的资源竞争问题,以及解决这个问题的两种主要方法:互斥和同步。互斥确保同一时刻只有一个线程在临界区执行,而同步则涉及线程间的协调与等待。文章详细阐述了锁(包括自旋锁和无等待锁)和信号量的概念,并通过代码示例展示了它们在实现互斥和同步中的应用,如生产者-消费者问题的解决方案。
摘要由CSDN通过智能技术生成

一、线程的互斥与同步     

        上一篇讲到了线程的一些基础知识,接下来我们讨论一下当多个线程同时访问共享资源是,会不会也会造成冲突呢?答案是肯定的,当多个线程同时访问共享资源时,就会造成多线程资源竞争的问题。

        那为了解决这个问题,我们一般有俩种办法,那就是互斥同步

        由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区它是访问共享资源的代码片段一定不能给多线程同时执行。       

        互斥保证一个线程在临界区执行时,其它线程应该被组织进入临界区

        互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个进程/线程进入临界区,其它试图想进入临界区的进程/线程都会被阻塞,知道第一个进程/线程离开了临界区。

        同步:并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通消息称为进程/线程同步

二、互斥与同步的实现和使用

        为了实现进程/线程间正确的协作,主要的方法有两种:

        :加锁、解锁操作

        信号量:P、V操作

        这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。

        使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。

        任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。

三、锁

        根据锁的实现不同,可以分为忙等待锁无忙等待锁

 3.1忙等待锁(自旋锁)

        忙等待锁的实现

        我们可以运用Test-and-Set指令来实现忙等待锁

typedef struct lock_t
{
    int flag;
}lock_t;
void init(lock_t *lock)
{
    lock->flag = 0;
}
void lock(lock_t *lock)
{
    while(TestAndSet(&lock->flag, 1) == 1)
    {
    }
}
void unlock(lock_t *lock)
{
    lock->flag = 0;
}

        针对上述代码,我理解一下这个锁的工作原理:

        第一个场景是,首先假设一个线程在运行,调用 lock(),没有其他线程持有锁,所以 flag 是 0。当调用 TestAndSet(flag, 1) 方法,返回 0,线程会跳出 while 循环,获取锁。同时也会原子的设置 flag 为1,标志锁已经被持有。当线程离开临界区,调用 unlock() 将 flag 清理为 0。

        第二种场景是,当某一个线程已经持有锁(即 flag 为1)。本线程调用 lock(),然后调用 TestAndSet(flag, 1),这一次返回 1。只要另一个线程一直持有锁,TestAndSet() 会重复返回 1,本线程会一直忙等。当 flag 终于被改为 0,本线程会调用 TestAndSet(),返回 0 并且原子地设置为 1,从而获得锁,进入临界区。

        但获取不到锁时,线程就会一直while循环,不做任何事情,这就是忙等待锁(自旋锁)。这是最简单的一种锁,一直自旋,利用CPU周期,直到锁可用。在单处理器上,需要抢占式的调度器(不断通过时钟终端一个线程,运行其它线程)。否则,自旋锁在单核CPU上无法使用,因为一个自旋的线程永远不会放弃CPU。

3.2无等待锁

        无等待锁就是在获取不到锁的时候,不用自旋。而是把当前线程放入到锁的等待队列,然后执行调度程序,把CPU让给其它线程执行。

type struct lock_t
{
    int flag;
    queue_t *q;      //等待队列
}lock_t;
void init(lock_t *lock)
{
    lock->flag = 0;
    queue_init(lock->q);
}
void lock(lock_t *lock)
{
    while(TestAndSet(&lock->flag, 1) == 1)
    {
        //保存现在运行线程 TCB
        //将现在运行的线程 TCB 插入到等待队列
        //设置该线程为等待状态
        //调度程序
    }
}
void unlock(lock_t *lock)
{
    if(lock->q != NULL)
    {
        //移出等待队列的队头元素
        //将该线程的 TCB 插入到就绪队列
        //设置该线程为就绪状态
    }
    lock->flag = 0;
}

四、信号量

        信号量是操作系统提供的一种协调共享资源访问的方法。关于信号量,我在进程通信的时候已经详细讲述,不懂得可以去看我之前的文章。

        P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。

4.1系统是如何实现PV操作的呢?

//信号量数据结构
type struct sem_t
{
    int sem;           //资源个数
    queue_t *q;        //等待队列
}sem_t;

//初始化信号量
void init(sem_t *s, int sem)
{
    s->sem = sem;
    queue_init(s->q);
}

//P操作
void P(sem_t *s)
{
    s->sem--;
    if(s->sem < 0)
    {
        1.保留调用线程CPU现场;
        2.将该线程的 TCB 插入到 s 的等待队列;
        3.设置该线程为等待状态;
        4.执行调度程序;
    }
}

//V操作
void V(sem_t *s)
{
    s->sem++;
    if(s->sem <= 0)
    {
        1.移出 s 等待队列首元素;
        2.将该线程的 TCB 插入就绪队列;
        3.设置该线程为就绪状态;
    }
}

        PV操作如何使用的呢?

        信号量不仅可以实现临界区的互斥访问控制,还可以线程间的时间同步。

信号量实现临界区的互斥访问    

        为每类共享资源设置一个信号量s,其值为1,表示该临界区资源未被占用。

        只要把进入临界区的操作至于P(s)和V(s)之间,即可实现进程/线程互斥:

         此时,任何想进入临界区的线程,必先在互斥信号量上执行P操作,在完成对临界区资源的访问后再执行V操作。由于互斥信号量的初始值为1,故在第一个线程执行P操作后s值变为0,表示临界资源为空闲,可分配给该线程,使之进入临界区。

        若此时又有第二个线程想进入临界区,也应先执行P操作,结果使s变为负值,这意味着临界资源已被占用,因此,第二个线程被阻塞。

        并且,直到第一个线程执行V操作,释放临界资源而回复s值为0后,又执行V操作,使s回复到初始值1。

        对于俩个并发线程,互斥信号量的值仅取1、0和-1三个值,分别表示:

        1.如果互斥信号量为1,表示没有线程进入临界区

        2.如果互斥信号量为0,表示一个线程进入临界区

        3.如果互斥信号量为-1,便是一个线程进入临界区,另一个线程等待进入。

         通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。

信号量实现事件同步

        同步的方式是设置一个信号量,其初始值为0;

        有俩个线程s1、s2,s2在等待s1发来的信号唤醒,一旦s1给s2发来信号后,s1随即进入等待唤醒状态,s2接收到s1发来的信号之后开始执行自己的线程,在执行完自己的线程内容后,又给s1回发信号,然后进入等待唤醒状态,当s1收到s2发来的信号后,随机开始执行自己的线程。如此反复,实现线程的同步。

semaphore s1 = 0;        //s1表示自己正在执行
semaohore s2 = 0;        //s2表示自己正在执行

//s2线程函数
void s2()
{
    while(1)
    {
        执行;
        V(s1);      //通知s1,自己执行完毕,s1可以执行了
        P(s2);      //s2进入等待状态
    }
}

//s1线程函数
void s1()
{
    while(1)
    {
        P(s1);      //询问自己能否执行
        执行;
        V(s2);      //自己执行完毕进入等待状态,通知s2可以执行了
    }
}

4.2生产者-消费者问题

         生产者-消费者问题描述: 

1.生产者在生成数据后,放在一个缓冲区中;

2.消费者从缓冲区去处数据处理;

3.任何时刻,只能一个生产者或消费者可以访问缓冲区;           

        我们对问题进行分析可以得出:

1.任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥

2.缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。

        那么,我们需要三个信号量,分别是:

1.互斥信号量mutex:用于互斥访问缓冲区,初始化值为1; 

2.资源信号量fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为0(表面缓冲区一开始为空);

3.资源信号量emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生产数据,初始化值为n(缓冲区大小);

        如果消费者线程一开始执行 P(fullBuffers),由于信号量 fullBuffers 初始值为 0,则此时 fullBuffers 的值从 0 变为 -1,说明缓冲区里没有数据,消费者只能等待。

        接着,轮到生产者执行 P(emptyBuffers),表示减少 1 个空槽,如果当前没有其他生产者线程在临界区执行代码,那么该生产者线程就可以把数据放到缓冲区,放完后,执行 V(fullBuffers) ,信号量 fullBuffers 从 -1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。

        消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进入临界区,从缓冲区读取数据。最后,离开临界区后,把空槽的个数 + 1。        

        具体代码实现:

#define N 100
semaphore mutex = 1;           //互斥信号量,用于临界区的互斥访问
semaphore emptyBuffers = N;    //表示缓冲区空位的个数
semaphore fullBuffers = 0;     //表示缓冲区数据的个数

//生产者线程函数
void producer()
{
    while(1)
    {
        P(emotyBuffers);                  //将空位的个数-1
        P(mutex);                         //进入临界区
        将生成的数据放入到缓冲区;
        V(mutex);                         //离开临界区
        V(fullBuffers);                   //将数据的个数+1
    }
}

//消费者线程函数
void consumer()
{
    while(1)
    {
        P(fullBuffers);                   //将数据的个数-1
        P(mutex);                         //进入临界区
        从缓冲区里读取数据;
        V(mutex);                         //离开临界区
        V(emptyBuffers);                  //将空位的个数+1
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值