惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
CXSECURITY Database RSS Feed - CXSecurity.com
Google Online Security Blog
Google Online Security Blog
The Last Watchdog
The Last Watchdog
S
Security @ Cisco Blogs
Help Net Security
Help Net Security
Security Archives - TechRepublic
Security Archives - TechRepublic
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
W
WeLiveSecurity
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
H
Hacker News: Front Page
人人都是产品经理
人人都是产品经理
aimingoo的专栏
aimingoo的专栏
Vercel News
Vercel News
Microsoft Azure Blog
Microsoft Azure Blog
小众软件
小众软件
Project Zero
Project Zero
T
Tailwind CSS Blog
V
Vulnerabilities – Threatpost
P
Privacy & Cybersecurity Law Blog
Know Your Adversary
Know Your Adversary
Last Week in AI
Last Week in AI
腾讯CDC
Schneier on Security
Schneier on Security
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
WordPress大学
WordPress大学
量子位
L
Lohrmann on Cybersecurity
J
Java Code Geeks
Cyberwarzone
Cyberwarzone
Recent Announcements
Recent Announcements
IT之家
IT之家
博客园_首页
罗磊的独立博客
博客园 - 聂微东
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Application and Cybersecurity Blog
Application and Cybersecurity Blog
F
Fortinet All Blogs
博客园 - Franky
P
Palo Alto Networks Blog
V2EX - 技术
V2EX - 技术
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Martin Fowler
Martin Fowler
N
News and Events Feed by Topic
C
Cybersecurity and Infrastructure Security Agency CISA
B
Blog RSS Feed
Cisco Talos Blog
Cisco Talos Blog
TaoSecurity Blog
TaoSecurity Blog
H
Heimdal Security Blog
G
GRAHAM CLULEY
Cloudbric
Cloudbric

一个工匠

AI 发展临界点 - 快上车 流式响应(Chat):HTTP 响应体字节流的读取与解析 iPhone 截屏高效翻译 Flash Open Terminal iPhone 侧载安装 Xcode Symbolic Debug AI 指北 投资风险收益评估 Mac 快速修改系统快捷键 Git Record passkey How to pay apple frameworks Mac - Command Line Tools DNS 的 CNAME 是如何工作的 WebRTC 的一些解释 SSE 指南 1.1.1.1、WARP 和 MASQUE 移动端换端方案与场景还原 设备发现 分辨率 - 像素密度 - HiDPI Mac 字体 ESP32 trump - 川普 币圈 - 虚拟货币 【旅游】富士山 【旅游】sakura(樱花) 【旅游】千岛湖 我的 2023 【Swift】中文词语纠错 计算机字符编码与内存编码 - Unicode、UTF8、String 从汇编角度理解 “函数调用栈” 和 “有栈协程” 在 Xcode 中更好的使用 Swift 通过 Swift Package 制作二进制库 Arc 浏览器 - 意外的惊喜 【Swift 三方源码 1】SwiftShell 高效的命令行工具 Swift 脚本开发环境搭建 rime - 小鹤双拼 【旅游】沿海旅游 (威海、青岛) 好记性远远不如烂笔头 高级语言小记 又一年高考 Vision 未来已来 致敬耗子哥 Socket 与 ICMP 对互联网隐私的一些想法 网络代理是如何工作的 (致敬 Surge) 又十年 - 至三十而立 双拼 - 少有的高回报效率技能 Vim 技巧 我的 2022 三年疫情五味杂陈 - 下 老梗 - 浏览器按回车后发生了什么 三年疫情五味杂陈 生活推荐清单 Shell 和进程 有计划的努力 内卷 不确定性 数学可以保障逻辑自洽 这些年我的软件清单 决定做一些改变 函数调用栈之彻底理解 锁 - 共享数据安全指↑ 江苏女有感 碎碎念 - 2022 碎碎念 - 2021 打算记录一些碎碎念 多重性格就是逆天改命 从 Core Foundation 看更大世界 TCP 数据传输过程分析 iOS main 文件中的 @autoreleasepool 的真实用途 Objective-C 和 Runtime 早起打卡计划 ing 打比方 IM 和 Socket 的关系及 Heart 的必要性 如果我的人生突然只剩下 8 小时... 胡说八道之雪粉 Array 和 LinkedList 的深刻认知 Swift struct (值类型) 与 class (引用类型) 区别及三种传参机制深刻关系和使用 逃离信用卡和花呗 Swift_let_var_struct_class 网聚人的力量 - 网易 绿皮火车 喝酒十之八九会误事 HKong 胡说八道之云服务 互联网网络传输协议理解坑点 CPU 的制作 我心里一直有一个念想 网络基础协议 为什么 32 位操作系统只能用 4G 内存空间 努力和天赋 洗牙 RAC-Api-Summer iOS 内存原理 Block 闭包简查记录 《白夜行》 谈谈事件驱动 老博新开 祭首
多线程的难点在哪里
海驴 · 2020-05-04 · via 一个工匠

