深入理解并行编程3

原创
2018/06/11 22:13
阅读数 430

工具

脚本语言

  • Linux shell

POSIX 多进程

  • pthreads

POSIX 进程创建和销毁

  • 进程通过fork()创建
    • 执行fork()的进程,称之为:父进程
      • 父进程可以通过wait()等待子进程执行完毕
        • 每一此调用wait()只能等待一个子线程
          • 循环
          • 保存信号量
            • 防止意外错过子进程通知
    • fork()会返回两次
      1. 父进程
        • 成功创建子进程,则返回 子进程PID
        • 失败则返回 负数(错误码)
      2. 子进程
        • 返回 0
      • 可以通过返回值来判断是哪一个进程
  • 使用kill()销毁
  • exit()自行结束退出
  • 父进程和子进程并不共享内存
    • 操作的变量不是同一个
    • 最细粒度的并行化需要共享内存
      • 线程
      • 共享内存式的并行化可要比 fork-join 式的并行化复杂的多

POSIX 线程的创建和撤销

  • 创建
    • 进程需要调用pthread_create()来创建一个线程
      • 参数:
        1. 指向pthread_t结构的指针
          • 用于存放将要创建线程的线程 ID 号
        2. 指向pthread_attr_t结构的指针
          • 可选
        3. 新线程将要调用的函数
          • 顶层函数
        4. 传递给第3步函数的参数
  • 结束
    • 执行完成
      • 顶层函数返回
    • 调用pthread_exit()退出
  • 父进程
    • 调用pthread_join()等待线程结果
      • 对 fork-join 中的 wait()的模仿
      • 一直阻塞到 tid 变量指定的线程返回
  • 线程之间共享内存
    • 变量
    • 数据竞争

POSIX 锁

POSIX 规范允许程序员使用“POSIX 锁”来避免数据竞争

POSIX 锁包括:

  • 操作类型为 pthread_mutex_t 的锁
    • 该锁的静态声明和初始化由PTHREAD_MUTEX_INITIALIZER完成
      • pthread_mutex_init()
        • 动态分配并初始化
    • pthread_mutex_lock()
      • 获取一个指定的锁
    • pthread_mutex_unlock()
      • 释放一个指定的锁
    • 互相排斥的加锁/解锁
      • 一次只能有一个线程在一个特定时刻“持有”一把特定的锁

POSIX 读写锁

pthread_rwlock_t类型

  • 也可以由PTHREAD_RWLOCK_INITILIZER静态初始化

  • 或者由pthread_rwlock_init()原语动态初始化

  • pthread_rwlock_rdlock()原语获取pthread_rwlock_t的读锁

  • pthread_rwlock_wrlock()获取它的写锁

  • pthread_rwlock_unlock()原语负责释放锁

  • 在任意时刻只能有一个线程持有给定pthread_rwlock_t的写锁

    • 但同时可以有多个线程持有给定pthread_rwlock_t的读锁
      • 至少在没有线程持有写锁时是如此
  • 读写锁是专门为大多数读的情况设计的

    在这种情况中,读写锁可以提供比互斥锁大得多的扩展性,因为互斥锁从定义上已经限制了任意时刻只能有一个线程持有锁, 而读写锁允许任意多数目的读者线程同时持有读锁

  • 读写锁优化

    • 如果数据从不被改变,那么在访问它时,可以不持有任何锁。
    • 如果数据修改频率一点也不频繁,您可以使用检查点,终止所有线程,修改数据,然后在检查点重新启动线程。
    • 另一个方法是为每一个线程保留一个排它锁。这样,某个线程要读取数据时,获取它自己的锁,要写数据时,获取所有线程的锁
      • 但是随着线程数量的增加,写者的开销变大了
    • 读写锁在临界区最小时开销最大
      • 最好能有其他手段来保护极其短小的临界区

原子操作

  • 这些操作都返回参数的原值
    • __sync_fetch_and_add()
    • __sync_fetch_and_sub()
    • __sync_fetch_and_or()
    • __sync_fetch_and_and()
    • __sync_fetch_and_xor()
    • __sync_fetch_and_nand()
  • 比较并交换
    • 当变量的原值与指定的 oldval 相等时,这两个原语自动将 newval 写给指定变量
    • __sync_bool_compare_and_swap()
      • bool 版本的原语在操作成功时返回 1,操作失败时返回 0
    • __sync_val_compare_and_swap()
      • val 版本的原语在变量的原值等于指定的 oldval 时,返回变量的原值,表明操作执行成功
  • 内存屏障
    • __sync_synchronize()
    • 限制编译器和 CPU 对指令乱序执行的优化

Lixux内核中类似POSIX的操作

gcc 的__sync_族原语都是提供 memory-ordering 的语义,这激励许多程序 员实现自己的函数, 来满足许多不需要 memory ordering 语义的场景

选择

永远记住,进程内的通信和消息传递总是比共享内存的多线程执行要好

计数

原子自增的性能随着 CPU 和线程个数的增加而下降

统计计数器

  • pre-thread 计数器,读取时互斥合并
  • 考量写/读的侧重场景

近似上限计数器

  • 资源平均分配给多个线程
    • 资源隔离
    • 数据结构是否统一
  • 但在很多情况下,不能将计数器问题完全分治
    • 部分分治
      • 每个线程只需操纵自己的私有状态
      • 计数还是可以在进程间传递
        • 每线程计数器
        • 全局计数器
      • 关键:如何将仍在运行的线程的统计值加入全局的合计值,而不是等待这些线程退出
  • 读取时,加锁合计

精确上限计数器

如何同时保持两个数值的原子操作

  • 两个变量合并成一个变量
    • 32位:高16位表示计数;低16位表示上限

原子化操作也是需要跨CPU,也是需要额外开销代价的!

基于信号量的上限计数器

Signal-Theft

构建状态机

graph LR 
idle((IDLE))
style idle fill:#1F0,stroke:#333,stroke-width:4px;
req((REQ))
style req fill:#f90,stroke:#333,stroke-width:4px;
ready((READY))
style ready fill:#1F0,stroke:#333,stroke-width:4px;
ack((ACK))


idle -- need flush --> req
idle -- no count --> ready
ready -- flushed --> idle
req -- lcounting --> ready
req -- counting --> ack
ack -- done counting --> ready

这样的结构,在越复杂的CPU架构中,可能会优于原子化操作;但如果硬件简单,原子化操作同步开销小,则不一定!

并行计数器

为了防止计数器总是在零附近变动 方法:

  1. 为计数器增加一个很大的偏差值(如:10亿)
    • 确保计数器的值远离零,让计数器可以有效工作

当计数器需要精确控制,有时还需要阻止后续访问:

  1. 在更新计数器时使用读写锁的读锁
  2. 读取计数器时使用同一把读写锁的写锁

注意:

  • C语言的++操作符不能在多线程代码中保证函数的可靠性
  • 对单个变量的原子操作性能不好,可扩展性也差

分割,用线程还是进程做分割

  • 多进程各自处理自己的数据是很好的方法
  • 但是多线程共享并行处理的优势:
    1. 只对性能最关键的应用部分才必须分割
      • 分割的部分通常是应用的一小部分
    2. 虽然与寄存器指针相比,缓存缺失是非常慢的,但比起进程间通信原语要快的多,比TCP/IP网络通信更是快的多
    3. 共享内存多处理器不算贵
展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
0 收藏
0
分享
返回顶部
顶部