多线程及编程的感性理解

既然上了RTT,就要考虑多线程的编程问题。其实之前做过两个任务的程序,就两个线程,一个是显示,一个是业务逻辑处理。再次在前年做上位机的探照灯串口通信软件时,也用到了多线程编程的思想,不过是用管道来实现。感性的认知就是按设备划分的程序,然后,根据内核的特点统一管理。具体还有哪些门道呢?下面仅是对自己还不了解的,作些许记录,以后还要结合实际项目总结。

需要了解的问题:

1、线程由哪些部分组成?

线程是程序执行流的最小单元,一个线程包括:独有ID,程序计数器 (Program Counter),寄存器集合,堆栈。同一进程可以有多个线程,它们共享进程的全局变量和堆数据。线程和进程都是虚拟的概念,只有寄存器是真实存在。

系统的复杂性,开发者往往不能期望所有线程都能真正的并发执行,而且开发者也不清楚 XNU 何时切换内核态线程、何时进行线程调度,所以开发者要经常考虑到线程调度的情况。

1、减少队列切换,控制线程数量

当线程数量超过 CPU 核心数量,CPU 核心通过线程调度切换用户态线程,意味着有上下文的转换(寄存器数据、栈等),过多的上下文切换会带来资源开销。虽然内核态线程的切换理论上不会是性能负担,开发中还是应该尽量减少线程的切换。

队列切换,可以认为是同一个流程的操作。没必要开多个线程来处理。

2、线程优先级权衡

通常来说,线程调度除了轮转法以外,还有优先级调度的方案,在线程调度时,高优先级的线程会更早的执行。有两个概念需要明确:

  • IO 密集型线程:频繁等待的线程,等待的时候会让出时间片。
  • CPU 密集型线程:很少等待的线程,意味着长时间占用着 CPU。

特殊场景下,当多个 CPU 密集型线程霸占了所有 CPU 资源,而它们的优先级都比较高,而此时优先级较低的 IO 密集型线程将持续等待,产生线程饿死的现象。当然,为了避免线程饿死,系统会逐步提高被“冷落”线程的优先级,IO 密集型线程通常情况下比 CPU 密集型线程更容易获取到优先级提升。

虽然系统会自动做这些事情,但是这总归会造成时间等待,可能会影响用户体验。所以笔者认为开发者需要从两个方面权衡优先级问题:

  • 让 IO 密集型线程优先级高于 CPU 密集型线程。
  • 让紧急的任务拥有更高的优先级。

这两种划分方法,提供了一个思考的方向。值得借鉴。

3、主线程任务的优化

有些业务只能写在主线程,比如 UI 类组件的初始化及其布局。这里的主线程,可以理解为需要大量占用CPU的线程。

基于主线程任务的管理大致分为几点:

内存复用

通过内存复用来减少开辟内存的时间消耗,这在系统 UI 类组件中应用广泛。

懒加载任务

既然 UI 组件必须在主线程初始化,那么就需要用时再初始化吧

任务拆分排队执行

通过监听 Runloop 即将结束等通知,将大量的任务拆分开来,在每次 Runloop 循环周期执行少量任务。其实在实践这种优化思路之前,应该想想能不能将任务放到异步线程,而不是用这种比较极端的优化手段。

有待实践吧,仅供参考,当前还不能完全明白为什么要这样做。

 

系统安全问题处理:

当原子操作不能满足业务时,往往需要使用各种“锁”来保证内存的读写安全。常用的锁有互斥锁、读写锁、空转锁等。

1、在读取锁失败时,线程有可能有两种状态:

  • 空转状态:线程执行空任务循环等待,当锁可用时立即获取锁。
  • 挂起状态:线程挂起,当锁可用时需要其他线程唤醒。

唤醒线程比较耗时,线程空转需要消耗 CPU 资源并且时间越长消耗越多,由此可知空转适合少量任务、挂起适合大量任务。

实际上互斥锁和读写锁都有空转锁的特性,它们在获取锁失败时会先空转一段时间,然后才会挂起,而空转锁也不会永远的空转,在特定的空转时间过后仍然会挂起,所以通常情况下不用刻意去使用空转锁

2、优先级反转问题

优先级反转概念:比如两个线程 A 和 B,优先级 A < B。当 A 获取锁访问共享资源时,B 尝试获取锁,那么 B 就会进入忙等状态,忙等时间越长对 CPU 资源的占用越大;而由于 A 的优先级低于 B,A 无法与高优先级的线程争夺 CPU 资源,从而导致任务迟迟完成不了。解决优先级反转的方法有“优先级天花板”和“优先级继承”,它们的核心操作都是提升当前正在访问共享资源的线程的优先级。

3、避免死锁

很常见的场景是,同一线程重复获取锁导致的死锁,这种情况可以使用递归锁来处理,pthread_mutex_t使用pthread_mutex_init_recursive()方法初始化就能拥有递归锁的特性。

使用pthread_mutex_trylock()等尝试获取锁的方法能有效的避免死锁的情况,在 YYCache 源码中有一段处理就比较精致:

while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            ...
            finish = YES;
            ...
            pthread_mutex_unlock(&_lock);
        } else {
            usleep(10 * 1000); //10 ms
        }
    }

这段代码除了避免潜在的死锁情况外,还做了一个10ms的挂起操作然后循环尝试,而不是直接让线程空转浪费过多的 CPU 资源。虽然挂起线程“浪费了”互斥锁的空转期,增加了唤醒线程的资源消耗,降低了锁的性能,但是考虑到 YYCache 此处的业务是修剪内存,并非是对锁性能要求很高的业务,并且修剪的任务量可能比较大,出现线程竞争的几率较大,所以这里放弃线程空转直接挂起线程是一个不错的处理方式。

4、最小化临界区

开发者应该充分的理解业务,将临界区尽量缩小,不会出现线程安全问题的代码就不要用锁来保护了,这样才能提高并发时锁的性能。

5、时刻注意不可重入方法的安全

当一个方法是可重入的时候,可以放心大胆的使用,若一个方法不可重入,开发者应该多留意,思考这个方法会不会有多个线程访问的情况,若有就老老实实的加上线程锁。

6、编译器的过度优化

编译器可能会为了提高效率将变量写入寄存器而暂时不写回,方便下次使用,我们知道一句代码转换为指令不止一条,所以在变量写入寄存器没来得及写回的过程中,可能这个变量被其它线程读写了。编译器同样会为了提高效率对它认为顺序无关的指令调换顺序。

以上都可能会导致合理使用锁的地方仍然线程不安全,而volatile关键字就可以解决这类问题,它能阻止编译器为了效率将变量缓存到寄存器而不及时写回,也能阻止编译器调整操作volatile修饰变量的指令顺序。

原子自增函数就有类似的应用:int32_t OSAtomicIncrement32( volatile int32_t *__theValue )

7、CPU 乱序执行

CPU 也可能为了提高效率而去交换指令的顺序,导致加锁的代码也不安全,解决这类问题可以使用内存屏障,CPU 越过内存屏障后会刷新寄存器对变量的分配。

OC 实现单例模式的方法:

void
_dispatch_once(dispatch_once_t *predicate,
        DISPATCH_NOESCAPE dispatch_block_t block)
{
    if (DISPATCH_EXPECT(*predicate, ~0l) != ~0l) {
        dispatch_once(predicate, block);
    } else {
        dispatch_compiler_barrier();
    }
    DISPATCH_COMPILER_CAN_ASSUME(*predicate == ~0l);
}

总结:

关于多线程的编程路刚起步!只能是先借鉴别人的一些思想方法了。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值