Android 并发编程起因

简介:

大多数的Android设备是多处理器的,Android3.0和以后的版本开始支持多处理器核心架构。多处理器对称Symmetric Multi-Processor缩写为SMP,定义了针对多核CPU如何共享内存的设计。SMP使得软件开发变得更加复杂,而且SMP工作在ARM类型处理器上比x86处理器上更具有挑战,x86测试运行正常的代码可能在ARM上可能会执行失败。

一、 多处理器并发问题的理论知识

1. 内存一致模型:

内存一致模型描述了由硬件结构对于内存访问所作的保证,如你将先写一个值到内存地址A处,又写一个值到内存地址B处,模型会保证每一个处理器都会按照前面的顺序收到前面的变化。这样程序员感知到的会是这样:

所有的内存操作看起来是一个一个执行的

单独看任何一个处理器,指令都是按照程序定义的顺序执行的

实际上处理器很可能会对指令进行重排序或者延迟执行读写操作,请看下面一个例子:

线程1和线程2可能执行在不同的处理器上,在你编写多线程代码时要时刻考虑这个。当每个线程执行完成后,结果可能有如下多种:

单处理器的x86或者ARM是顺序一致的,但是大多数的SMP系统都不是。

2. 处理器一致性

X86的SMP处理器提供了处理器一致性的设计,相比顺序执行稍微若一些,它保证了:两次读不会重排序、两次写不会重排序,但是写后读操作不保证顺序。如下面的例子:

线程1期望使用A来标识它是否繁忙,线程2使用B来标识繁忙。两个线程通过简称对方是否繁忙来决定是否执行关键逻辑。在顺序执行的机器上这是对的,但是在SMP类型的x86或者ARM上,线程1里的写:A=true和读:reg1=B在线程2里可能观察到的是相反的顺序,着站在线程2角度观察到的情况可能变为:

这种不确定顺序问题的产生主要是由于处理器缓存造成的。

3. 处理器缓存行为

现代的处理器会有一个或者多个缓存来存储内存和处理器之间使用的数据,通常被标记为L1和L2之类的,数字越高离处理器越远。缓存内存会增加大小、耗费硬件硬件、更加耗电,因此Android上使用的ARM处理器通常只有一个小的L1缓存,很少或者没有L2/L3缓存。

从L1缓存里读取数据或者向其写入数据都是非常快的,大致是从内存读写速度的10到100倍速度执行,因此处理器会优先使用缓存执行更多的操作。缓存的写策略决定了什么时候将缓存的值写入主存,写贯穿的缓存会立即将结果写入内存,回写缓存会等到执行超过了空间范围且需要移除一些实例时执行。无论哪种方式,处理器会持续执行指令,可能在写会主存前执行了很多的指令(写贯穿方式不会等待写执行完才执行其他指令)。

每个处理器有自己的缓存导致了并发问题的产生。最简单的模型里,每个缓存不与其他缓存有关联不与其他缓存共享,只能通过写回内存后得到变更。读写内存耗费较长的时间会导致内部的多个线程交互变得极其缓慢,所以需要一个方式来实现缓存的数据共享,这通常叫做缓存一致性设计,是由处理器的缓存一致性模型决定的。

由于上述设计的存在,处理器1线程1执行写:A=1后处理器2线程2读取A,可能会从主存中得到A也可能从线程2自己的缓存中读到,这都会导致读取错误的值。此时为了保证内存访问的一致性,处理器1可以等到其他处理器都收到A的变更通知后再执行其他指令,但是这会带来严重的性能问题,放松对读写内存一致性的限制又会增加程序员的开发负担。

处理器缓存不是操作独立的字节,数据是按照缓存行读写的。对于很多的ARM处理器是32个字节。如果你从本地的主存读取数据,你可能会读取临近的一些值。写数据的时候需要从主存读取并更新,这样会导致可能会读取相近的数据并更改。

