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上是不需要的。将一些操作移到关键代码区里会是安全的(但反之就不一定),这样通过将一些关联的操作代码放到一起执行可以提升效率,因为从内存加载是很慢的操作,但处理器可以继续执行不依赖前面加载内存数据结果的指令。

目录
相关文章
|
2天前
|
Linux 编译器 Android开发
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
在Linux环境下,本文指导如何交叉编译x265的so库以适应Android。首先,需安装cmake和下载android-ndk-r21e。接着,下载x265源码,修改crosscompile.cmake的编译器设置。配置x265源码,使用指定的NDK路径,并在配置界面修改相关选项。随后,修改编译规则,编译并安装x265,调整pc描述文件并更新PKG_CONFIG_PATH。最后,修改FFmpeg配置脚本启用x265支持,编译安装FFmpeg,将生成的so文件导入Android工程,调整gradle配置以确保顺利运行。
22 1
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
|
25天前
|
Java Android开发
Android 开发获取通知栏权限时会出现两个应用图标
Android 开发获取通知栏权限时会出现两个应用图标
12 0
|
1月前
|
XML 缓存 Android开发
Android开发,使用kotlin学习多媒体功能(详细)
Android开发,使用kotlin学习多媒体功能(详细)
101 0
|
1月前
|
设计模式 人工智能 开发工具
安卓应用开发:构建未来移动体验
【2月更文挑战第17天】 随着智能手机的普及和移动互联网技术的不断进步,安卓应用开发已成为一个热门领域。本文将深入探讨安卓平台的应用开发流程、关键技术以及未来发展趋势。通过分析安卓系统的架构、开发工具和框架,本文旨在为开发者提供全面的技术指导,帮助他们构建高效、创新的移动应用,以满足不断变化的市场需求。
18 1
|
1月前
|
机器学习/深度学习 调度 Android开发
安卓应用开发:打造高效通知管理系统
【2月更文挑战第14天】 在移动操作系统中,通知管理是影响用户体验的关键因素之一。本文将探讨如何在安卓平台上构建一个高效的通知管理系统,包括服务、频道和通知的优化策略。我们将讨论最新的安卓开发工具和技术,以及如何通过这些工具提高通知的可见性和用户互动性,同时确保不会对用户造成干扰。
33 1
|
16天前
|
XML 开发工具 Android开发
构建高效的安卓应用:使用Jetpack Compose优化UI开发
【4月更文挑战第7天】 随着Android开发不断进化,开发者面临着提高应用性能与简化UI构建流程的双重挑战。本文将探讨如何使用Jetpack Compose这一现代UI工具包来优化安卓应用的开发流程,并提升用户界面的流畅性与一致性。通过介绍Jetpack Compose的核心概念、与传统方法的区别以及实际集成步骤,我们旨在提供一种高效且可靠的解决方案,以帮助开发者构建响应迅速且用户体验优良的安卓应用。
|
19天前
|
监控 算法 Android开发
安卓应用开发:打造高效启动流程
【4月更文挑战第5天】 在移动应用的世界中,用户的第一印象至关重要。特别是对于安卓应用而言,启动时间是用户体验的关键指标之一。本文将深入探讨如何优化安卓应用的启动流程,从而减少启动时间,提升用户满意度。我们将从分析应用启动流程的各个阶段入手,提出一系列实用的技术策略,包括代码层面的优化、资源加载的管理以及异步初始化等,帮助开发者构建快速响应的安卓应用。
|
19天前
|
Java Android开发
Android开发之使用OpenGL实现翻书动画
本文讲述了如何使用OpenGL实现更平滑、逼真的电子书翻页动画,以解决传统贝塞尔曲线方法存在的卡顿和阴影问题。作者分享了一个改造后的外国代码示例,提供了从前往后和从后往前的翻页效果动图。文章附带了`GlTurnActivity`的Java代码片段,展示如何加载和显示书籍图片。完整工程代码可在作者的GitHub找到:https://github.com/aqi00/note/tree/master/ExmOpenGL。
19 1
Android开发之使用OpenGL实现翻书动画
|
19天前
|
Android开发 开发者
Android开发之OpenGL的画笔工具GL10
这篇文章简述了OpenGL通过GL10进行三维图形绘制,强调颜色取值范围为0.0到1.0,背景和画笔颜色设置方法;介绍了三维坐标系及与之相关的旋转、平移和缩放操作;最后探讨了坐标矩阵变换,包括设置绘图区域、调整镜头参数和改变观测方位。示例代码展示了如何使用这些方法创建简单的三维立方体。
15 1
Android开发之OpenGL的画笔工具GL10
|
25天前
|
Android开发
Android开发小技巧:怎样在 textview 前面加上一个小图标。
Android开发小技巧:怎样在 textview 前面加上一个小图标。
12 0