线程基础与 iOS 中的多线程 (一)

多线程

线程基础


线程,有时也被称为轻量级进程,是程序执行的最小单元,通常来讲,一个进程由多个线程组成,各线程共享程序的内存空间(代码段,数据段、堆、进程级的资源包括打开文件与信号)。

尽管线程的访问非常自由,但实际中线程也拥有自己的私有存储空间,私有存储空间主要有以下几方面:

  1. 栈:一般情况下认为栈是线程的私有数据。
  2. 寄存器
  3. 线程局部存储

从编码的角度来看,线程私有的数据包括:

  1. 局部变量
  2. 函数参数
  3. TLS 数据(线程局部存储)

线程之间共享的数据包括:

  1. 全局变量
  2. 堆上的数据
  3. 函数里的静态变量
  4. 打开的文件
  5. 程序代码

线程并发、调度、状态


包含概念:线程调度 并发 时间片 运行 就绪 等待

一般来讲,线程总是并发执行的,但也有真正的并发状态(真的同时执行多个线程,不同的线程运行在不同的处理器上)与模拟并发状态。当线程数量小于等于处理器数量时,线程就会真正的并发执行,彼此之间互不干扰。

但当线程数量大于处理器数量的时候,并发就会受到一些阻碍,因为此时至少一个处理器会运行多个线程。在一个处理器运行多个线程的情况下,并发就是模拟出来的状态。操作系统会让这些线程轮流执行一小段时间(通常是几十到几百毫秒),这样看起来就像“同时执行”。像这样在同一个处理器上切换不同的线程的行为称为线程调度

调度中的线程,就会至少拥有三种状态:

  • 运行:线程正在执行
  • 就绪:线程可以立刻执行,但 CPU 不空闲
  • 等待:线程正在等待某一事件 (通常是 I/O 或者同步) 发生,无法执行。

处于运行状态的线程可以执行的时间,被称为时间片。时间片用完后,线程进入就绪状态。如果在时间片用完之前线程就开始等待某事件,那么线程就进入等待状态。

线程调度的优先级


多任务操作系统对线程的调度方式各不相同,但都带有优先级调度轮转法的痕迹。轮转法就是让各个线程轮流执行一小段时间的方法,这决定了线程之间交错执行的时间点。而优先级调度则决定了线程按照什么顺序轮转执行。所以各个线程都有自己的线程优先级

IO 密集型和 CPU 密集型线程
频繁的 IO 处理线程通常会主动放弃仍然可占用的时间份额,来进入等待状态,这种线程一般计算比较少,IO 等待比较多,所以称为 IO 密集型线程。另外一种线程则很少等待,而是需要 CPU 进行大量计算,通常一段时间片运行完都不能计算完成,还需要更多的时间片来进行计算,这样的线程称为CPU密集型线程。一般情况 IO密集型线程比 CPU 密集型线程更容易得到优先级的提升,原因是 CPU 也喜欢挑软柿子捏。

线程饿死
如果一个 CPU密集型的线程被用户指定为高优先级线程,那么其他低优先级线程可能存在“饿死”现象,即 CPU 密集型线程不停占用时间片进行大量计算,从而使低优先级的线程长时间得不到时间片,也就长时间的得不到执行。所以调度系统通常会逐步提升这些长时间得不到执行的线程的优先级。

总结下来,线程的优先级改变一般有三种方式:

  • 用户指定优先级
  • 长时间得不到执行而被提升
  • 根据进入等待状态的频繁程度提升或者降级优先级

抢占式和不可抢占式线程


当线程用尽时间片之后,会被强制剥夺继续执行的权利,而进入就绪状态,这个过程被称为抢占,即之后 CPU 开始执行别的线程;现在的调度系统大多都是可抢占式的线程调度,因此也存在因调度时机不确定而产生的线程安全问题。

不可抢占式线程中,除非线程主动放弃执行,否则线程会连续的占用时间片来执行。这种模式下如果线程拒绝主动进入就绪状态,并且没有任何等待操作,那么其他线程将永远无法执行,这种模式下线程主动放弃执行无非两种情况:

  • 线程试图等待某事件(IO等)
  • 线程主动放弃时间片

