Mike Zhang

DNS DevOps CISSP CISA Security+ 摄影 程序员 北京

并发编程编写规范

01 Sep 2018 » program

并发编程解耦执行目的和执行时机,改进程序的吞吐量和结构模式,将单一执行的结构变为并发执行。其中并发与并行的区别,在于是否真的是严格时间上平行执行。并发包含并行,并发不一定真的平行执行,比如单核CPU下是无论如何都做不到并行的。

并发不一定能够改善性能,比如CPU密集型的处理下,单核处理器无法实现多个并发程序的优化,只有在多线程和多处理器能够分享大量等待时间的时候才有效。并发编程,特别是web方面,尽管都能够实现并发访问,但是实现方式却大不相同,比如golang的goroutine和nodejs的eventloop实现。

1. 并发防御原则

并发编程中对于数据的访问会导致一些不期望的结果,比如下面的代码中,如果多个线程同时访问共享的结构体X的一个实例,当调用getNextId的时候到底返回结果是如何,不得而知

public class X {
    private int lastIdUsed;
    public int getNextId(){
        return ++lastIdUsed;
    }
}

为了写出更加安全高效的多线程代码,可以考虑一下几个方面:

  • 单一权责原则: 将并发相关代码独立出来进行管理,实际执行的操作代码单独管理。 比如一个抓取页面中图片的程序,并发应该单独的进行编写,而单次抓取图片的逻辑代码分离。

  • 设置临界区, 对于共享的数据结构应该考虑使用临界区去隔离开来,但是过多的临界区也会导致问题,比如忘记设置保护,或者死锁等等

  • 避免设置共享数据,假设直接的复制数据是可行的,可以考虑复制,而避免锁定资源等开销和错误的可能性

  • 线程的独立: web上每个连接都可能会产生一个独立的线程或者协程或者goroutine等类似的并发模型结构,独立确保之间互不干扰,不用同步可以最大程度的增大并发的效率(但是一般都会需要一些共享资源的调用,比如数据库连接等)

2. 并发编程的执行模型

2.1 基本概念

限定资源: 对于并发访问一定要考虑资源的限定,比如最大的并发连接数,数据库连接池大小等,否则一旦数据量足够大的情况下,会导致程序的崩溃或者数据库访问失效。

互斥 : 每一个时刻都只允许一个线程访问共享数据或者资源

死锁: 两个或者多个线程互相等待执行结束,而不能进一步操作或者终止操作。

线程饥饿: 线程无法得到执行的机会,比如总是让执行的快的线程先执行,或者紧急的线程先执行,导致部分线程一直没有执行机会。

活锁: 假设某些线程等待其他线程在不运行的情况下才能运行,但是始终无法执行(类似于无线信号通信中的冲突避免算法)

2.2 模型类别

  • 生产者消费者模型: 使用一部分线程生产数据,一部分线程消费数据,通过调节线程数量来优化限定资源的使用。

  • 读者和作者模型: 对于共享资源的读写,一般可以考虑读写锁来完成,允许多个读资源的同时访问,增加吞吐量,但是不允许写操作和读操作的同时,更不允许多个写操作的执行。

  • 哲学家宴席问题: 五个哲学家围坐一桌,总共有五个叉子放在他们每个人的左手边,只有同时拿到左边和右边的叉子才能吃饭,如何编写并发程序解决问题。

哲学家问题存在的难处是,如何协调的问题。如果编写不当可能造成死锁,比如所有哲学家同时拿起左侧的叉子并等待右边的叉子是可用的。

线程饥饿也是个问题, 比如坐在对面的两个哲学家效率很高,导致其他的三个都可能无法正常的获得执行机会”:

解决的办法可以考虑资源的回退和信号量机制来完成。

3. 编写并发代码注意事项

保持临界区域的代码尽量短小,临界区域(锁区域)每次只允许一个线程的调用,过长的代码区域会导致程序执行中代码的访问延迟和大量的锁开销。

避免使用共享对象

编写正确关闭代码:父线程等待所有子线程正常结束后关闭资源等,如果子线程无法正常返回时候,时候父线程能够正确的处理

测试代码: 在不同的编程配置和系统配置以及负载条件下频繁的运行, 以下为一些建议:

  • 线程代码可能会在运行几千次甚至百万次后才会显示,不要把偶发错误忽略
  • 先使得非线程代码工作
  • 编写可调整(线程数量,系统环境,吞吐量等),可插拔的线程代码
  • 运行多于处理器数量的线程下执行
  • 不同平台上运行
  • 调整代码并强制发生错误(使用插入点,编写一些sleep函数,或者插入一些混杂的操作等等),比如代码中插入下面的方法类操作,但是只有在测试环境中才执行比如sleep或者yield等操作,实际线上则什么都不做

    public class ThreadJigglePoint(){ public static void jiggle(){ … } }

实际代码中如下所示的调用上面的方法:

<br />public synchronized String nextUrlOrNull(){
    if(hasNext()){
        ThreadJiglePoint.jiggle()
        String url = ....   
        updateHasNext()
        ThreadJiglePoint.jiggle()
        return url;
    }

}

4. 逐步改进代码

代码能够工作还不够,需要考虑代码的整洁性,保持代码的整洁很容易,但是一旦出现坏的代码, 不去理会,当代码量不断的扩大的时候,相互依赖和隐藏的纠结,导致后期找到和修改的成本越来越大,与其将来花费时间去修改代码,不如开始的时候保持良好的代码规范和整洁的代码。 不要让拖延症成为项目的绊脚石, 这也会使得编码人员显得不够专业。

  1. 每个函数都要尽量保持短小,更加易于测试,符合SRP单一职责原则
  2. 利用抽象,解耦代码的依赖,使得代码符合开放闭合原则。
  3. 利用TDD测试的方式,来强制代码编写符合要求,并在每次修改的时候能够进行代码的复查。

