# iOS 一窥并发编程底层(一)

语歌博客

逻辑控制流

在我们系统中通常是会有其它程序在运行,进程是可以告诉每一个程序它是独自在使用处理器。这个时候如果有调试器单步去执行程序,就会出现一系列的程序计数器( PC ) 值,这些值唯一的对应于包含在程序的可执行目标文件的指令。这个所谓的 PC 值叫做 逻辑控制流

一句话简单的介绍什么是并发:

  • 如果逻辑控制流在时间上重叠就是并发 (concurrent)

e.g:

  •   往宏观上讲:在计算机系统中硬件异常处理程序, 我们进行 `Command + C `的时候
    
  •   往微观上讲:I/O 多路复用,应用程序在一个进程的上下文中显示地调度它们的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。
    
  • .... 如果在底层上面扣太多,就不是 iOS 方面的内容了。

我们知道对 应用层的开发 都是通过对底层的一个 API 的封装,这里也会简单介绍一下底层方面的理论。

如果要了解并发编程就免不了 进程,线程 这些字眼。


进程

我相信从事 IT 行业的开发人员,对于它是不陌生的,千篇一律的话我就不多说了。下面就简单聊点不常知道的。
首先进程有独立的虚拟地址空间,如果想要和其他流进行通信,就是进程与进程之间进行通信,控制流必须使用某种显示的进程间通信机制 (IPC)

线程

线程是运行在一个单一进程上下文的逻辑流,由内核进行调度。

在 Obj中国 上我看到有用 pthread 进行示例证明。 我这里会适当的补充一点。

Posix 线程(Pthread) 是在 C 程序中处理线程的一个标准接口,在所有的 Linux 上都适用。 那么 Objective- C 对它的依赖就可想而知了。

Pthread 定义了大约 60多个 个函数,它们分别用来进行创建,杀死,和回收线程.对线程安全地共享数据,通知对等线程系统状态的变化等等。

直接适用 Pthread 的函数是非常繁琐的,那么到了 OC 这里就免不了对它进行了二次封装, 就这样来到了 Cocoa 那么到了 Swift 里面也基本是换汤不换药的拿来即用。


现在正式来到多线程的世界

先说点不厌其烦的废话知识:

在 Objective-C 中常用的加锁方式有用 @synchronized 来修饰变量,以此来保证变量在作用范围内不会被其他线程改变。

那在 Swift 中是用 objc_sync_enterobjc_sync_exit 配合来使用加锁

上面提到锁相关的一些信息,那么它存在的目的无非就是一个:就是在多线程共享相同的程序变量

那么它在底层的原理是什么?正是这里要讲的。

问题
1.多线程存在的时候其基础的内存模型是什么?
2.变量如何映射到内存里面去?
3.引用这些变量的线程有多少?

每个线程都有它自己独立的线程上下文,包括线程的 ID, 栈 , 栈指针 ,程序计数器, 条件码, 通用目的寄存器

每个线程和其他线程一起共享进程的上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本,读/写数据, 栈以及所有的共享库代码和数据区域组成。

任何线程都可以访问共享虚拟内存的任意位置, 如果众多线程中的某一个线程修改了一个内存位置,那其他的线程都能在它读到这个位置时发现这个变化。

虚拟内存对相关变量的一些操作

  1. 全局变量:
    虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以调用

  2. 本地变量:
    每个线程的栈都包含它自己的所有本地自动变量

  3. 本地的静态变量:本地带 Static 属性, 虚拟内存的读/写区域只包含程序中声明的每个本地静态变量的一个实例

信号量

计数器 (引用计数)
同步错误 synchronization error
进度图 progress graph


计数器 (引用计数) 与 同步错误 (synchronization error)

在多线程访问同一个全局变量的时候,我们查看对计数相关的汇编代码,过程大致如下:

  1. 加载全局变量 cnt 到累加寄存器 %rdx (当前线程的寄存器 %rdx 的值)
  2. 增加 %rdx 的指令
  3. %rdx的更新值存回到共享变量 cnt 的指令

当然在iOS当中不会直接这样计数,因为这样会存在一个很大的问题:

当两个线程同时对一个计数器的值进行读取,并加1,再将结果写到内存中去,这个时候计数器就会出现问题。因为计数器加了两次而写到内存中确是相当于只加了一次的那个值

引用 Obj中国 上面一个例子:

线程 A 和 B 都从内存中读取出了计数器的值,假设为 1 ,然后线程A将计数器的值加1,并将结果 2 写回到内存中。同时,线程B也将计数器的值加 1 ,并将结果 2 写回到内存中。实际上,此时计数器的值已经被破坏掉了,因为计数器的值 1 被加 1 了两次,而它的值却是 2。

在iOS 开发的应用层上面来看就是加锁等等一系列的操作。在真正核心底层方面好多文章是没有具体去讲的,您可以综合性的看看其他的文章,推荐 Obj中国 上面关于多线程系统的讲法。好了,我们继续:

进度图 progress graph

将 n 个并发线程的执行抽象为一条 n 维 笛卡尔空间 中的轨迹线.

# iOS 一窥并发编程底层(一)_第1张图片

每条轴的 k 对应线程 k 的进度。每个点代表线程 k已经完成了指令 I_k的状态。

我们上面讲的:

在多线程访问同一个全局变量的时候,我们查看对计数相关的汇编代码,过程大致分为3步

我们这里设定在 A 线程的时候步骤为:

第一步 第二步 第三步
A1 A2 A3

同理设定在 B 线程的时候步骤为:

第一步 第二步 第三步
B1 B2 B3

这个时候我们来看下图

# iOS 一窥并发编程底层(一)_第2张图片