不可抢占式调度方式好处是,不容易发生线程安全问题,即便如此,这种调度方式在今天也十分少见。

线程安全


在一个可抢占式的多线程环境中,可访问的全局变量和堆数据随时都有可能被其他线程改变。因此多线程程序并发执行时,数据的一致性就变得很重要了,也就是所谓的线程安全问题。

例如同时有两个线程对一个全局变量 i 进行一次自增操作 (++),其结果通常来讲是不可预测的,结果有可能是 0、1、或者2。当然我们想要的结果是 2。发生这种情况的原因就是自增++操作是非原子性的

原子的 atomic

一般来讲原子操作就是不可分割的操作,实际中来讲,真正的原子操作就是一条 CPU 指令,或者一条汇编代码(一般一条汇编语句对应一条 CPU 指令),因为无论如何单条指令的执行是不会被打断的。而自增++操作被编译为汇编代码后不止一条指令,而是被分割为 3 条指令:

a. 读取 i 到某个寄存器 X 上
b. X ++
c. 将 X 的内容存储会 i

而我们知道每个线程都有自己的寄存器,那么线程 1 和 线程 2 同时并发的执行 i++,在 CPU 划分时间片执行的时候,上述的三步骤就有可能被调度系统打断,从而穿插执行,例如线程 1 执行 a 步骤以后,时间片被线程 2 抢占,导致线程 1 和 线程 2 读到的 i 的值都是 0。

所以很多体系结构中,为了避免少出错,都会提供原子操作指令,例如 iOS 中的 atomic 来保证 getter 和 setter 的完成性,但这只是保证了我们能够拿到一个完整的对象,或者完整的写入一个对象。宏观来讲也不是线性安全的,例如 A 线程调用 setter 后,B 线程又调用了 setter,而 B 的 setter 写入并不是我们预期的,此时 A 调用 getter 得到的就是非预期结果了。下面有个很好的例子来理解即使使用了 atomic,依然要注意线程安全问题:

@property (atomic, assign) int atomicInt; // 一个 atomic 属性

// 测试代码

dispatch_group_t gcdGroup = dispatch_group_create();
for (int i = 0; i < 2000; i++) { // 多线程累加 2000 次
    dispatch_group_async(gcdGroup, dispatch_get_global_queue(0, 0), ^{
        self.atomicInt++; // 注意 ++ 操作
    });
}
dispatch_group_notify(gcdGroup, dispatch_get_main_queue(), ^{
    NSLog(@"current atomicInt: %@", @(self.atomicInt));  // 结果不确定,可能是 1990 1987 1550 ....
});

这段代码的预期是想让 atomicInt 累加 2000 次,最终期望结果是 2000,然而并不是。关键代码其实是 self.atomic++,代码其实会被转化为 self.atomicInt = self.atomicInt + 1; 在进一步细分:

1: 先获得当前 self.atomicInt ,调用 atomicInt 的 getter 方法
2: 然后 +1 后赋值,又调用 atomicInt 的 setter 方法

那么问题很明显了,单独来说 getter 或者 setter 都是原子性的,但是多线程在 getter 和 setter 穿插执行的情况下,就会出现乱序问题,某个线程在执行 getter 后,还没来及做赋值操作,调度系统就把时间片给其他线程来 setter,导致结果紊乱。所以这样的情况,我们依然要对 self.atomicInt++ 进行加锁来保证最后的结果达到预期:

pthread_mutex_init(&_localLock, NULL);
dispatch_group_t gcdGroup = dispatch_group_create();
for (int i = 0; i < 2000; i++) {
    dispatch_group_async(gcdGroup, dispatch_get_global_queue(0, 0), ^{
        pthread_mutex_lock(&_localLock); // 加锁
        self.atomicInt++;
        pthread_mutex_unlock(&_localLock);
    });
}

dispatch_group_notify(gcdGroup, dispatch_get_main_queue(), ^{
    pthread_mutex_destroy(&_localLock); // 销毁锁
    NSLog(@"current atomicInt: %@", @(self.atomicInt));
});

同步与锁