4. ARM在指令排序的薄弱

ARM提供了薄弱的内存一致性保障,它不保证读和写相互之间的顺序。如:

在x86上,这不会出问题,reg会得到41。线程2会观察到线程1存储的值的变化,并且按线程1的程序顺序。但是在ARM的多处理并发场景下,读和写可能被重排序,reg可能得到0也可能得到41,除非你精确的定义顺序,否则你不知道结果是什么。

5. 内存数据屏障

内存屏障提供了一种告知处理器内存操作的顺序的方式。屏障指令本身是无用的,容易造成高消耗。通常包括如下几种常见的指令:

读后读/写后写

回到前面的例子:

线程1需要保证存储值到A要发生在存储值到B之前,这就是一个写后写的场景。线程2需要保证读取B发生在读取A之前,这就是个读后读的场景。如前面介绍的,读和写在ARM里会被观察到不同的执行顺序。我们可以这样解决:

写后写屏障保证了所有的观察者能够观测到线程1写A先发生写B后发生,同理线程2也可也保证读取顺序的可见一致性。

由于处理器架构处理内存模型的不同,上面的屏障在x86是不需要的,在ARM上是必须的。

读后写/写后读

类似的,读后写或者写后读也可以加入对应的屏障指令保证顺序的一致和可见。不过要注意,x86只有“写后读”场景需要加入屏障保证内存一致性可见性,ARM所有场景都需要。

屏障指令:

不同的处理器提供了不同的屏障指令,如Sparc V8提供了上面的全部4种指令。X86的SSE2提供了一个全屏障指令,ARMv7提供了写后写和全屏障指令。全屏障指令即代表支持上面的4中场景。

需要注意的是,屏障指令只保证了指令执行的顺序,并不会对缓存一致性和同步做出保证,ARM的屏障指令对其他内核的缓存是没影响的。

内存屏障总结:

不同场景需要需要使用不同的内存屏障指令,如果准确的使用会是有益处的,但是代码的维护风险会变得很高。正因为如此,ARM处理器不提供不同种类的内存屏障指令,很多需要使用屏蔽指定时的原子语义是通过全量屏蔽指令完成的。

内存屏障最核心的设计是定义顺序,不是一个执行一堆刷新的指令,可以把它看做当前处理器核心执行指令的时间分割线。

6. 原子操作

原子操作可以保证一系列的执行步骤会像单一的一条指令一样。在ARM上,读写32字节这个最进本的操作时原子执行的。如果数据部是对其的,原子性会因此而丢失,不对齐的数据会跨两个缓存行,其他的处理器核心可以独立的看到一半的变更。因此,ARMv7文档声明它提供了“单独拷贝原子性”来应对全字节访问、“半字对齐”应对半字对齐场景、全字访问应对全字对齐场景。但是两个字(64-bit)访问就不是原子的了,除非位置是双字对齐且使用特殊的读写指令。

对内存执行更复杂的操作通常是读-改-写指令,需要读取数据、更改数据然后写回数据。处理器有多种不同的处理时限,ARM使用了叫做“Load Linked/Store Conditional”的技术实现:读-改-写操作执行处理旧的过期数据就会变得没有意义,如果两个核心对同一个地址执行原子增加操作,因为各自缓存的存在,任何一个都无法看到其他的变更,这种操作不是实际原子的。处理器的缓存一致性规则需要保证读改写RMW能够在多处理器内核环境下正常工作。

原子读改写不可以被理解为通过内存屏障实现,ARM的原子实现是没有内存屏障的。针对同一地址的一系列的原子读改写操作可以被其他的内核安装程序顺序观察到,但是原子和非原子操作混合是不能保证顺序的。

7. 原子性和屏障技术结合

可以通过自旋锁的技术实现了解二者的结合,核心思想是一个内存地址初始化锁值为0,如果一个线程需要访问一个关键区域,它会设置锁值为1,当关键区域代码执行完成,锁值被恢复为0。如果其他一个线程已经将锁值设置为1,那么当前线程会停下来自旋直到锁值恢复为0。

