iOS老司机的多线程PThread学习分享

语言: CN / TW / HK

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第4天,点击查看活动详情

前言

  • iOS中关于多线程相关的文章不胜枚举.
  • 诚然, 掌握了iOS中的GCD相关API, 不论开发语言是Objective-C还是Swift, 都能够处理绝大多数业务开发中的多线程场景.
  • 但是, 作为一个有追求的iOS开发者, 除了掌握常见API的使用, 我们还应探索多线程的底层, 避免只见树木不见森林.
  • 机缘巧合, 有幸学习了Casa的PThread课程, 针对GCD的底层PThread, Casa在这两个多小时的课程中有着深入浅出的讲解. 帮助广大开发者既见树木又见森林. Casa博客PThread介绍
  • 如果你对iOS中多线程的底层技术PThread也有所好奇, 不妨和我一起跟着Casa的思路看下去:)
  • ps: 文章纯手打, 如有错误, 请评论区指正, 在此谢过了~

章节1 线程基础概念和操作

1.1. POSIX和多线程

  • POSIX Thread

  • 各种各样的操作系统, 他们接口不统一, 所以需要针对每一种操作系统写不同的代码

  • 很麻烦, 怎么办?

  • Portable Operating System Interface 可移植操作系统接口

  • POSIX标准中的Thread章节(2.9 Threads)

  • 定义的函数都是pthread开头的

  • 大部分主流系统都不是严格遵守POSIX

  • POSIX具备指导意义, 实际情况还是要看操作系统对应文档

1.2. 线程的创建

  • 如何创建一个线程?

  • 猜一下 线程 = 创建线程(要做的事情, 事情的参数, 线程的配置, 存储线程的结构体)

  • 结果? pthread_create(线程记录, 线程属性, 事情, 参数) int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void*(*start_routine)(void *), void *arg)

1.3. 线程的属性和Join/Detach

  • stack

  • sechedule

    • 走系统默认
    • 交给系统
  • 杂项

    • detach state 
      • joinalbe(默认)/detached 
      • 有人等/无所谓 
    • scope
      • 线程竞争时, 参与竞争的范围
      • 即优先级有范围
      • process(默认)/system
  • Detach vc Join

    • 一个detach属性的线程
      • 意味着你拿不到这个线程的返回值(如果有的话)
      • 而且, 在这个线程结束之后, 相关资源就会被立刻回收.
    • 一个join属性的线程
      • 在线程结束之后资源不会被立刻释放, 而是等待别的线程来join
      • 当把自己的返回值交给来join的线程之后, 自己就会被释放
      • 如果一直没有线程来join, 那这个线程就会一直存在, 直到进程结束.
  • Detach

    • -> 创建线程 -> 继续执行
    • Detach属性的子线程 ->
    • 任务结束系统立刻回收
  • Join(接应)

    • int pthread_join(pthread_t thread, void **retval)
    • 存储子线程任务结果
    • -> 创建线程 -> 调用pthread_join并等待指定线程 等待中 pthread_join的等待结束 -> 继续执行
    • join属性的子线程 -> 任务结束 ^ 传递任务结果
  • 很多个线程一起Join一个子线程?

    • 子线程只能被join 1次!
    • 先到先得!
    • 创富join一个线程无任何作用

1.4. 线程的结束

  • join

    • pthread_join(pthread_t thread, void **value_ptr)
    • 父线程监听子线程的结束, 并通过pthread_join函数获得子线程的返回值
  • exit

    • pthread_exit(void **value_ptr)
    • 子线程主动结束, 通过pthread_exit传递值给父线程的pthread_join去接收
  • kill

    • pthread_kill(pthread_t thread, int sig)
    • 向 [子线程/自己] 发送指定的信号, 如果子线程没有响应该信号的代码,
    • 则交由进程响应. 例如发送SIGQUIT信号, 子线程不响应的话,
    • 进程就会响应该信号, 结束进程
  • return

    • 本质上跟pthread_exit一样, 但是不会调用cleanup函数
    • cleanup函数, 清理函数
      • pthread_cleanup_push
      • pthread_cleanup_pop
      • 要一一对应, 否则编译不过