不管在哪门开发语言中,多线程都是绕不过去的开发方式。多线程本身也不是开发语言的一部分,只是开发语言会对多线程进行语言级别的包装。
多线程属于计算机原理的一部分。每个应用都需要操作系统分配一个进程后才能在进程中执行,为了实现并发效果,才有了多进程方案。而进程切换开销太大,这才有了多线程方案。乃至于多线程也是有不小的内存和 CPU 开销,后面的协程才开始起家。
但是协程已经不属于计算机的范畴,协程是单线程的,需要语言级别实现的。有些语言并没有实现协程,也只能使用多线程的方案对 CPU 资源进行深一步的榨干。

像 iOS 开发里面,每个应用都是独立的沙盒,官方并没有提供多进程的方案 (操作系统级别肯定要支持),也没有提供协程方案,如果想更有效的利用 CPU 资源,只能从多线程上面入手。

iOS 里面多线程虽然理解起来有点绕,尤其任务和队列嵌套的时候。但只要多使用几次并加以过程分析,多线程的使用也就能很好的过关了。因为 GCD 本身已经对多线程有了非常棒对封装,只要不自己作死,串型队列和同步任务一起处理的时候小心一些,就不会出现队列等待导致死锁。即使死锁了,像这样的系统中断性问题,也很容易排查和处理。

那多线程,难点在哪里呢?显然不是 Api 级别的多线程接口调用。

移动端界面开发,要保障不出现重大事故,很大程度上不在于 UI。因为 UI 是寄托于数据展示的,只要数据不出现大问题,UI 都是写好的机器代码,基本不会出问题。
但如果数据有间歇性的不稳定,那么对于 App 来说就是一个隐藏的地雷,因为谁也不知道哪个时刻会发生数组越界,或者数据为空。
所以,数据的稳定性和完整性,非常重要。

在多线程情况下,我们会操作 UI 吗?显然不会,多线程更多处理的都是数据。所以多线程难点可以从数据下手。如果多线程情况下数据的可靠性无法保证,宁愿放弃多线程 (FMDB 就是一个例子)
多线程情况下,数据的稳定性和完整性的保障,很复杂吗?其实也并不复杂,因为就两个要素点:原子性粒度的大小和锁
原子性粒度的大小,不会太难控,有些编程经验,不会处理的太差。
而锁机制翻来覆去就那些,系统能够支持的甚至更少。在 Java 里面就有很多不同的锁,在 iOS 里面算来算去就那么几个,还是新瓶装旧酒。

那多线程,难点主要在哪里呢?其实还是数据。难点在于数据的处理!复杂度上的消耗,计算机时间片的消耗。
多线程编程,最终考量的其实是数据结构和算法!

对于上面的诸多观点,下面一一进行分析:

GCD 线程操作的理解

iOS 下面,官方提供的多线程方案有 Pthreads、NSThread、GCD、NSOperation。

Pthreads 是完全 C 开发的,相关函数调用感觉会非常小巧,感受一下,如pthread_create(x ...)pthread_exit(NULL)这样。我一直很喜欢这种面向过程式的函数调用开发方式,显然这样的函数也是和面向对象格格不入的。
NSThread 是对 Pthreads 的 OC 封装,方便使用了一些。但是他们两个都需要自行管理线程生命周期。既然已经面向对象开发,连内存都可以自动释放了,我们还是应该使用更加一体化的多线程方式,那就是 GCD 和 NSOperation。

