> 文章列表 > 线程的同步

线程的同步

线程的同步

目录

一、简介

为什么需要线程同步?

二、互斥

验证

 互斥锁死锁

三、条件变量

验证

四、 自旋锁

自旋锁与互斥锁之间的区别:

代码编写​编辑

验证

 五、读写锁

代码编写​编辑

验证


一、简介

为什么需要线程同步?

        对于一个单线程进程来说,它不需要处理线程同步的问题,所以线程同步是在多线程环境下可能需要注意的一个问题。 线程的主要优势在于,资源的共享性,譬如通过全局变量来实现信息共享,不过这种便捷的共享是有代价的,那就是多个线程并发访问共享数据所导致的数据不一致的问题。

        线程同步是为了对共享资源的访问进行保护。 这里说的共享资源指的是多个线程都会进行访问的资源,譬如定义了一个全局变量 a,线程 1 访问了变量 a、同样在线程 2 中也访问了变量 a,那么此时变量 a 就是多个线程间的共享资源,大家都要访问它。

        保护的目的是为了解决数据一致性的问题。当一个线程可以修改的变量,其它的线程也可以读取或者修改的时候,这个时候就存在数据一致性的问题,需要对这些线程进行同步操作,确保它们在访问变量的存储内容时不会访问到无效的值

        出现数据一致性问题其本质在于进程中的多个线程对共享资源的并发访问(同时访问) 。进程中的多个线程间是并发执行的,每个线程都是系统调用的基本单元,参与到系统调度队列中;
对于多个线程间的共享资源,并发执行会导致对共享资源的并发访问,并发访问所带来的问题就是竞争(如果多个线程同时对共享资源进行访问就表示存在竞争), 并发访问就可能会出现数据一致性问题,所以就需要解决这个问题;要防止并发访问共享资源,那么就需要对共享资源的访问进行保护,防止出现并发访问共享资源。

         当一个线程修改变量时,其它的线程在读取这个变量时可能会看到不一致的值, 上图两个线程读写相同变量(共享变量、共享资源)的假设例子。在这个例子当中,线程 A 读取变量的值,然后再给这个变量赋予一个新的值,但写操作需要 2 个时钟周期(这里只是假设);当线程 B 在这两个写周期中间读取了这个变量,它就会得到不一致的值, 这就出现了数据不一致的问题

        线程同步技术,来实现同一时间只允许一个线程访问该变量,防止出现并发访问的情况、消除数据不一致的问题,如下图

        线程的主要优势在于,资源的共享性,譬如通过全局变量来实现信息共享。不过这种便捷的共享是有代价的,必须确保多个线程不会同时修改同一变量、或者某一线程不会读取正由其它线程修改的变量,也就是必须确保不会出现对共享资源的并发访问。 Linux 系统提供了多种用于实现线程同步的机制,常见的方法有:互斥锁、条件变量、自旋锁以及读写锁等

二、互斥锁

        互斥锁(mutex)又叫互斥量,从本质上说是一把锁,在访问共享资源之前对互斥锁进行上锁,在访问完成后释放互斥锁(解锁);对互斥锁进行上锁之后,任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁。如果释放互斥锁时有一个以上的线程阻塞,那么这些阻塞的线程会被唤醒,它们都会尝试对互斥锁进行加锁,当有一个线程成功对互斥锁上锁之后,其它线程就不能再次上锁了,只能再次陷入阻塞,等待下一次解锁
        就拿卫生间(共享资源)来说,当来了一个人(线程)看到卫生间没
人,然后它进去了、并且从里边把门锁住(互斥锁上锁)了;此时又来了两个人(线程),它们也想进卫生间方便,发生此时门打不开(互斥锁上锁失败),因为里边有人,所以此时它们只能等待(陷入阻塞);当里边的人使用完了之后(访问共享资源完成),把锁(互斥锁解锁)打开从里边出来,此时外边有两个人在等,当然它们都迫不及待想要进去(尝试对互斥锁进行上锁),自然两个人只能进去一个,进去的人再次把门锁住,另外一个人只能继续等待它出来。