附:代码规范总结

1. 注释的使用

  • 删除无用的注释,过期的注释
  • 传递的注释信息不必要,比如修改历史,修改时间等这些可以在版本管理工具中查看
  • 被注释的代码及时删除
  • 保持简介的注释

2. 环境

构建和测试代码都应该简介可靠,而不是依赖于大量相互依赖的环境变量或者脚本等。应该使用make build或者make test就可以完成整个的构建和测试工作。

3. 函数

  • 参数尽量保持三个以内
  • 函数应该只做一件事情,bool值参数往往代表了程序作了不止一件事情
  • 不被调用的函数请及时的删除
  • 函数的输入输出不要违反直觉。

4. 一般性问题

  • 忽视测试或者编译器的警告,可能导致未来出现的程序执行问题。 因此在部署程序的时候一定要完成代码的检查。
  • 不要依赖于直觉,测试要尽量考虑边界条件下的执行,测试要覆盖这些边界。
  • DRY 不要重复代码,如果重复,代表了代码质量存在问题,需要进行重构,使用抽象的类或者子类等方式处理重复行为
  • 基类与子类的实现上面,要考虑抽象层次,不要将不需要的或者只有部分子类才用到的提升到基类的层次。
  • 限制类和模块中暴漏的接口的数量, 简介干净的代码更易于实现。
  • 删除不需要的死代码
  • 前后保持一致,特别是变量的声明和使用方面。
  • 错误的耦合, 声明一些变量或者函数等需要考虑他们的位置,位置不恰当导致模块直接的依赖也不同。
  • 算子参数(布尔型,枚举型等),用于同时处理多个行为的参数引入导致程序过于复杂或非单一职责

  • 特性依恋: 不同类之间的函数过于依赖于对方的实施细节(类A的函数A1调用类B的B1函数),导致类的耦合太紧密。但是有时候确实必要的,比如仅仅用于管理某一个类的辅助类。

  • 理解事物是如何工作的,仅仅通过测试还不够好,需要知道解决方案是正确的。

  • 函数名称应该表达其行为

  • 不恰当的使用静态方法(依赖内部实现或者多态)

  • 逻辑依赖改为物理依赖, 依赖者模块不应该对于被依赖模块有假设(逻辑依赖),比如打印报表的类中直接声明一个静态变量PageSize(属于假设知道,逻辑依赖),应该通过物理依赖(设置函数去设置page size)

  • 遵循固定的编码规则

  • 使用命名常量来代替魔术数字, 不要使用无法让人理解的数字去直接使用

  • 变量声明,并发锁的使用,浮点数的使用是否足够准确,需要仔细考虑

  • 避免否定性条件 shouldCompact() 要比if (!shouldNotCompact())更加让人能够理解

  • 函数应该只做一件事

  • 正确处理对象中的时序耦合问题,确保不会直接调用中间的函数状态

  • 函数应该只在一个抽象层级
  • 避免传递浏览(链式模式)

  • 使用通配符避免过长的导入 import package.*

  • 不要继承常量,导致查询和编码都比较麻烦

  • 使用枚举来管理一组常量

关于测试的一些内容:

  • 测试不足
  • 使用覆盖率工具
  • 不要忽略一些小的测试
  • 被忽略的测试是对于事物的不确定
  • 测试边界条件
  • 测试相近的缺陷
  • 测试失败的模式(编写尽量完整的测试)
  • 测试应该快速有效

附录 死锁

死锁发生的四个必须条件:

  • 互斥: 无法在同一时间为多个线程所有, 或者数量上有限制
  • 上锁和等待: 获取到一个资源,在获得其他所需资源及完成工作前不会释放资源
  • 线程无法夺取资源,只能等待
  • 循环等待

避免死锁的方式:

  • 尽量考虑原子操作
  • 增加资源数量,使其大于等于线程数量
  • 获取资源时候,检查是否可用

如果资源繁忙则释放资源,防止死锁:这种方式导致问题是, 线程饥饿某些线程可能无法被执行(CPU资源利用率低),或者活锁所有的线程都等待,释放资源(CPU反而利用率高)

满足抢先机制: 请求释放资源,如果对方正好在等待期间,则释放资源重新运行,代码可能稍微复杂

不做循环等待: 获取资源次序进行定义,比如T1需要R1和R2 T2需要R2和R1,只要强制线程1和线程2 以同样次序分配,则循环就不会发生。这样会导致资源不必要的等待和锁定,而且有时候次序也无法直接确定,动态生成。

附录 多线程代码执行路径

代码实例:

public class Example{
  int lastId;
  public void resetId(){
    lastId = 0;
  }

   public int getNextId(){
    ++lastId;
   }
}

上述代码执行过程中比如多线程下访问getNextId()并非原子操作,尽管只做了一步自增操作,实际执行的指令为8个! 涉及到入栈和出栈,8个指令中任何指令都可能执行被中断,其他的线程开始访问,因此结果就不会向预期的那样准确,可能的结果也不止一个。

  1. 载入 this到栈中
  2. 复制堆栈顶部内容
  3. 读取lastId的值
  4. 将整数1压入栈中
  5. 执行加法操作,并加入到栈顶
  6. 复制结果放入到this之前
  7. 将结果放入指定位置
  8. 返回栈顶的值

如果指令由10个线程同时进行执行,则执行路径有(8*10)!/ (8!) ^10 。 只能通过加入锁或synchronized方式来管理并发代码的访问