NSOperation 将多线程的面向对象更加具体化,在处理比较复杂和大型的多线程场景下,非常适用,因为代码理解性和可读性非常高。
其实,iOS 开发人员使用的基本都是 GCD,无出其右。因为工作场景下,GCD 完全可以完成多线程任务了,性能也足够好,使用又方便,代码简短易懂。如果不是复杂和大型的多线程场景,基本也不会去用 NSOperation。
而且 GCD 还有一个大杀器,那就是线程安全锁GCD 将多线程操作和线程安全都涵盖了,我们可以很方便的使用dispatch_barrier_async写出读写锁,也可以使用dispatch_semaphore写出二元和多元信号量锁。
可以说,使用 GCD,把多线程开发的大部分问题一套带走了。当然还有一个没有带走的,就是上面提到的 “数据结构和算法”。

很多人对 GCD 理解困难,其实是被三个方面困住了。一个是不理解队列这种数据结构一个是不理解任务这种执行方式一个是不写代码进行测试和分析执行过程

同步任务和异步任务

一个任务是同步还是异步,是依靠线程的。因为我们函数执行过程中,只可能在一个线程里面执行。如果是同步任务,那么不可能换线程,如果是异步任务,那么必须要换线程。
函数执行是一个函数调用执行栈空间,通过 rbp 和 rsp 两个寄存器不断上下移动栈指针位置来实现的。而一个函数调用执行栈就专属于一个线程。
如果是同步任务,只能在当前函数调用栈执行,所以开启不了新线程。
如果是异步任务,必须要脱离当前函数调用栈,必须要开启新线程。(不开启新线程也可以,就是协程方案。但是 OC 不支持协程,所以只能开启新线程。)

同步任务要点

  1. 同步任务立刻被放入队尾(但是不一定立刻执行,因为队列里面可能已经有任务 X 和 Y,则必须等 X 和 Y 出队 [如果是同步任务还必须执行完] 后才能执行被放入队尾的同步任务)。
  2. 同步任务一定要被执行完后才能继续后面的代码执行。
  3. 不具备开启新线程能力。同步任务被调用的时候在 A 线程,执行也一定在 A 线程。

异步任务要点

  1. 异步任务立刻被放入队尾(但是不一定立刻执行,因为队列里面可能已经有任务 X 和 Y,则必须等 X 和 Y 出队后才能执行被放入队尾的异步任务)。
  2. 异步任务因为肯定会开启新线程,所以后续代码立刻执行。
  3. 具备开启新线程能力,而且一定要开启。但是开启线程数量由队列决定。异步任务被调用的时候在 A 线程,执行一定不在 A 线程。

串行队列和并发队列

队列 (Queue) 是非常基本的数据结构,基于数组或者链表这两种物理结构实现。队列它的特点就是:外部数据从队尾入队,内部数据从队头出队。
比如这个队列:队尾->A->B->C->队头,如果现在加入外部数据D,那么 D 只能添加在 A 的后面,想插队添加到指定 index 是不可能的。而如果内部数据想被删除,只能先删除 C,然后才能继续删除 B 和 A。想插队删除元素,也不可能。

串行队列要点

  1. 允许开启线程,最多开启 1 个线程,是否开启线程由任务决定。
  2. 所有任务必须依次出队,必须上一个出队的任务处理完,下一个任务才允许出队并执行。

并发队列要点

  1. 允许开启线程,可以开启多个线程 (100 以上都有可能,依靠系统调度),是否开启线程由任务决定。
  2. 所有任务必须依次出队,但是下一个任务出队不需要上一个任务执行完。

用上面 ABC 队列举例,如果 A、B、C 任务分别需要执行 10s。
相关伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 被外界业务调用的函数
- (void)test {
/*
...
这里有业务代码执行,标记这块区域为X,此时X执行代码所在线程我们假设为thread_1。(thread_1也就是当前test函数被执行所在的线程。)
...
*/
dispatch_queue_t queue = 队列;
任务C(queue, ^{
[NSThread sleepForTimeInterval:10];
});
任务B(queue, ^{
[NSThread sleepForTimeInterval:2];
});
任务A(queue, ^{
[NSThread sleepForTimeInterval:2];
});
/*
...
这里有业务代码执行,标记这块区域为Y,此时Y执行代码一定也在thread_1线程
...
*/
}