多线程中有很多有关同步的概念,一般地,所谓同步,即指一个线程访问数据未结束的时候,其他线程不得对同一个数据进行数据访问,这样,对数据的访问就被原子化了。实际开发中,我们也会用同步和异步来描述一段操作或者任务,这里谈同步的时候,表示执行的任务会阻塞当前线程,直到任务执行完成,线程才会返回;异步则表示线程会立即返回,不会阻塞线程。这里我们暂且讨论前一种同步概念,即数据同步。

同步的最常见方法就是锁,每一个线程在访问数据首先试图获取锁,并在访问结束后释放锁。而锁的种类大致可以分为如下几种:

  • 信号量,二元信号量和多元信号量
  • 互斥量
  • 临界区
  • 读写锁
  • 条件变量

1. 信号量

二元信号量是最简单的锁,它只有两种状态:占用和非占用。适合只能被唯一一个线程独占访问的资源。

相应的,对于允许多个线程并发访问的资源,多元信号量(简称信号量),是一个很好的选择。一个初始值为 N 的信号量允许 N 个线程并发访问,线程访问资源的时候首先获取信号量,并将信号量的值减 1,如果信号量的值小于 0,则线程进入等待状态,否则继续执行。访问完资源后,线程释放信号量,将信号量值加 1。信号量值如果大于 1 ,其他线程就有机会获得一个信号量,并执行。所以信号量可以有效的控制多线程的并发数量。而只有0和1两种取值的信号量就是我们上边说的二元信号量。

iOS 中,我们熟悉的 dispatch_semaphore 可以就是信号量的实现函数,使用很方便;dispatch_semaphore 有三个常用函数:

  • dispatch_semaphore_create(value) 生成信号量,一般 value >= 0;
  • dispatch_semaphore_wait 让信号量的值减一,相当于获得信号量
  • dispatch_semaphore_signal 让信号量加 1 ,相当于释放信号量

dispatch_semaphore 可以用来实现资源的同步加锁:

dispatch_semaphore_t waitSemaphore = dispatch_semaphore_create(1); // 设置为1,则相当于二元信号量
dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER); // DISPATCH_TIME_FOREVER ,永久等待

// ... 修改数据代码

dispatch_semaphore_signal(waitSemaphore); //释放信号量

dispatch_semaphore 可以用来把异步变为同步,从而进一步实现链式调用(代码省略):

- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script
{
    __block NSString *res = nil;

    dispatch_semaphore_t waitSemaphore = dispatch_semaphore_create(0);
    [self evaluateJavaScript:script completionHandler:^(NSString *result, NSError *error){ // 默认是异步执行的
        res = result;
        // some code ...
        dispatch_semaphore_signal(waitSemaphore); 
    }];

    while (dispatch_semaphore_wait(waitSemaphore, DISPATCH_TIME_NOW)) { // 等到 dispatch_semaphore_signal 释放一个信号量,才能执行
        [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
    }
    return res;
}

2. 互斥量

和二元信号量很像,但是比二元信号量更为严格,要求哪个线程获取,就由哪个线程释放;而二元信号量则可以有一个线程获取信号量,而由另一个线程释放该信号量。

iOS 中的 NSLock、NSRecursiveLock 都实现了基本的互斥锁,另外还有 pthread 里的 pthread_mutex 锁,使用起来都很方便。NSRecursiveLock 也被称为递归锁,在某个方法可能发生递归调用的时候,防止死锁的产生。另外就是我们常见的 @synchronized 实现的锁。网上有人测算过这几种锁的性能从高到低依次是:dispatch_semaphore > pthread_mutex > NSLock > NSRecursiveLock > synchronized。

iOS 中的自旋锁
自己并没有研究过自旋锁,看了些资料,基本上可以理解为自旋锁也是一种互斥锁,iOS 里的使用该所是通过 OSSpinLock 来实现的。自旋锁和普通互斥锁 ( NSLock) 不同的地方在于,互斥锁在没有获取到锁的时候,会进入就绪状态,等待操作系统的调度,而自旋锁会一直循环在那里,等着锁被释放。

临界区
是比互斥量更为严格的同步手段。我们把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程都是可见的,也就是说一个进程创建了信号量和互斥量,另一个线程去获取该锁是合法的。而临界区则只适用于本线程,其他线程根本无法获取本线程的锁。

第二篇:线程基础与 iOS 中的多线程 (二)

评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