代码编写

         创建两个线程,然后都是访问同一函数,当进入for循环语句之后某个线程获取到资源之后,该共享资源就会被上锁,另一个线程就会无法访问该资源,然后阻塞等待该资源解锁。共享资源是变量 main_count,因为多个线程都会访问它,所以需要使用互斥锁来保护它的访问

验证

 互斥锁死锁

        一个线程需要同时访问两个或更多不同的共享资源,而每个资源又由不同的互斥锁管理。当超过一个线程对同一组互斥锁(两个或两个以上的互斥锁)进行加锁时,就有可能发生死锁;譬如,程序中使用一个以上的互斥锁,如果允许一个线程一直占有第一个互斥锁,并且在试图锁住第二个互斥锁时处于阻塞状态,但是拥有第二个互斥锁的线程也在试图锁住第一个互斥锁。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,会被一直阻塞,于是就产生了死锁

        在实际使用中需要注意该问题

三、条件变量

        条件变量是线程可用的另一种同步机制。条件变量用于自动阻塞线程,知道某个特定事件发生或某个条件满足为止,条件变量是用来等待而不是用来上锁的,条件变量本身不是锁,通常情况下,条件变量是和互斥锁一起搭配使用的。 使用条件变量主要包括两个动作:
⚫ 一个线程等待某个条件满足而被阻塞;
⚫ 另一个线程中,条件满足时发出“信号”

        例子, 生产者---消费者模式,生产者这边负责生产产品、而消费者负责消费产品,对于消费者来说,没有产品的时候只能等待产品出来,有产品就使用它。

         创建一个线程,在进入线程函数后,先上锁,判断main_avail资源是否有货,没有则通过 pthread_cond_wait() 函数释放互斥锁并等待生产者线程的信号,该函数会先解锁、等待条件满足、重新上锁,这是原子操作,等待生产者线程发送信号之后,会重新获取互斥锁mutex并继续执行while循环。在获取互斥锁之后,消费者线程会再次判断main_avail的值,如果main_avail仍然小于等于0,则继续等待信号;否则就进入第二个while循环开始消费资源。

        在生产者线程(主线程)中,先上锁生产,然后解锁给消费者发送信号说货

        在这里加sleep是为了打印信息的时候慢一些

验证

四、 自旋锁

        自旋锁与互斥锁很相似, 从本质上说也是一把锁,在访问共享资源之前对自旋锁进行上锁,在访问完成后释放自旋锁(解锁);事实上,从实现方式上来说,互斥锁是基于自旋锁来实现的,所以自旋锁相较于互斥锁更加底层。

        如果在获取自旋锁时, 自旋锁处于未锁定状态, 那么将立即获得锁(对自旋锁上锁); 如果在获取自旋锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地“自旋”, 直到该自旋锁的持有者释放了锁。由此可知,自旋锁与互斥锁相似, 但是互斥锁在无法获取到锁时会让线程陷入阻塞等待状态;而自旋锁在无法获取到锁时, 将会在原地“自旋”等待。 “自旋” 其实就是调用者一直在循环查看该自旋锁的持有者是否已经释放了锁,“自旋”一词因此得名。

        自旋锁的不足之处在于:自旋锁一直占用的 CPU,它在未获得锁的情况下,一直处于运行状态(自旋),所以占着 CPU,如果不能在很短的时间内获取锁,这无疑会使 CPU 效率降低。
        试图对同一自旋锁加锁两次必然会导致死锁,而试图对同一互斥锁加锁两次不一定会导致死锁,原因在于互斥锁有不同的类型,当设置为 PTHREAD_MUTEX_ERRORCHECK 类型时,会进行错误检查, 第二次加锁会返回错误, 所以不会进入死锁状态。

        谨慎使用自旋锁,自旋锁通常用于以下情况: 需要保护的代码段执行时间很短,这样就会使