在串行队列下有下面可能:

  1. X 为 thread1,任务为 thread1,执行过程为:X_thread1->(C->10s 后 ->B->10s 后 ->A->10s 后)_thread1->Y_thread1(A、B、C 均为同步任务)
  2. X 为 thread1,任务为 thread2,执行过程为:X_thread1->(C->10s 后 ->B->10s 后 ->A->10s 后)_thread2->Y_thread1(A、B、C 均为异步任务)
  3. X 为 thread1,任务为 thread1 和 thread2,执行过程为:X_thread1->(C->10s 后)_thread1->(B)_thread2->(A->10s 后)_thread1->Y_thread1(A、C 均为同步任务,B 异步)
    1. 分析一下:因为 C 是同步任务,所以必须 C 执行 10s 后,后面的任务才能出队。因为 B 是异步任务,所以 C 执行完后,B 先出队,但 A 不用等 B 执行完即可出队执行。所以 B 和 A 可以说是并发执行的。
    2. A 出队后,只有当 A 被执行完成,后面的业务代码才能继续执行。

在并发队列下有下面可能:

  1. X 为 thread1,任务为 thread1,执行过程为:X_thread1->(C->10s 后 ->B->10s 后 ->A->10s 后)_thread1->Y_thread1(A、B、C 均为同步任务)
  2. X 为 thread1,任务为 threadx,执行过程为:X_thread1->Y_thread1->(C)_threadx->(B)_threadx->(A)_threadx(A、B、C 均为异步任务)
    1. 分析一下:首先 ABC 都是异步任务,在并发队列里面都会开启新线程,所以 X 执行完后把 ABC 添加到队列后,不会等 ABC 的执行过程,直接就会执行 Y 了。为什么呢?因为 ABC 在其他线程,由其他线程负责执行,ABC 的代码执行调用栈都不在 thread1 上面,而是在他们各自对应的线程。
    2. 其次,ABC 三个任务,C 会先出队,然后是 B,然后是 A。他们出队顺序是固定的,但是因为他们各自在各自的执行线程,所以执行的先后顺序是不确定的。
    3. ABC 是否开启多个线程有系统决定。如果系统开启 3 个线程,那么 ABC 会各自在自己的线程执行,没有先后顺序。如果系统仅仅开启 2 个线程,那么 A 会被分配到 C 或者 B 的执行线程,这个时候 A 就必须要等 C 或者 B 执行完才能执行(代码执行依靠调用栈,当前在执行 C 或者 B,就不可能执行 A,只能等 C 或者 B 执行完,当前调用栈结束,才能继续执行 A)。
  3. X 为 thread1,任务为 thread1 和 thread2,执行过程为:X_thread1->(C->10s 后)_thread1->(B)_thread2->(A->10s 后)_thread1->Y_thread1(A、C 均为同步任务,B 异步)
    1. 分析过程和串行队列一致

上面的结果都只是一小部分。因为任务可能会有多个同步和异步穿插,所以整体执行过程会更复杂 (如 A 同步 + B 异步 + C 同步 + D 异步等)。但只要记住上面同步异步及串行并发的要点部分,整体抽丝剥茧来分析,过程并不难理解。

多写测试代码多分析

多写一些测试代码并分析过程,GCD 的内容很快就能理解。
多写一些串行队列嵌套同步任务,很容易出现死锁,很快就能根据上面的几个要点分析出来死锁原因。
所以死锁和主队列没多大关系,只要是串行队列嵌套同步任务,都可能出现死锁
主队列就是串行队列的特殊形式,因为主队列比串行队列更严苛,主队列不能开启新线程,只能在主线程运行,这就是主队列的要点,其他和串行队列一致

比如下面这个多线程代码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 被外界业务调用的函数
- (void)test {
/*
...
这里有业务代码执行,标记这块区域为X,此时X执行代码所在线程我们假设为thread_1。(thread_1也就是当前test函数被执行所在的线程。)
...
*/
dispatch_queue_t queue = dispatch_queue_create("", DISPATCH_QUEUE_SERIAL);
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// C
dispatch_sync(queue, ^{
[NSThread sleepForTimeInterval:1];
});
});
// B
dispatch_async(queue, ^{
[NSThread sleepForTimeInterval:2];
});
// A
dispatch_sync(queue, ^{
[NSThread sleepForTimeInterval:2];
});
/*
...
这里有业务代码执行,标记这块区域为Y,此时Y执行代码一定也在thread_1线程
...
*/
}

它的执行流程是:X_thread1->(C->1s)_thread1->(B->2s->C->2s)_thread1->Y_thread1
分析:C 被异步加入到串行队列里面,所以这个时候队列里面已经有 C。因为 C 是异步加入的,所以 B 代码也会立刻执行并被立刻加入串行队列。但是 B 没有办法执行,因为 C 需要 1s 才能执行完成。当 1s 过后,B 才能出队并执行。所以代码会在 B 处停 3s 然后才能将 A 加入队列并执行。