我们看到图中有一个点 (A1,B3).
这个点的意思就是:当线程 A 完了第 A1 状态的同时,线程 B 完成了 B3 状态。

使用进度图的目的就是讲指令执行模型转化为从一种状态到另一种状态的转换。

这样就可以把程序的执行历史转换为状态空间中的一条轨迹线。

对于线程不管是 A 或者 B 也好,对全局变量的的操作(A1,A2,A3)步骤或者 (B1,B2,B3)步骤的过程中构成了一个临界区,这个临界区不应该和其他进程的临界区交替执行。我们确保每个线程在执行它的临界区中的指令时,拥有对共享变量 的 互斥 的访问( Mutually exclusive access). 通常这种现象称为互斥(Mutual exclusion).

这样在上图里面会出现这样的规则:相同指令不能再同一时刻完成,对角线的线是不存在的。


# iOS 一窥并发编程底层(一)_第3张图片

两个临界区的交集形成的状态空间区域称为不安全区(unsafe region)

# iOS 一窥并发编程底层(一)_第4张图片

安全轨迹线:不在不安全区的轨迹线
不安全轨迹线:雷区的轨迹线

任何安全轨迹线都将正确地更新共享计数器。为了保证任意的全局变量在并发线程的正确执行,我们就必须以某种方式同步线程,使他们总是有一条安全轨迹线。其思想原理的基本思想就是基于 信号量

信号量: (semaphore) 一种特殊类型的变量。
信号量以s表示.是具有非负整数值的全局变量,只能有两种特殊的操作来处理,这两种操作称为 P 和 V:

P(s): 如果 s 是非零的,那么P 将 s 减1,并且立即返回。如果 S 为零,那么就挂起这个线程,直到 s 为零,而一个 V 操作会重启这个线程。在重启之后,P 操作将 s减1,并将控制返回给调用者。

V(s): V操作将 s 加1。如果有任何线程阻塞在 P 操作等待 s 变成非零,那么 V 操作会重启这些线程中的一个,然后该线程将 s 减1,完成它的 P 操作。

P 中的测试和减1操作是不可分割的,一旦预测信号量s 变为非零,就会将s减1,不能有中断操作,这个过程中不会有中断。 V 的加1 操作也是不可分割的。

没有中断的操作

加载 加1 存储信号

ps: V 的定义中没有定义等待线程被重启动的顺序。唯一的要求是 V 必须只能重启一个正在等待的线程。因此,当有多个线程在等待同一个信号量时,就不能预测 V 操作要重启哪一个线程。

P和V 的定义确保了一个正在运行的程序绝不可能进入这一种状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为 信号量不变性(semaphore invariant)

使用信号量来实现互斥

作用是:
将每个**全局变量 **与 一个信号量 s = 1 联系起来,然后用 P(s) 和 V(s) 操作将相应的临界区包围起来。这种方式成为 二元信号量 (binary semaphore),它的值要么是 0 要么是 1。以提供互斥为目的的二元型号量常常称为 互斥锁 (mutex).

那么在一个互斥锁上执行 P 操作称为对互斥锁加锁。执行 V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用了这个互斥锁。 一个被用作一组可用资源的计数器的信号量被称为 计数信号量

如上面的雷区图,在雷区内因为信号量的不确定性故: s < 0

以上看到的仍然是坑: 因为上面是单处理器的讲解
但是有一个是万用的:同步对共享变量的访问是必须的。

多线程中对相同资源的访问:
案例1:
在多媒体开发过程中对视频的帧编码,并实时播放。这个时候就会有一个缓存的东西存在,其存在的目的是为了减少视频流的抖动,引起的原因是帧的编码与解码时与数据相关的差异引起的。

案例2:
我们开发过程中对手机屏幕点击事件的产生后,该事件先进入缓存中,然后多线程根据优先级来从缓冲区里面取出该事件进行响应。这就能很好解释有时点击屏幕卡屏了一会儿才响应。

饥饿问题:
这个网上帖子泛滥: 传送门 Obj中国

多个线程并行处理分配给它们的区域处理方法:
主线程给其他开的线程一个整数理解为该线程的 ID。每个线程用它的ID来决定它应该计算序列的哪一部分。

并行程序的性能

运行时间是衡量程序性能的最终标准。相对衡量标准能够说明并行程序有多好地利用了潜在的并行性。
并行程序的加速比(speedup)通常定义为: Sp = T1/Tp

p 是处理器的核树,Tk 是在 K 个核上的运行时间。这个公式被称为:强扩展(strong scaling).

  1. 当T1是程序顺序执行版本的执行时间时,Sp称为 绝对加速比 (absolute speedup).
  2. 当T1是程序并行版本在一个核上的执行时间,Sp称为 相对加速比 (absolute speedup).

绝对加速比会比相对加速比更加难以测量,因为测量绝对加速比需要程序的两种不同的版本。对于复杂的并行代码,创造一个独立的顺序版本也不现实。

效率: Ep = Sp/p = T1/pTp

弱扩展:(weak scaling): 在增加处理器数量的同时,增加问题的规模,这样随着处理器的数量的增加,每个处理器执行的工作量保存不变,在这样的情况下加速比和效率被表达为单位时间完成的工作量。

线程安全

首先被称为线程安全是当且仅当被多个多线程反复的调用,它才会一直产生正确的结果。如果一个函数设计的不是线程安全的,它就是线程不安全的。

线程不安全的函数定义:

  1. 对全局变量的保护
  2. 保存跨越多个调用的状态函数。如:随机数生产的函数
  3. 返回指向静态变量的指针的函数。
  4. 调用线程不安全函数的函数。

每个的具体例子有点多分下一章节进行

iOS 一窥并发编程底层(二)

你可能感兴趣的:(# iOS 一窥并发编程底层(一))