23.多线程原理与atomic

一、 什么是线程

线程 也被称为 轻量级进程 ,是程序执行流程的最小单元。一个标准的线程由 线程ID当前指令指针PC寄存器集合堆栈 组成。

通常来说,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)以及一些进程资源(如打开文件和信号)。

大多软件中,线程都不止一个。多个线程可以互不相干扰地并发执行,并共享进程的全局变量和堆数据。那么多线程与单线程的进程相比,又有哪些优势呢?

1.1 使用多线程的原因

  • 某个操作可能会陷入长时间的等待,等待的线程会进入睡眠状态,无法继续执行。多线程执行可以有效利用等待时间。典型的例子是等待网络响应,这可能需要花费数秒甚至数十秒。

  • 某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互就会中断。多线程可以让一个线程负责交互,另一个负责计算。

  • 程序逻辑本身就要求兵法操作,例如一个多任务下载软件。

  • 多CPU或者多核计算机,本身具备同时执行多个线程的能力,因此单线程程序无法全面发挥计算机的全部计算性能。

  • 相对于多进程应用,多线程在数据共享方面的效率要高很多。

二、 线程的访问权限

线程的访问非常自由,它可以访问进程内村里的所有数据,甚至包括其他线程的堆栈(如果知道其他线程的堆栈地址的话,一般不会),线程野拥有自己的私有存储空间,包括以下方面:

  • 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。

  • 线程局部存储(TLS:Thread Local Storage)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只有非常有限的容量。

  • 寄存器(包括PC寄存器),寄存器是执行流的基本结构,因此位线程私有。

从 C 程序员的角度看,数据在线程之间是否私有如下:

  • 线程私有

    • 局部变量

    • 函数的参数

    • TLS 数据

  • 线程之间共享(进程所有)

    • 全局变量

    • 堆上的数据

    • 函数里的静态变量

    • 程序代码,任何线程都有权利读取并执行任何代码

    • 打开的文件,A线程打开的文件可以由B线程读写

三、 线程调度

不论是多核还是单核计算机,线程总是“并发”执行的。当线程数小于等于处理器核数时(并且操作系统支持多处理器),线程的并发是真正的并发,不同线程运行在不同处理器上,彼此互不干涉。对于线程数大于处理器数量的情况,线程的并发会受到一些阻碍,因为此时至少有一个处理器会运行多个线程。

在但处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间(通常是几十到几百毫秒),这样每个线程就“看起来”在同时执行。这种在一个处理器上不断切换的线程的行为称为 线程调度(Thread Schedule),有三个主要状态:

  • 运行

    • 此时线程正在运行

  • 就绪

    • 此时线程可以立即执行,但CPU已经被占用

  • 等待

    • 此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行

处于运行中的线程,拥有一段可以执行的时间,称为时间片

  • 当时间片用尽,该进程将进入就绪状态

  • 如果在时间片用尽之前进程就开始等待某事件,那么他将进入等待状态

  • 每当一个线程离开运行状态,调度系统就会选择一个其他的就绪线程继续执行。

  • 在一个处于等待状态的线程多等待的事件发生后,该线程就讲进入就绪状态

四、 优先级

现在主流的调度方式尽管不尽相同,但都有***优先级调度(Priority Schedule)轮转法(Round Robin)***的痕迹。

  • 优先级调度

    • 决定了线程按照什么顺序轮流执行

    • 具有高优先级的线程会更早地执行

    • 低优先级的线程常常要等到系统中已经没有高优先级的可执行线程存在时才能够执行。

  • 轮转法

    • 让每个线程轮流执行一小段时间

    • 这决定了线程之间交错执行的特点

线程的优先级布景可以手动设置,系统也会根据线程的表现自动调整优先级,以使得调度更加高效。通常情况下,频繁进入等待状态的线程(进入等待状态,会放弃之后仍然可用的时间份额,例如I/O处理的线程)比频繁进行大量计算,以至于每次都要吧时间片全部用尽的线程要更瘦欢迎。原因很简单,频繁等待的线程通常只占用很少的时间,CPU也喜欢先捏软柿子。