多线程数据处理之原子性粒度

如果上面 GCD 你已经能够熟练的分析并使用了,可能会有种大悟的感觉,原来多线程也不过如此。
可千万不要认为,上面 GCD 的使用,就是多线程的全部,相关多线程的坑来说,上面都是皮毛,多线程的坑完全不是串行队列死锁那么简单。

我们看最简单的一种情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@interface VC ()  {
int _number;
}
@end

@implementation VC
- (void)test {
_number = 0;
dispatch_queue_t queue = dispatch_queue_create("", DISPATCH_QUEUE_CONCURRENT);
dispatch_group_t group = dispatch_group_create();
for (int i = 0; i < 10000; ++i) {
dispatch_group_async(group, queue, ^{
self->_number = self->_number + 1;
});
}
for (int i = 0; i < 10000; ++i) {
dispatch_group_async(group, queue, ^{
self->_number = self->_number - 1;
});
}
dispatch_group_notify(group, queue, ^{
printf("the _number is %d\n", self->_number);// 很少会打印0,有可能是-12,有可能是10,反正很少为0
});
}
@end

上面,我们多个异步线程操作_number 成员变量,分别进行 10000 次增减 1,最后结果不是 0。这就是多线程的一个坑。
出现上面的原因就是,self->_number = self->_number + 1;这行代码不是原子的。
你可能会觉得,那这样的等号赋值可能有问题,那++self->_number;这样的方式进行增 1 操作会不会正常?
结果就是,一样不正常。不正常的原因,依旧因为++self->_number;这行代码也不是原子的。
那什么是原子的操作?
一条 CPU 执行的一个单指令,就是原子的++--这样的高级语言,在汇编后都会被编译成好几个操作指令。当计算机把操作指令执行了一半的时候,另一个线程也会开始执行,这个时候前一个线程就会被系统调度打断,去执行下一个线程的指令。所以,数据这个时候就产生了紊乱,导致++--操作完全乱套了。

问题已经讲述清楚了,那该怎么解决呢?就是要手动制造原子性。一个操作指令是原子的,但是我们不可能把高级语言写出操作指令的形式,那样就回到汇编时代了。所以我们需要在外部制造更大的原子性区域,在这个区域里面,同一时间只能有一个线程操作。这样,就不会出现区域里面的代码执行一半转而另一个线程闯进来了。

原子性是有粒度大小的,如果粒度过大,则多线程间接变成单线程。如果粒度过小,则可能不足以保障原子性
下面举例分析:

粒度过大:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
- (void)test {
_number = 0;
dispatch_queue_t queue = dispatch_queue_create("", DISPATCH_QUEUE_CONCURRENT);
dispatch_group_t group = dispatch_group_create();
for (int i = 0; i < 10000; ++i) {
dispatch_group_async(group, queue, ^{
@synchronized (self) {// 这里为了测试方便,使用了self,开发过程中不要这么用,应该维持一个私有对象用来做标记
[self add];
}
});
}
for (int i = 0; i < 10000; ++i) {
dispatch_group_async(group, queue, ^{
@synchronized (self) {
[self sub];
}
});
}
dispatch_group_notify(group, queue, ^{
printf("the _number is %d\n", self->_number);
});
}

- (void)add {
/*
...
这里有很多代码逻辑,预计1000行
...
*/
++_number;
/*
...
这里有很多代码逻辑,预计1000行
...
*/
}

- (void)sub {
/*
...
这里有很多代码逻辑,预计1000行
...
*/
--_number;
/*
...
这里有很多代码逻辑,预计1000行
...
*/
}

上面我们的多线程操作主要是 add 和 sub 两个函数,业务处理也都在这两个函数里面。但是我们在调用 add 和 sub 函数的时候,通过 synchronized 锁临时添加了原子性区域,这就导致 add 和 sub 里面的 2000 多行代码,同一时间只能一条线程执行,变成了单线程操作。多线程形同实亡。

粒度过小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@property (atomic) int num;