为了确保上述算法的实现,我们可以使用一个叫做比较交互的原子性读改写技术。这个功能需要三个参数:内存地址、期望值、新值。如果内存地址的值时期望值,则写入新值返回旧值。否则不作修改。一个小的变化可以产生另一个功能:比较设置,返回值变为boolean标识是否变更而不是返回旧值。两个功能类似,可以简称为CAS。结合屏障技术,一个自旋锁可以类似如下实现:

在对称多处理场景SMP,一个自旋锁是保护关键区域代码执行非常有效的手段。如果我们知道另一个线程正在关键指令并占有锁,我们会浪费一些循环来等到我们得到执行机会。然而,如果其他占用锁的线程和当前线程碰巧在同一个处理器核心执行,我们的自旋会是一种浪费,因为其他线程不会有进展除非操作系统非配给它执行的机会(通过迁移其他线程到另一个核心或者抢占当前线程来执行)。一个更合理的优化方式是自旋几次后将线程交给操作系统的原始实现:让线程进程睡眠状态知道执行线程执行完成。

内存屏障是必须的,用来保证其他线程观察到锁值的变化优先于关键区域的内存操作的变化。同样,我们需要保证对关键内存的操作变化的要先于锁释放执行和被观察到,因此完整的实现如下:

如前面提到的,最后执行的原子写操作时ARM和x86都提供的实现,不同于原子的读改写操作,原子写不保证其他线程能够立刻观测到这个值的变更,但这不是问题,我们只是需要保证其他线程不进入关键代码区。

申请一个自旋锁时,需要先执行CAS然后执行内存屏障,通常叫做acquiring操作。释放一个自旋锁时,需要先执行内存屏障然后执行有原子写,通常叫做releasing操作。

对于不同的处理器架构,实现上会做出相应的优化。如x86上只有写后读的屏障才需要,因此释放场景的屏障操作在x86上是不需要的。将一些操作移到关键代码区里会是安全的(但反之就不一定),这样通过将一些关联的操作代码放到一起执行可以提升效率,因为从内存加载是很慢的操作,但处理器可以继续执行不依赖前面加载内存数据结果的指令。