1.5. 取消一个线程

  • pthread_cancel取消一个线程

  • int pthread_cancel(pthread_t threas)

  • 调用pthread_cancel可以让对应线程运行到取消点时取消线程

  • 线程取消点?

    • POSIX标准, 
    • 表里的函数都是线程取消点
    • 不一定准!不是所有的标准都去遵守, 看操作系统
  • 有些C库没有符合POSIX标准, 所以在调用这些函数之前,可以自建线程取消点

  • void pthread_testcancel(void), 告诉CPU检查状态, 是否被取消

1.6. POSIX里其他有用的函数

  • pthread_once

    • pthread_once(pthread_once_t *once_control, void (routine)(void))
    • 使用PTHREAD_ONCE_INIT去初始化once_control
  • pthread_self & phtread_equal

      • pthread_t pthread_self() 
      • 告诉你自己是谁
      • int pthread_equal(pthread_t thread1, pthread_t thread2)
      • 告诉你跟你一样的人是谁

章节2 锁和各种情况

2.7. Mutex锁

  • 可以衍生出递归锁共享锁

  • 锁是干什么用的?

    • 一条线程给count加1
    • 期望的count结果是2
      1. 从内存读取变量的值
      1. 在读到的值上+1
      1. 将结果写回内存
  • 两条线程给count加1, 期望的count结果是3

    • 多条线程共享同一块内存
      1. 从内存读取变量的值 count 1 count 1
      1. 在读到的值上+1 count 2 count 2
      1. 将结果写回内存 count 2 count 2
  • 怎么办?

    • 临界区的概念
    • 不希望一个线程在执行任务的时候, 其他线程掺和进来
    • 这段任务叫临界区, 一个任务只能有一个线程执行
  • 问题原因?

    • 临界区被多个线程同时操作了
  • 通过加锁使得临界区只有一个线程在执行

  • 加锁的情况

    • 申请锁
    • 从内存读取变量的值
    • 在读到的值上+1
    • 将结果写回内存
    • 释放锁
    • 此时绿色线程可以成功读取内存中变量的值
  • Mutex Lock 最常用的锁, 相关的5个函数

    • int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr)
    • int pthread_mutex_destroy(pthread_mutex_t *mutex)
    • int pthread_mutex_lock(pthread_mutex_t *mutex)
    • int pthread_mutex_unlock(pthread_mutex_t *mutex)
    • int pthread_mutex_trylock(pthread_mutex_t *mutex) 不必让线程一直处于等待状态

2.8. Mutex锁的各种属性

  • Mutex锁的属性

    • priority ceiling
      • 数字 防止优先级反转 当前线程优先级
    • mutex protocol
      • INHERIT/ NONE(默认) / PROTECTED 防止优先级反转 
    • process shared
      • PRIVATE(默认)
      • SHARED
      • 是否能够跟其它线程共享锁, 哪怕是跨进程
    • mutex type 
      • NORMAL 不记录持有人
      • DEFAULT(默认) 保留名
      • ERRORCHECK 不能重复加锁解锁
      • RECURSIVE 允许同一线程加N次锁, 但也要解锁N次才会释放
    • robust
      • STALLED(默认) / ROBUST
      • 如果没解锁线程就跪了: 啥也不做 / 让下一个线程处理

2.9. 优先级反转及解决方案

  • 优先级反转?

    • 低优先级的线程反而优先于高优先级的线程执行
  • 正常的情况

    • 低优先级线程 R -> 释放资源
    • 高优先级线程 -> R被占用 通知低优先级的线程我要抢占你了
  • 优先级反转的场景

    • R -> 释放资源 被中优先级的线程抢占了, 系统让中优先级的线程先执行
    • -> R          中优先级的任务结束, 低优先级的才能继续执行
    • 中优先级的线程打断了低优先级线程的释放流程
    • 使得中优先级线程反而先于高优先级的线程执行
  • 怎么办?

    • 不应该让中优先级的线程去打断低优先级线程的资源回收流程
    • 释放资源的时候(关键临界区)不允许被中断
    • 无锁同步方案(Non-blocking Synchronization / Read-Copy-Update)
    • Priority Ceiling Protocol
      • 是Mutex锁的一个属性
      • 配置了该属性之后, 其实就是配置了优先级
      • 低优先级 1 中优先级 5 高优先级 8
      • 释放资源(ceiling更高的才能抢占)
      • 中优先级的线程无法抢占低优先级的线程了
      • 因为priority没有ceiling高
    • Priority Inheritance
      • 中优先级的线程无法抢占低优先级线程了
      • 因为priority没有红色线程高
      • 低优先级inherit(继承)自高优先级, 中优先级的不会比高优先级线程高