- (void)test {
self.num = 0;
dispatch_queue_t queue = dispatch_queue_create("", DISPATCH_QUEUE_CONCURRENT);
dispatch_group_t group = dispatch_group_create();
for (int i = 0; i < 10000; ++i) {
dispatch_group_async(group, queue, ^{
++self.num;
});
}
for (int i = 0; i < 10000; ++i) {
dispatch_group_async(group, queue, ^{
--self.num;
});
}
dispatch_group_notify(group, queue, ^{
printf("the _number is %d\n", self.num);// 依旧不为0
});
}

我们定义了一个原子性 (atomic) 的 num 变量,但是最后打印的 num 依旧不为 0。原因就是 num 虽然已经在 set 和 get 方法里面添加了 synchronized 锁,但这个锁只能保障 num 变量在读和取的时候是原子性的。如果两个线程同时读,这个时候两个线程获取到的值是一样的,但是一个线程增 1,一个线程减 1,最后两个线程原子性调用 set 方法赋值。显然,num 的值这个时候就以最后调用 set 方法的线程值为准,不在准确了。
这个时候就是原子性粒度过小,导致虽然添加了锁,但依旧值不准确。

所以为了多线程处理能力最大化 (足够榨干 CPU 资源),也为了数据依旧稳定和准确,原子性的粒度需要考量一个合适的区域。

多线程数据处理之锁

原子性粒度考量完成后,下面就是如何保障这个原子性区域的问题了。上面我们简化说明都使用了 synchronized 锁,但是多线程本身还有其他各种锁,synchronized 只是其中使用最方便但是效率也最低的一种。
iOS 下锁比较好理解,因为不像 JAVA,iOS 下锁就那么多,下面根据多线程锁机制来分析。

《程序员的自我修养 - 链接、装载与库》一书中,在说到线程安全锁机制的时候,概括说了 5 种锁,而 iOS 里面所有锁就是基于其中 4 种来的。
5 种线程锁分别是:信号量、互斥量、临界区、读写锁、条件变量。
信号量:是线程级别的,任何一个线程均可以自行加锁和解锁,任何一个线程也可以对另一个线程已加的锁进行解锁。
互斥量:也是线程级别的,但是比信号量严苛一些。任何一个线程均可以自行加锁和解锁,但是 A 线程不能对 B 线程已加的锁进行解锁,必须有 B 线程自己解锁。(iOS 里面,A 也可以解 B 加的锁,没有报错。)
临界区:进程级别的。比互斥量严苛了一些。加锁解锁被称作进入临界区离开临界区。A 进程创建的临界区,只有 A 进程可以进出,其他进程不能操作。因为 iOS 是沙盒机制,对于单个 App 来说,不存在多进程,所以临界区在 iOS 开发里面用不到。不过系统肯定是需要临界区的,不过那是操作系统的事情了。
读写锁:读的时候数据不需要保障稳定性,所以可以并发读,但是写一定要独立,写的时候只能一个一个写,而且写的时候不能有读操作。也叫共享-独占锁
条件变量:在达到某个特定的条件下,线程才能加锁和解锁。条件可以预先设置好,后面的加锁和解锁就根据条件来触发。