我们一般把频繁等待的线程称之为IO密集型线程,而把很少等待的线程称之为CPU密集型线程。IO密集型总是比CPU密集型容易得到优先级的提升。

4.1 总结优先级的改变方式

  • 用户指定优先级

  • 根据进入等待状态的频繁程度提升或者降低优先级

  • 长时间得不到执行而被提升优先级(防止饿死(Starvation)

五、 atomic的实质

atomic 是我们常用来保证属性线程安全的关键字。保证同一时间只有一个线程能够写入,多线程可读取

大家可能都知道它是一个自旋锁。那么你怎么知道它是一个自旋锁呢?我们来看下源码(基于:objc4-818.2)

5.1 源码解读

reallySetProperty是属性set方法的实现源码。其中我们可以看到很多熟悉的面孔: atomic、 copy

static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
    if (offset == 0) {
        object_setClass(self, newValue);
        return;
    }

    id oldValue;
    id *slot = (id*) ((char*)self + offset);

    // copy 关键字的处理
    if (copy) {
        newValue = [newValue copyWithZone:nil];
    } else if (mutableCopy) {
        newValue = [newValue mutableCopyWithZone:nil];
    } else {
        if (*slot == newValue) return;
        newValue = objc_retain(newValue);
    }

    // atomic 关键字的处理
    if (!atomic) {
        oldValue = *slot;
        *slot = newValue;
    } else {
        spinlock_t& slotlock = PropertyLocks[slot];
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue;        
        slotlock.unlock();
    }

    objc_release(oldValue);
}

我们看到 atomic 用了一个 spinlock_t。它是一个自旋锁么?

它实际上是 mutex_tt C++的自旋锁。

get 方法也做了类似的处理。

六、 一定线程安全么

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 线程1
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        for (int i = 0; i < 1000; i++) {
            self.number = self.number + 1;
            NSLog(@"number: %ld", self.number);
        }
    });
    
    // 线程2
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        for (int i = 0; i < 1000; i++) {
            self.number = self.number + 1;
            NSLog(@"number: %ld", self.number);
        }
    });
}

属性是 atomic 修饰的,按理说应该是线程安全的,两个线程各对 number 做了 1000 次循环 +1,最终 number 的值应该是2000,但输出的值却不是期望的 2000。

// 最后的几行输出
2020-03-07 22:37:21.713683+0800 TestObjC[23813:2171198] number: 1986
2020-03-07 22:37:21.714004+0800 TestObjC[23813:2171198] number: 1987
2020-03-07 22:37:21.714267+0800 TestObjC[23813:2171198] number: 1988
2020-03-07 22:37:21.714541+0800 TestObjC[23813:2171198] number: 1989
2020-03-07 22:37:21.714844+0800 TestObjC[23813:2171198] number: 1990
2020-03-07 22:37:21.715027+0800 TestObjC[23813:2171198] number: 1991
2020-03-07 22:37:21.715442+0800 TestObjC[23813:2171198] number: 1992

以上是输出的最后几行,最终的值只加到1992。

  • 这是因为两个线程在并发的调用setter和getter,在setter和getter内部是加了锁

  • 但是在做+1操作的时候并没有加锁,导致在某一时刻,线程一调用了getter取到值

  • 线程2恰好紧跟着调用了getter,取到相同的值

  • 然后两个线程对取到的值分别+1,再分别调用setter,使得两次setter其实赋值了相等的值

因此使用atomic修饰属性时对属性的操作是否是线程安全的,虽然在内部加了锁,但并不能保证绝对的线程安全。

思考

我们知道 atomic 实际上是对属性set/get 方法做了处理,那么如果通过 KVC 直接进行成员变量的读写线程安全么?

Last updated