2.10. 跨进程共享锁

  • process shared

  • 让锁可以被跨进程共享

  • 看看多进程, fork函数创建进程 ```

fork()

PID = 0  PID > 0 ```

  • 进程共享锁

    • 通过进程的共享内存, 进行锁的共享 ```

int shmid = shmget(1616, sizeof(TShared), IPC_CREAT|0666);

TShared shm = (Tshared )shmat(shmid, NULL,0);

pthread_mutex_init(&shm->Lock, 带上pshared属性); ```

  • 进程共享锁和共享锁是什么区别?

    • 绝大多数场景是用在读写锁的读锁里面
    • 很少用
    • 调度的单位往往是线程
    • 涉及到进程共享, 容易出错, 尽量少用

2.11. 递归锁

  • mutex type 

    • NORMAL 不记录持有人, 重复加锁, 无休止等待, 死锁
    • DEFAULT 保留名
    • ERRORCHECK 不能重复加锁解锁, 会报错
    • RECURSIVE 允许同一线程加N次锁, 但也要解锁N次才会释放, 会报成功 ```

void foo() {

... 申请锁();

...foo();

...释放锁();

} ```

  • 不会被自己锁住, 即使递归调用也能正常申请到锁

2.12. 锁的Robust机制

  • robust 使健壮

    • STALLED(默认) / ROBUST
    • 如果没解锁线程就跪了;
    • 啥也不做 / 让下个线程处理
  • 当持有一个锁的线程还没释放就挂了, 会发生什么?

    • STALLED 
      • 无法申请到锁, 未定义的行为. 
      • 后面申请这个锁的线程可能会一直wait
    • ROBUST
      • 下一个申请这个锁的线程会收到一个 EOWNERDEAD 错误
      • 第三个第四个申请锁的线程会处于waiting状态
      • 这个线程可以尝试恢复上一个线程挂掉之后对锁或对程序执行逻辑的影响
      • 如果恢复成功, 就可以调用pthread_mutex_consistent()函数来标记这个锁已经恢复正常
      • 然后这个锁就相当于被这个线程给持有了
      • 当这个线程释放锁了之后, 其他后面的线程就可以正常使用锁了
      • 如果恢复失败, 这个锁就会永远处于不可用的状态, 只能通过pthread_mutex_destroy()来回收这个锁
    • Robust工作原理
      • 执行一些恢复逻辑
      • pthread_mutex_consistent() -> 释放锁

2.13. 在不同场景下一个线程重复加解锁

  • 重复加解锁会怎么样?

    • mutex type
    • robust
    • 同一个线程重复加解锁
  • type       robust   重复加锁(自己给自己加锁) 重复解锁(自己给自己解锁)

  • NORMAL     STALLED  死锁                      未定义行为

  • NORAML     ROBUST   死锁                      报错

  • ERRORCHECK 任意值   返回错误                  报错

  • RECURSIVE  任意值   重复加锁                  报错

  • DEFAULT    STALLED  未定义行为                未定义行为

  • DEFAULT    ROBUST   未定义行为                报错

2.14. 死锁

  • 互相锁死就是死锁

  • 单线程可能死锁吗? 可能, 自己重复加锁

  • 主要是多线程下的死锁

  • 怎么办?

    • 按照顺序去加锁
    • 第一个线程完成自己的任务后, 第二个线程才能申请到资源