iOS 的锁有二十种左右,但更多都是对几个特定锁对封装。举例来说:

  1. OSSpinLock 自旋锁(特别说明,虽然性能非常高,但是已经被废弃)。本质是互斥锁,ABC 同时访问对时候,C 先进去并加锁,然后 AB 不断循环访问是否解锁,如果解锁,立刻进入并加锁。所以被挡在锁外面对线程没有休息,而是不停对查询。
    1. CPU 消耗很大,因为挡在锁外面对线程一直在不停查询。
    2. 因为优先级反转原因,该锁已经被苹果弃用。比如优先级为:A > B>C。这个时候 C 提前进入加锁并执行代码,但是 A 优先级太高,导致 A 不停查询并占用了非常多对时间片,最后 C 用了很久才执行完并解锁。这个过程中,有太多时间片都浪费在了查询上。
  2. os_unfair_lock 互斥锁的一种,OSSpinLock 的替代品。自旋锁会不停的查询并忙等,os_unfair_lock 会在加锁的情况下,对线程进行休眠。当解锁后继续执行。
    1. 只要是锁,优先级反转都会出现。但是不同于自旋锁,互斥锁会让挡在外面对线程处于休眠状态,在解锁后激活并执行。这样对 CPU 的消耗会很低。
  3. dispatch_semaphore 信号量。可以实现二元信号量和多元信号量。通过信号量还可以做限制并发操作。
  4. pthread_mutex 互斥锁。mutex 是互斥锁的完整体现。基于 mutex 可以实现互斥锁、递归锁、条件锁。效率非常高。
    1. 普通互斥锁:性能很高的锁,挡在锁外面的线程会休眠,不会出现优先级反转后时间片浪费情况。
    2. 递归锁:当 A 线程因为递归等原因,在没有释放锁的情况下,又重新加锁。这个时候互斥锁是不能加锁的,因为之前已经加过锁了。递归锁可以解决这个问题,在递归锁下,同一个线程可以一次加锁,然后一次解锁。
    3. 条件锁:mutex 实现的条件锁,不能根据条件自动加锁解锁。需要动手激活指定条件然后加锁或解锁。
  5. NSLock 互斥锁。对 mutex 普通互斥锁的封装,面向对象。
  6. NSRecursiveLock 递归锁。对 mutex 递归锁的封装,面向对象。
  7. NSCondition 条件锁。对 mutex 条件锁的封装,面向对象。可以预设条件,在条件到达后,自行加锁解锁。相关 mutex 自行实现,代码过程更加自动化。
  8. synchronized 递归锁。对 mutex 递归锁的封装,使用最方便,不需要手动加锁和解锁。但是性能也是所有锁里面最低的。
  9. dispatch_rwlock 读写锁。可以保障写操作的互斥独立,读操作是重入可并发的。
  10. dispatch_barrier_async 栅栏锁。可以分割一段任务队列(警告:必须使用自定义队列,不能使用主队列和全部队列)。也可以用来模拟读写锁,iOS 的属性修饰符 atomic 完全可以通过 dispatch_barrier_async 来实现。
  11. dispatch_group_t 栅栏锁。和 barrier 类似,功能方向略有差异,group 栅栏锁可以实现组的操作。

还有其他一些锁,但可以发现,大差不差,都是对多线程 5 种锁的实现和封装,这 5 种锁分别是:信号量、互斥量、临界区(iOS 开发层面没必要使用)、读写锁、条件变量

在 iOS 开发里面具体使用哪些锁问题已经不大,只要不使用 OSSpinLock,其他锁都可以试一试。目前来看,业务层面开发,NSLock 和 synchronized 用的较多。组件库方面,os_unfair_lock 和 mutex 使用的较多。

多线程数据安全总结

Runtime 源码里面,对于 SideTable 中 weak 表的实现,就是原子性粒度和锁的解释说明。
Runtime 对 weak 表的整个实现,都标记为线程不安全,并且在外部 SizeTable 中定义了 spinlock 自旋锁来限制原子性区域。
所以 weak 表的实现里面,原子性区域还是比较大的,整个 weak 表内部的数据的处理都处于不安全状态,通过最外界的函数调用处,给予自旋锁来保障线程安全。
相关代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct SideTable {
// 锁对象 => 自旋锁,用于上锁/解锁 SideTable
// spinlock_t的最终定义实际上是一个uint32_t类型的非公平的自旋锁。所谓非公平,就是说获得锁的顺序和申请锁的顺序无关,也就是说,第一个申请锁的线程有可能会是最后一个获得到该锁,或者是刚获得锁的线程会再次立刻获得到该锁,造成饥饿等待。 同时,在OC中,_os_unfair_lock_opaque也记录了获取它的线程信息,只有获得该锁的线程才能够解开这把锁。
spinlock_t slock;
// 索引哈希表(稠密哈希) => 对象引用计数map,用来存储OC对象的引用计数(仅在未开启isa优化 或 在isa优化情况下isa_t的引用计数溢出时才会用到)。
// 是一个以objc_object为key的hash表,其vaule就是OC对象的引用计数。同时,当OC对象的引用计数变为0时,会自动将相关的信息从hash表中剔除。
RefcountMap refcnts;
// weak表(核心实现) => 对象弱引用map
weak_table_t weak_table;

// 构造函数
SideTable() {
memset(&weak_table, 0, sizeof(weak_table));
}
// 析构函数(看看函数体,苹果设计的SideTable其实不希望被析构,不然会引起fatal 错误)
~SideTable() {
_objc_fatal("Do not delete SideTable.");
}

// 锁操作 符合StripedMap对T的定义
void lock() { slock.lock(); }
void unlock() { slock.unlock(); }
void forceReset() { slock.forceReset(); }

// Address-ordered lock discipline for a pair of side tables.

template<HaveOld, HaveNew>
static void lockTwo(SideTable *lock1, SideTable *lock2);
template<HaveOld, HaveNew>
static void unlockTwo(SideTable *lock1, SideTable *lock2);
};

