前言
掌握多线程的使用,是程序员进阶必须掌握的技能之一,为什么多线程这么重要为多线程能更充分的发挥出cpu的性能,是我们在开发中提高程序性能最重要并且最有效的一种方式。怎么才能掌握多线程的使用/p>
只有彻底掌握线程的基础知识,才能用好线程。什么是线程什么会产生线程安全问题何保证线程安全何提升多线程的性能些都是线程很重要的基础知识。在这篇文章中,我会针对线程的基础知识,进行一个全面并且深入的讲解。那么,我们开始对线程的学习吧。
线程原理
线程是什么呢是操作系统能够进行运算调度的最小单位,被包含在进程之中,是进程中的实际运作单位。在很多操作系统中,线程其实就是一个进程,比如在Linux系统中,并不存在线程这种调度单元,而是提供了轻量级进程来当作线程使用,轻量级进程和普通进程的进程描述结构,调度算法都是一样的,不同的是轻量级进程可与其他进程共享逻辑地址空间和系统资源。既然Linux系统不存在线程,那我们要在Linux,或者Android的开发中使用线程怎么办呢两种方法
- 使用内核空间提供的轻量级进程,一个轻量级进程代表了一个线程。
- 原子性
- 可见性
- 顺序性
-
P0和P1同时都在执行这段代码
在这种情况下,线程P0将turn置为了1,这个时候线程P1又将turn置为了0。当线程P0进入while判断,对P0来说,这个时候flag[1-线程 ]也就是flag[1] 为true,但是turn却是flase,不满足while循环的条件,所以P0会进入临界区。但是P1在while判断时,flag[0]为true,并且turn为0,满足while的条件,于是会一直循环,直到p0退出临界区。
-
只有一个线程在执行这段代码
在这种情况下,如果是P0在执行这段代码,while循环的判断中,flag[1]是false,不满足while循环的判断,于是P0会进入临界区。
- 第一点是TAS的值非0即1,这就导致通过TAS实现的锁只有两种状态,它用来实现互斥是很合适的,但是在一些更复杂的条件判断中,仅仅只通过互斥可能会满足不了要求。
- 第二个涉及到了TAS保证可见性的处理,当TAS每次将lock写为1时,都需要发送缓存一致性协议(缓存一致性协议后面会详细讲)通知其他Cache lock值已经失效,而发送协议需要占用总线流量,所以TAS在总线流量占用是比较大的。
CreateNativeThread函数通过调用pthread_create函数来创建线程。pthread_create的实现在Bionic目录的pthread_create对象中。Bionic是Android平台为了使用C/C++进行原生应用程序开发所有提供的POSIX标准C库,如果Bionic也有实现pthread_create兼容Android的线程创建。
/bionic/libc/bionic/pthread_create.cpp
可以看到pthread_create是通过clone的系统调用来创建线程的。
线程安全
知道了什么是线程,也知道了线程是如何产生的,接下来了解一下线程在使用过程中遇到的安全问题,以及如何解决线程安全问题。
导致线程安全的原因
在使用多线程时,不可避免的会遇到线程安全问题,为什么会有线程安全问题呢要是这三个原因导致的。
原子性
前面说过,一个线程对应了一个轻量级进程。而进程之前的切换,是内核采完全公平算法进行切换的,我们无法控制这个过程。所以一个正在运行的线程,可能随时被内核切换,变成非运行状态,一旦我们正在运行的线程发生了切换,就会导致当前线程的执行过程发生中断,这种情况下就会产生线程安全问题。
看一下这样一个简单的函数
这个简单的函数在多线程并发的情况下,count就会产生线程安全问题。它再多线程中运行的流程可能如下。
CPU在读数据会先在最快的寄存器寻找需要的数据,找不到再去L1,L2,L3最后到L4一次寻找。写数据时,也会先将数据写道寄存器,然后再同步到L1,L2或者L3中的Cache,最后再同步到内存中。在这种架构下,多核CPU就会产生数据不一致的情况。
会出现这个问题的原因就是可见性,CPU0对a的操作,不能让其他的线程立刻可见,所以就出现了线程的安全性问题。
顺序性
为了性能优化,虚拟机和CPU都会对指令重排序,这样就会导致我们的程序执行的顺序实际编码的顺序不一致。
这里举一个我们用的非常频繁的例子:单例,来说明顺序性导致的线程安全问题。这是一个双重检查单例模式的实现,在大部分情况下,程序都能正常运行,一旦发生顺序问题,就可以导致程序崩溃。
new Singleton的创建过程分为:分配内存,在内存中初始化Instance,然M的地址赋值给Instance这三步,如果这三步的顺序被重排,按照如下执行。
为什么这个算法能实现互斥呢这两种场景
可以看到,不管是同时只有一个线程在执行这段代码,还是同时两个线程都执行这段代码,都能保证只有一个线程进入临界区。这个时候会有人问,如果是三个线程,四个线程或者多个线程的情况呢就是Peterson算法的缺点,它只能满足两个线程的互斥,但是基于Peterson衍生出来的Filger算法能满足多个线程的互斥,Filger主要是将flag和turn进行了扩展,思路都是一样的,这里就不详讲了,它的实现如下。
通过互斥算法可以实现互斥,但是软件实现的互斥在性能上不太好,特别是在大量的并发情况下,通过while循环遍历的方式,性能会更差。所以互斥算法也不是比较好的互斥的方式。
硬件指令
屏蔽中断,软件锁变量和互斥算法都不是很好的实现互斥的方式,那么有没有实现互斥的最好的办法呢,那就是硬件支持的互斥。
从早期处理器支持的:测试并设置(Test-and-Set), 获取并增加(Fetch-and-Increment),交换(Swap)。到现代处理器才开始支持的:比较并交换(Compare-and-Swap),加载链接/条件存储(Load-Linked/Store-Conditional)。这五种指令都是通过硬件支持的原子操作,通过这五种方式,我们就能比较完美的实现互斥。
硬件指令原子性的原理都是通过lock指令将内存总线锁住,以禁止其他CPU在本指令结束之前访问内存,内存总线只有一条,并且是独占的,不管多核还是单核,同一时间,只有一个CPU能占用总线。
这里详细介绍一下测试并设置(TAS)和比较并交换(CAS),加载链接/条件存储(LL/SC)**这两个硬件指令,因为这两个是用的最多的两个指令。
测试并设置(TAS)
TAS指令会向某个内存地址(这个内存地址只有1bit,所以值非0即1)写入值1,并且返回这块内存地址存的原始值。TAS指令是原子的,这是由实现TAS指令的硬件保证的(这里的硬件可以是CPU,也可以是实现了TAS的其他硬件),我们看一下如何通过TAS实现自旋锁。
在while循环中,通过test_and_set对lock进行操作,如果lock返回值为1,说明已经有其他线程将lock的值设置成了1,所以会在while中不断的循环,知道其他线程退出临界区,将lock设置成了1,这次,这个线程就可以进入临界区,并且重新将lock设置成了1。通过空转循环检测的锁被称为自旋锁,TAS是Linux系统中实现自旋锁最常用的一种方式。
但是TAS也有一些缺点
比较并交换(CAS)
CAS指通过将某个内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值,CAS的逻辑比TAS要复杂很多,所以这里通过代码模拟CAS的过程。
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!