目录
相关文章
|
1月前
|
缓存 搜索推荐 Android开发
安卓开发中的自定义控件实践
【10月更文挑战第4天】在安卓开发的海洋中,自定义控件是那片璀璨的星辰。它不仅让应用界面设计变得丰富多彩,还提升了用户体验。本文将带你探索自定义控件的核心概念、实现过程以及优化技巧,让你的应用在众多竞争者中脱颖而出。
|
1月前
|
Java Android开发 Swift
安卓与iOS开发对比:平台选择对项目成功的影响
【10月更文挑战第4天】在移动应用开发的世界中,选择合适的平台是至关重要的。本文将深入探讨安卓和iOS两大主流平台的开发环境、用户基础、市场份额和开发成本等方面的差异,并分析这些差异如何影响项目的最终成果。通过比较这两个平台的优势与挑战,开发者可以更好地决定哪个平台更适合他们的项目需求。
98 1
|
7天前
|
编解码 Java Android开发
通义灵码:在安卓开发中提升工作效率的真实应用案例
本文介绍了通义灵码在安卓开发中的应用。作为一名97年的聋人开发者,我在2024年Google Gemma竞赛中获得了冠军,拿下了很多项目竞赛奖励,通义灵码成为我的得力助手。文章详细展示了如何安装通义灵码插件,并通过多个实例说明其在适配国际语言、多种分辨率、业务逻辑开发和编程语言转换等方面的应用,显著提高了开发效率和准确性。
|
6天前
|
Android开发 开发者 UED
安卓开发中自定义View的实现与性能优化
【10月更文挑战第28天】在安卓开发领域,自定义View是提升应用界面独特性和用户体验的重要手段。本文将深入探讨如何高效地创建和管理自定义View,以及如何通过代码和性能调优来确保流畅的交互体验。我们将一起学习自定义View的生命周期、绘图基础和事件处理,进而探索内存和布局优化技巧,最终实现既美观又高效的安卓界面。
19 5
|
4天前
|
JSON Java Android开发
探索安卓开发之旅:打造你的第一个天气应用
【10月更文挑战第30天】在这个数字时代,掌握移动应用开发技能无疑是进入IT行业的敲门砖。本文将引导你开启安卓开发的奇妙之旅,通过构建一个简易的天气应用来实践你的编程技能。无论你是初学者还是有一定经验的开发者,这篇文章都将成为你宝贵的学习资源。我们将一步步地深入到安卓开发的世界中,从搭建开发环境到实现核心功能,每个环节都充满了发现和创造的乐趣。让我们开始吧,一起在代码的海洋中航行!
|
6天前
|
缓存 数据库 Android开发
安卓开发中的性能优化技巧
【10月更文挑战第29天】在移动应用的海洋中,性能是船只能否破浪前行的关键。本文将深入探讨安卓开发中的性能优化策略,从代码层面到系统层面,揭示如何让应用运行得更快、更流畅。我们将以实际案例和最佳实践为灯塔,引领开发者避开性能瓶颈的暗礁。
17 3
|
8天前
|
存储 IDE 开发工具
探索Android开发之旅:从新手到专家
【10月更文挑战第26天】在这篇文章中,我们将一起踏上一段激动人心的旅程,探索如何在Android平台上从零开始,最终成为一名熟练的开发者。通过简单易懂的语言和实际代码示例,本文将引导你了解Android开发的基础知识、关键概念以及如何实现一个基本的应用程序。无论你是编程新手还是希望扩展你的技术栈,这篇文章都将为你提供价值和启发。让我们开始吧!
|
1月前
|
Web App开发 安全 程序员
FFmpeg开发笔记(五十五)寒冬里的安卓程序员可进阶修炼的几种姿势
多年的互联网寒冬在今年尤为凛冽,坚守安卓开发愈发不易。面对是否转行或学习新技术的迷茫,安卓程序员可从三个方向进阶:1)钻研谷歌新技术,如Kotlin、Flutter、Jetpack等;2)拓展新功能应用,掌握Socket、OpenGL、WebRTC等专业领域技能;3)结合其他行业,如汽车、游戏、安全等,拓宽职业道路。这三个方向各有学习难度和保饭碗指数,助你在安卓开发领域持续成长。
60 1
FFmpeg开发笔记(五十五)寒冬里的安卓程序员可进阶修炼的几种姿势
|
14天前
|
Java API Android开发
安卓应用程序开发的新手指南:从零开始构建你的第一个应用
【10月更文挑战第20天】在这个数字技术不断进步的时代,掌握移动应用开发技能无疑打开了一扇通往创新世界的大门。对于初学者来说,了解并学习如何从无到有构建一个安卓应用是至关重要的第一步。本文将为你提供一份详尽的入门指南,帮助你理解安卓开发的基础知识,并通过实际示例引导你完成第一个简单的应用项目。无论你是编程新手还是希望扩展你的技能集,这份指南都将是你宝贵的资源。
43 5
|
13天前
|
设计模式 IDE Java
探索安卓开发:从新手到专家的旅程
【10月更文挑战第22天】 在数字时代的浪潮中,移动应用开发如同一座金矿,吸引着无数探险者。本文将作为你的指南针,指引你进入安卓开发的广阔天地。我们将一起揭开安卓平台的神秘面纱,从搭建开发环境到掌握核心概念,再到深入理解安卓架构。无论你是初涉编程的新手,还是渴望进阶的开发者,这段旅程都将为你带来宝贵的知识和经验的财富。让我们开始吧!