还有 Runtime 里面_read_images 函数里面操作 SEL 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    // 将所有SEL都注册到哈希表中,是另外一张哈希表
static size_t UnfixedSelectors;
sel_lock();
for (EACH_HEADER) {
if (hi->isPreoptimized()) continue;

bool isBundle = hi->isBundle();
// 取出的是字符串数组,例如首地址是"class"
SEL *sels = _getObjc2SelectorRefs(hi, &count);
UnfixedSelectors += count;
for (i = 0; i < count; i++) {
// sel_cname函数内部就是将SEL强转为常量字符串
const char *name = sel_cname(sels[i]);
// 注册SEL的操作
sels[i] = sel_registerNameNoLock(name, isBundle);
}
}
sel_unlock();
---
void sel_lock(void)
{
selLock.write();
}
void sel_unlock(void)
{
selLock.unlockWrite();
}
---
rwlock_t selLock;

这里对原子性粒度把控的就很细,用的读写锁。

多线程下数据结构和算法的重要性

到这里,多线程下数据安全基本已经可以告一段落了。但是,还没有结束。
我们之前提过,多线程下数据安全通过原子性粒度的把控和锁机制,已经可以比较好的实现。而粒度控制小心一些,锁就那么多,用的恰当一些,数据安全就没有问题了。
后面的问题,就是原子性区域内部的代码执行效率的问题了。因为原子性区域内部都是单个线程在执行,所以执行效率一定是要很高的
我们举个例子,如果原子性区域里面,代码耗时需要 1s,那么多线程操作下,是不是有很多线程都会被原地休眠?整个执行效率肯定低下的要死。

所以,这个时候,就要使用合适的数据结构和算法,来提高代码执行的效率

我们用 YYCache 的内存缓存举例,YYCache 使用 pthread_mutex 互斥锁,原子性粒度控制的很小,在对数据进行操作的时候才开始加锁和解锁。
缓存使用来 LRU 算法,通过双向链表来实现数据对增和删的复杂度为 O (1)。
但是链表的查询复杂度是比较高的,因为链表无法做随机寻址,也没法用数组的空间局部性缓存加速。
所以作者通过空间换时间的方式,引入了 hash map,将缓存数据存入 hash map 中实现查询复杂度为 O (1)。
这样,整体内存缓存的数据的操作复杂度都将为 O (1)。

之前也写过文章说明数组和链表的优缺点,其中重要一点就是链表的增删复杂度为 1,数组的查复杂度为 1。为了更好的使用数组和链表的双方优点,所以 hash map 和链表常一块使用。

Runtime 里面也是各种 hash table 和 hash map table 的使用,甚至 hash 函数都为了高效,尽可能使用位操作来计算索引值。

所以多线程编程,数据安全能通过锁保障的也都能很好保障(像 FMDB 和 iOS UI 主线程,知道多线程数据安全的处理风险太大,索性就不支持多线程了),唯有算法这个环节,诡异且多变,最能体现价值。


今天是五四青年节日,我查了青年的年龄标准,是 14-28 周岁。突然很开心,我明年还能在过一次五四青年节。
历史前进的车轮肯定不会停下来,不管是文明,经济,抑或是网络,甚至自由。后浪必定比前浪更加优秀,这是毋庸置疑的,否则不符合历史规律。
每一波新一代,都拥有更棒的环境,更好的认知,更方便的学习方式。所以后浪们必定更加优秀和杰出,这是必然。
社会这个大团体,也一定会在后浪的推动下,一直向前,稳步向前。
但有一个重点我也想表达,随着历史长河的流逝,社会必然会进步。如果要进步的更快,那思想独立和思想解放必定占据非常大的比重,中国的新一代在这方面有很大短板。
我已经在职场 7 年了,虽然还是青年,但已经没有了青年的气质和气息。即使假装青年的疯癫,但眼角的复杂情绪却无法掩藏,也无法欺骗自己。
我显然不是青年了,很多时候我会无知于自己的未来,也对未来充满恐惧、失措、无助,而青年人不应该有这些拘束思想,他们应该是奔放的,激情四射的。
我想了一下,我脱离青年身份,应该是 3 年前,那年我 24 周岁,本命年。那年,我孩子出生。