2.15. 读写锁

  • 解决读多写少的问题

  • 期望避免在临界区执行的时候, 其他线程进入到临界区产生干扰

  • 如果别的线程进来是为了读取数据, 进入临界区, 不做坏事

  • 申请写锁, 

  • 申请读锁, 

  • 根据线程的行为来申请读写锁, 提高多线程的性能

  • 读写锁

    • 专治读得太多, 写得太少
    • 创建和销毁 pthread_rwlock_init & phtread_rwlock_destroy
    • 读锁 phtread_rwlock_rdlock & pthread_rwlock_tryrdlock
    • 写锁 pthread_rwlock_wrlock & pthread_rwlock_trywrlock
    • 解锁 pthread_rwlock_unlock
  • 读写锁的2个属性

    • PShared 是否进程间共享
    • Kind (防止写饥饿)
      • Prefer Reader 读优先, 默认值
        • 写饥饿, 当读优先的时候, 写线程必须要等待前面的读线程都执行结束了, 才能得到执行
        • 读锁是共享出去的, 多个线程使用
      • Prefer Writer Non-Recursive 写优先
        • 只要申请写锁时, 后面申请读锁就不让进来了 
      • Prefer Writer 同上, glibc中只提供PWNR, 添加这个只是为了跟POSIX对齐

章节3 多线程下的各种机制

3.16. Thread Specific Data - TSD

  • TSD是干什么的?

    • 内存中的数据, 在线程间是被共享的, 
    • 如果想有一个数据, 只能被本线程内所有函数访问
    • 不能被别的线程访问, 应该怎么办?
    • 定义的数据是在栈上开辟的, 只能自己访问.
    • 定义的数据是在堆上开辟的, 所有线程都能访问, 怎么办? TSD!
  • Thread-specific Data API列表

    • 创建 int pthread_key_create(pthread_key_t *key, void (*destructor)(void *))
    • 删除 int pthread_key_delete(pthread_key_t key)
    • 写入 int pthread_setspecific(pthread_key_t key, const void *value)
    • 读取 void * pthread_getspecific(pthread_key_t key)
  • 不同的线程拿到同一个key, 拿到的也是自己维护的数据, 不同线程可以共用key

  • TSD还是比较慢的, 尽量少用!

3.17. Condition Variables 条件变量

  • 一种线程同步的方式, 线程进入临界区前, 等待信号

  • 例子

      1. 你自己看一下, 只要房间没人, 你就可以进
      • 进卫生间, 加锁, 
      1. 不管房间是不是有人, 别人叫你进了, 你才能进.
      • 进办公室, 也可以用加锁的方式实现, 但有一个问题
      • 如果办公室从一开始就没人, 办公室里没有任何工作人员
      • 加锁的方式解决这个问题, 关键的点是, 之前办公室里必须要有人
      • 假设加锁的线程, 晚于临界区, 无法进入等待状态
      • => Condition Variables! 条件信号, 有可能是迟来的
      • 条件变量的定义, 收到信号, 办公室来人了发信号
      • 锁是先获得锁的线程可以让别的线程等待
      • 条件变量, 先等待信号, 再进入临界区
      • 进入临界区的先后顺序不同.
  • Condition Variables

    • 设置条件让别的线程等待, 通过条件变量让别的线程继续运行
    • int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr)
    • int pthread_cond_destroy(pthread_cond_t *cond)
    • int pthread_cond_signal(pthread_cond_t *cond) 只会有一个人进入临界区
    • int pthread_cond_broadcast(pthread_cond_t *cond)
      • 广播信号, 所有等待的人都会收到信号
      • 收到信号都会进入临界区
    • int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex)
  • Condition Variable的2个属性

    • process shared 
      • shared / private(默认)
      • 条件变量是否可以跨进程?
      • 需要跟mutex的process share属性相配合
    • clock
      • int值; 各种宏, 但只能用: CLOCK_REALTIME(默认)/CLOCK_MONOTONIC(不受系统时间影响)
      • 控制超时时钟, 用于条件变量的timewait函数
    • 一般都是走默认