得持有锁的线程会很快释放锁,而“自旋”等待的线程也只需等待很短的时间;在这种情况下就比较适合使用自旋锁,效率高!

自旋锁与互斥锁之间的区别:

       ①实现方式上的区别:互斥锁是基于自旋锁而实现的,所以自旋锁相较于互斥锁更加底层;
       ②开销上的区别:获取不到互斥锁会陷入阻塞状态(休眠) ,直到获取到锁时被唤醒;而获取不到自旋锁会在原地“自旋”,直到获取到锁; 休眠与唤醒开销是很大的, 所以互斥锁的开销要远高于自旋锁、 自旋锁的效率远高于互斥锁; 但如果长时间的“自旋”等待,会使得 CPU 使用效率降低,故自旋锁不适用于等待时间比较长的情况。

       ③使用场景的区别: 自旋锁在用户态应用程序中使用的比较少, 通常在内核代码中使用比较多;因为自旋锁可以在中断服务函数中使用,而互斥锁则不行,在执行中断服务函数时要求不能休眠、不能被抢占(内核中使用自旋锁会自动禁止抢占) , 一旦休眠意味着执行中断服务函数时主动交出了CPU 使用权,休眠结束时无法返回到中断服务函数中,这样就会导致死锁

代码编写

       和互斥锁的代码差不多,在创建两个线程前初始化自旋锁PTHREAD_PROCESS_PRIVATE为私有自旋锁。只有本进程内的线程才能够使用该自旋锁。创建两个线程访问同一函数,当某一个线程进入循环后先上锁运算,之后再开锁,最后打印信息并销毁锁

验证

 五、读写锁

        互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁有3 种状态:读模式下的加锁状态(以下简称读加锁状态)、写模式下的加锁状态(以下简称写加锁状态)和不加锁状态(见) ,一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。因此可知,读写锁比互斥锁具有更高的并行性

读写锁有如下两个规则:
⚫ 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读模式加锁还是以写模式加锁) 的线程都会被阻塞。
⚫ 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。

        当读写锁处于读模式加锁状态,而这时有一个线程试图以写模式获取锁时, 该线程会被阻塞;而如果另一线程以读模式获取锁,则会成功获取到锁,对共享资源进行读操作。所以,读写锁非常适合于对共享数据读的次数远大于写的次数的情况。当读写锁处于写模式加锁状态时,它所保护的数据可以被安全的修改,因为一次只有一个线程可以在写模式下拥有这个锁;当读写锁处于
读模式加锁状态时,它所保护的数据就可以被多个获取读模式锁的线程读取。 所以在应用程序当中,使用读写锁实现线程同步, 当线程需要对共享数据进行读操作时,需要先获取读模式锁(对读模式锁进行加锁),当读取操作完成之后再释放读模式锁(对读模式锁进行解锁);当线程需要对共享数据进行写操作时,需要先获取到写模式锁,当写操作完成之后再释放写模式锁

        读写锁也叫做共享互斥锁。当读写锁是读模式锁住时,就可以说成是共享模式锁住。当它是写模式锁住时,就可以说成是互斥模式锁住

代码编写

         使用了读写锁来保护一个共享变量main_count,并启动了5个读线程和5个写线程来对该变量进行读写操作。每个读线程执行10次循环,每次获取读锁后打印main_count的值,每次循环间隔1秒。每个写线程执行10次循环,每次获取写锁后将main_count的值加20,打印修改后的值,每次循环间隔1秒。退出就所有线程结束后销毁读写锁。

验证

         在多线程程序中,线程的执行顺序是不确定的,取决于操作系统的调度算法。虽然程序中线程的创建顺序是固定的,但是它们的执行顺序却是随机的。所以,在这个程序中,读写线程的打印顺序可能会与创建线程的顺序不一样。所以看起来打印的顺序并不是按照线程创建的顺序来的。