3.18. 使用条件变量要注意的点

  • 一定要跟mutext一起用

  • 不跟mutext一起用的情况

    • Condition Variable的空等现象 1 ```

void thread_conciton_1() {

done = 1;

pthread_cond_signal(&condition_variable_signal)l

}

void thread_function_w() {

while (done == 0) {

// 可能会引入bug的地方, fuc1立刻跑完了, 信号已经扔出去过了

pthread_cond_wait(&condition_variable_signal, NULL);

}

} ``` - Condition Variable的空等现象 2

    • 没有先进行条件检测, 再等待条件 (注意while和if的选择)
    • fuc1的信号可能已经发出过了
    • 一定要在临界区扔出条件变量
      • Condition Variable的数据保护
      • 要在临界区释放mutex锁
      • 流程图
        • 其他线程乘虚而入的机会
        • 临界区 -> 扔信号 -> 唤醒其他线程
        • 临界区里面扔信号 唤醒其他线程
  • 条件变量Condition Variable三大注意

      1. 一定要结合Mutex使用, 空等
      1. 一定要先进行条件检测, 空等
      1. 一定要在临界区扔信号, bug
  • 在Mutex里面去做信号的监听

    • 监听信号时, 信号没有到, 此时线程会被挂起
    • 前面申请的Mutex就会被释放出来, 其他的条件变量线程就会能够获得这个锁

3.19. Semaphore信号量

  • 条件变量不能控制数量, 信号量可以控制数量

  • 如果一个景区同时只能固定数量的人参观, 应该怎么办?

    • 锁也可以做, 5个人5把锁, 但是这样做很麻烦
    • 条件变量, count计数, 判断, 此时count是49同时有10个人查看, 进入59人
    • 针对count要有个锁, 实现麻烦
    • => Semaphore! 
    • 一台电脑只联接了3个打印机
      • Semaphore去实现会更优雅
      • 操作系统会提供相应实现
  • Semaphore相关API

    • 不同操作系统
      • int sem_init(sem_t *sem, int pshared, unsigned int value)
      • int sem_open(const char *name, int oflag, ...)
    • int sem_post(sem_t *sem) // value 加1
    • int sem_wait(sem_t *sem) // value 减1
    • int sem_destroy(sem_t *sem) // 释放信号量
    • int sem_getvalue(sem_t *sem, int *sval) // 获得信号量当前的值
  • Semaphore使用: 值 > 0、post +1、wait -1

  • 生产者消费者代码

    • 生产者每生产完一个产品post +1
    • 消费者每消费一个产品wait -1
    • 用完destroy
    • 创建销毁+1-1

3.20. Barrier多线程栅栏函数

  • 等别的线程都到某个点后, 我再继续

    • 建一个房子, 三件事前准备, 找三个帮手同时办三件事情
    • 三个事情都完成, 才能开始后面的事情
    • 先完成手上的事情后, 才能进行下一步
    • => Barrier!
  • Barrier相关API

    • 设立一个栅栏, 让相关的线程走到预定位置就wait, 所有人都走了, 再继续.
    • int pthread_barrier_init(pthread_barrier_t *barrier, const pthread_barrierattr_t *attr, unsigned int count)
    • int pthread_barrier_destroy(pthread_barrier_t *barrier)
    • `int pthread_barrier_wait(pthread_barrier_t *barrier) 只要凑够一开始创建的线程的数量, 就能达到目的
  • Barrier只有1个属性

    • Process-shared
    • 可选项 private(默认) / shared
    • 作用 是否可以进行跨进程共享
  • Barrier到底是个啥?

    • 解决了什么问题?
    • 三个线程不知道什么时候开始, 什么时候结束
    • 需要在某个时刻, 三个线程需要同时触发
    • 第一个线程完成任务 -> 栅栏点, 等待
    • 第二个线程完成任务 -> 栅栏点, 等待
    • 第三个线程完成任务 -> 栅栏点, 等待
    • 所有的线程都到达了栅栏点 => 所有的线程可以继续了
  • 天选之子机制

    • wait函数返回的值是一个宏定义
    • 其他的线程返回的是0
    • 所有wait的线程唤醒之后
    • 只有一个线程收到的返回值是PTHREAD_BARRIER_SERIAL_THREAD
    • 其它线程收到的返回值是0
    • int result = phterad_barrier_wait(&mybarrier);
  • 天选之子可以干什么用?

    • 多线程归并排序, 
    • 需要一个唯一的线程做结果的归并

课程学习导图

发文不易, 喜欢点赞的人更有好运气👍 :), 定期更新+关注不迷路~

ps:欢迎加入笔者18年建立的研究iOS审核及前沿技术的三千人扣群:662339934,坑位有限,备注“掘金网友”可被群管通过~