《深入理解并行编程》整理笔记

目录

  • 1.并发编程的目标
  • 2.并行访问控制 - 是什么使并行编程变得复杂?
  • 3.关于硬件 - 对并行编程造成的障碍
  • 4.这种障碍的实际开销
  • 5.并行编程领域的基本工具
    • 5.1 脚本语言
    • 5.2 POSIX API - 支持多进程虚拟化和POSIX线程
    • 5.3 原子操作
    • 5.4 Linux 内核中类似 POSIX 的操作
  • 6.计数
  • 7.RCU(Read-Copy Update) 基础
    • RCU概念
    • Linux中RCU机制的原理
    • 可睡眠 RCU 实现
  • 拓展阅读
    • SMP对称多处理结构
    • 为何苹果双核性能比安卓4核要高
    • Java
    • TPC-数据库常用压测工具

1.并发编程的目标

相对于串行编程来说,并行编程有如下三个主要目标:

  • 性能 - 因为现在CPU性能已经遇到瓶颈,使用单线程编程又无法发挥多核CPU的性能
  • 生产率 - 提高创建并行软件的生产率
  • 通用性 - 开发并行程序需要很高的成本,更加通用的并行程序能够有效降低成本。然而通用性又会带来更大的性能损失和生产率损失。参考一下现在流行的我所知道的并行编程环境:
    • C/C++"锁及线程":这包含POSIX线程(pthread),Windows线程,以及很多操作系统内核环境。它们提供了优秀的性能(至少在SMP系统上是如此),也提供了良好的通用性。可惜的是生产率较低.
    • Java: 这个编程环境与生俱来就有多线程能力,它比C/C++生产率高。但性能要比它们底。
    • SQL: 结构化编程语言 SQL 非常特殊,仅仅运用于数据库查询。但是,它的性能非常好,生产率也很优秀,对并行编程知之甚少的人员来说,这个并行编程环境很好的允许它们使用大型并行机器。

所以,并没有完美的环境,需要我们在性能、生产率、通用性之间进行权衡。

对于电脑或者手机的结构来讲:从底层硬件、固件、操作系统核心、系统库、中间软件到上层应用;越往上层,生产率越来越重要。越往下层,性能和通用性越来越重要。因为在上层需要进行大量开发工作,必须考虑通用性来降低成本,而下层性能的损失极难在上层恢复。

所以,性能和通用性是底层开发主要关心的地方

2.并行访问控制 - 是什么使并行编程变得复杂?

(需要注意的是:并行计算的困难,有人为因素的原因与并行计算本身的技术 属性的原因,二者给并行计算带来的困难是差不多的。这是由于我们需要人为干 涉并行计算的过程,人和计算机间的双向交互需要人和机器都执行同等复杂的操 作。因此,采用抽象或者数学分析将极大的限制实用性。)

  • 给定一个单线程的顺序进程,单线程对所有进程的资源都有访问权。
  • 第一个并行访问控制问题是访问特定的资源是否受限于资源的位置。
  • 其他的并行访问控制问题是线程如何协调访问资源。这种协调是由非常多的 同步机制通过 供不同的并行语言和环境来实施的,包括消息传递,加锁,事务, 引用计数,显式计时,共享原子变量,以及数据所有权。需要传统的并行编程关 注由此引出的死锁,活锁,和事务回滚。这种框架如果要详细讨论需要涉及到同 步机制的比较,比如加锁相对于事务内存,但这些讨论不在这节的讨论范围内。

3.关于硬件 - 对并行编程造成的障碍

(大多数人根据直觉就知道,在系统间传递消息要比在单个系统上执行简单计 算更加耗时。不过,在共享同一块内存的系统的线程间传递消息是不是也更加耗 时,这点可就不一定了。本章主要关注共享内存系统中的同步和通信的开销,只 涉及了一些共享内存并行硬件设计的皮毛)

单线程

  1. CUP流水线

    • 过去微处理器在处理下一条指令之前,至少需要取址、解码和执行3个周期来完成本条指令;后来的CPU可以同时处理多条指令,通过一条很长的‘流水线’来控制CPU内部的指令流。
    • 带有长流水线的 CPU 想要达到最佳性能,需要程序给出高度可预测的控制流。在这种程序中,流水线可以一直保持在满状态,CPU 高速运行。
    • (如果程序中带有许多循环,且循环计数都比较小;或者面向对象 的程序中带有许多虚对象,每个虚对象都可以引用不同的实对象,而这些实对象 都有频繁被调用的成员函数的不同实现,此时 CPU 很难或者完全不可能预测某 个分支的走向。这样 CPU 要么等待控制流进行到足以知道分支走向的方向时, 要么干脆猜测——由于此时程序的控制流不可预测——CPU 常常猜错。在这两 种情况中,流水线都是空的,CPU 需要等待流水线被新指令填充,这将大幅降 低 CPU 的性能)
  2. 内存引用

    • 过去,微处理器从内存读取一个值的时间比执行一条指令的时间短。现在它可以在这段时间执行上百 条甚至上千条指令
    • 虽然现代微型计算机上的大型缓存极大地减少了内存访问延迟,但是只有高 度可预测的数据访问模式才能让缓存发挥最大效用。不幸的是,一般像遍历链表 这样的操作的内存访问模式非常难以预测——毕竟如果这些模式是可预测的,我 们也就不会被指针所困扰了,是吧?内存引用常常是影响现代 CPU 性能的重要因素。

多线程

  1. 原子操作

    • 原子操作的概念在某种意义上与CPU流水线上的一次执行一条的汇编操作冲突了。现代CPU为了提高性能让CPU能够乱序执行原子操作。
    • 原子操作通常只用于数据的单个元素。由于许多并行算法都需要在更新多个数据元素时,保证正确的执行顺序,大多数CPU都提供了内存屏障。它也是影响性能的因素之一。
  2. 内存屏障

    • 下面是一个简单的基于锁的 临界区。
    1 spin_lock(&mylock);
    2 a = a + 1;
    3 spin_unlock(&mylock);
    
    • 如果CPU没有顺序执行上诉语句,a会在没有锁的情况下加一,导致我们无法得到精确的值。为防止这种情况发生,锁操作原语必须包含显示或隐式的内存屏障。当然这样会降低CPU性能。
  3. Cache Miss - 缓存未命中

    • 现代CPU使用大容量的高速缓存来降低由于较低的内存访问速度带来的性能惩罚。但是,CPU高速缓存事实上对多CPU间频繁访问的变量起反效果。
    • 当某个CPU想去更改变量的值时,这个变量刚被其他CPU修改过而存在他的缓存中,这将导致代价高昂的Cache Miss
  4. I/O操作

    • 其实缓存未命中可以视为CPU之间的I/O操作,当然相比于其它的I/O操作,这个代价最低廉。
    • I/O操作设计网络、大容量存储器,或者(更糟的)人类本身,I/O操作对性能的影响远远大于以上提到的各种障碍。

4.这种障碍的实际开销

硬件体系结构

《深入理解并行编程》整理笔记_第1张图片
  • 这是一个粗略的八核计算机系统概要图。每个管芯有两个CPU核,每个核带有自己的高速缓存,管芯内还带有一个互联模块,使管芯内的两个核可以互 相通信。在图中央的系统互联模块可以让四个管芯相互通信,并且将管芯与主存 连接起来。
  • 数据是以“缓存线”为单位在系统中传输的,每个“缓存线”大小在32到256字节之间。
  • 当 CPU 从内存中读取一个变 量到它的寄存器中时,必须首先将包含了该变量的缓存线读取到 CPU 高速缓存。 同样地,CPU 将寄存器中的一个值存储到内存时,不仅必须将包含了该值的缓 存线读到 CPU 高速缓存,还必须确保没有其他 CPU 拥有该缓存线的拷贝。
  • 具体过程:
如果 CPU0 在对一个变量执行“比较并交换”(CAS- Compare and Swap)操作,而该变量所在的缓存线在 CPU7 的高速缓存中,就会发生以下经过简化的事件序列:
1. CPU0 检查本地高速缓存,没有找到缓存线。
2. 请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的本地高速缓存,
没有找到缓存线。
3. 请求被转发到系统互联模块,检查其他三个管芯,得知缓存线被 CPU6
和 CPU7 所在的管芯持有。
4. 请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,
在 CPU7 的高速缓存中找到缓存线。
5. CPU7 将缓存线发送给所属的互联模块,并且刷新自己高速缓存中的缓
存线。
6. CPU6 和 CPU7 的互联模块将缓存线发送给系统互联模块。
7. 系统互联模块将缓存线发送给 CPU0 和 CPU1 的互联模块。
8. CPU0 和 CPU1 的互联模块将缓存线发送给 CPU0 的高速缓存。
9. CPU0 现在可以对高速缓存中的变量执行 CAS 操作了。
关于CAS

操作的开销

一些在并行程序中很重要的常见操作开销如下图所示。该系统的时钟周期为0.6ns。在表格的第三列,操作被标准化到了整个时钟周期,称作“比率”。

Operation Cost (ns) Ratio
Clock period 0. 1.0
Best-case CAS 37.9 63.2
Best-case lock 65.6 109.3
Single cache miss 139.5 232.5
CAS cache miss 306.0 510.0
Comms Fabric 3,000 5,000
Global Comms 130,000,000 216,000,000

(最好情况下的CAS操作消耗大概40纳秒,超过60个时钟周期。这里的“最 好情况”是指对某一个变量执行CAS操作的CPU正好是最后一个操作该变量的 CPU,所以对应的缓存线已经在 CPU 的高速缓存中了,类似地,最好情况下的 锁操作(一个“round trip 对”包括获取锁和随后的释放锁)消耗超过 60 纳秒, 超过 100 个时钟周期。这里的“最好情况”意味着用于表示锁的数据结构已经在 获取和释放锁的 CPU 所属的高速缓存中了。锁操作比 CAS 操作更加耗时,是因
深入理解并行编程
为锁操作的数据结构中需要两个原子操作。
缓存未命中消耗大概 140 纳秒,超过 200 个时钟周期。需要在存储新值时查
询变量的旧值的 CAS 操作,消耗大概 300 纳秒,超过 500 个时钟周期。想想这 个,在执行一次 CAS 操作的时间里,CPU 可以执行 500 条普通指令。这表明了 细粒度锁的局限性。)

5.并行编程领域的基本工具

5.1 脚本语言

Linux shell 脚本语言用一种简单而又有效的方法处理并行化。比如,假设你 有一个叫做 compute_it 的程序,你需要用不同的参数运行两次。那么只需要这样 写:

1 compute_it 1 > compute_it.1.out &
2 compute_it 2 > compute_it.2.out &
3 wait
4 cat compute_it.1.out
5 cat compute_it.2.out

小问题:可是这个愚蠢透顶的 shell 脚本并不是真正的并行程序!这些垃 圾有什么用??

5.2 POSIX API - 支持多进程虚拟化和POSIX线程

(本节浅入浅出地介绍了 POSIX 环境,包括广泛应用的 pthreads[Ope97]。3.2.1 节介绍了 POSIX 的 fork()和相关原语,3.2.2 节介绍了线程创建和撤销,3.2.3 节 介绍了 POSIX locking 机制,最后的 3.4 节展示了 Linux 内核中的类似操作。)

  • POSIX进程创建和撤销
    • 进程通过 fork()原语创建,使用 kill()原语撤销,也可以用 exit()原语自我撤 销。执行 fork()的进程被称为新创建进程的“父进程”。父进程可以通过 wait()原 语等待子进程的执行完毕。
1 int x = 0;
2 int pid;
3
4 pid = fork();
5 if (pid == 0) { /* child */
6 x = 1;
7 printf("Child process set x=1\n");
8 exit(0);
9}
10 if (pid < 0) { /* parent, upon error */ 11 perror("fork");
12 exit(-1);
13 }
14 waitall();
15 printf("Parent process sees x=%d\n", x);
for (;;) {
   pid = wait(&status);
if
} }
(pid == -1) {
if (errno == ECHILD)
break;
perror("wait");
exit(-1);
  • POSIX 线程的创建和撤销
    • 在一个已有的进程中创建线程,需要调用 pthread_create()
    pthread_t tid;
    if (pthread_create(&tid, NULL, mythread, NULL) != 0) {
    perror("pthread_create"); // perror(); 是错误输出函数。 用来输出当前的错误信息,如果没有错误就显示ERROR 0。
    exit(-1);
    }
    if (pthread_join(tid, &vp) != 0) {
    perror("pthread_join");
    exit(-1);
    }

// pthread_create()的第一个参数是指向 pthread_t 类型
的指针,用于存放将要创建线程的线程 ID 号,第二个 NULL 参数是一个可选的 指向 pthread_attr_t 结构的指针,第三个参数是新线程将要调用的函数(在本例 中是 mythread()),最后一个 NULL 参数是传递给 mythread()的参数。

  • POSIX 锁 - 互斥锁pthread_mutex_lock() +现在OSSpinLock已经替换成这个了

    • POSIX 规范允许程序员使用“POSIX 锁”来避免 data race。POSIX 锁包括 几个原语,其中最基础的是 pthread_mutex_lock()和 pthread_mutex_unlock()。这 些原语操作类型为 pthread_mutex_t 的锁。该锁的静态声明和初始化由 PTHREAD_MUTEX_INITIALIZER 完成,或者由 pthread_mutex_init()原语来动态 分配并初始化。本节的示例代码将采用前者。
    • pthread_mutex_lock()原语“获取”一个指定的锁,pthread_mutex_unlock()原 语“释放”一个指定的锁。如果一对线程 尝试同时获取同一把锁,那么其中一个线程会先“获准”持有该锁,另一个线程 只能等待第一个线程释放该锁。
    • 如果我想让多个线程同时获取同一把锁会发生什么?
1 pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER; 
2 pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER; 
3 int x = 0;
4
5 void *lock_reader(void *arg)
6{
7   int i;
8   int newx = -1;
9   int oldx = -1;
10  pthread_mutex_t *pmlp = (pthread_mutex_t *)arg;
11
12  if (pthread_mutex_lock(pmlp) != 0) {
13      perror("lock_reader:pthread_mutex_lock");
14      exit(-1);
15  }
16  for (i = 0; i < 100; i++) {
17      newx = ACCESS_ONCE(x);
18      if (newx != oldx) {
19          printf("lock_reader(): x = %d\n", newx);
20      }
21      oldx = newx;
22      poll(NULL, 0, 1);
23  }
24  if (pthread_mutex_unlock(pmlp) != 0) {
25      perror("lock_reader:pthread_mutex_unlock");
26      exit(-1);
27  }
28  return NULL;
29}
30
31 void *lock_writer(void *arg)
32 {
33      int i;
34      pthread_mutex_t *pmlp = (pthread_mutex_t *)arg;
35
36      if (pthread_mutex_lock(pmlp) != 0) {
37          perror("lock_reader:pthread_mutex_lock");
38          exit(-1);
39      }
40      for (i = 0; i < 3; i++) {
41          ACCESS_ONCE(x)++;
42          poll(NULL, 0, 5);
43      }
44      if (pthread_mutex_unlock(pmlp) != 0) {
45          perror("lock_reader:pthread_mutex_unlock");
46          exit(-1);
47      }
48      return NULL;
49 }
  • POSIX 读写锁 - 自旋锁pthread_rwlock_t
    • POSIX API 供了一种读写锁,用 pthread_rwlock_t 类型来表示。和 pthread_mutex_t 一样,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 的读锁,至少在没有线程持有写锁时是如此。
    • 正如读者期望的那样,读写锁是专门为大多数读的情况设计的。在这种情况 中,读写锁可以 供比互斥锁大得多的扩展性,因为互斥锁从定义上已经限制了 任意时刻只能有一个线程持有锁,而读写锁允许任意多数目的读者线程同时持有 读锁。不过我们需要知道读写锁到底增加了多少可扩展性。

5.3 原子操作

* 读写锁在临界区最小时开销最大,考虑到这一点,那么最好 能有其他手段来保护极其短小的临界区。
* gcc 编译器 供了许多附加的原子操作,包括__sync_fetch_and_sub()、 __sync_fetch_and_or()、__sync_fetch_and_and()、__sync_fetch_and_xor()和 __sync_fetch_and_nand()原语,这些操作都返回参数的原值。如果你一定需要变 量的新值,可以使用__sync_add_and_fetch()、__sync_sub_and_fetch()、 __sync_or_and_fetch()、__sync_and_and_fetch()、__sync_xor_and_fetch()和 __sync_nand_and_fetch()原语。
* 有一对原语 供了经典的“比较并交换”操作,__sync_bool_compare_and_swap()和__sync_val_compare_and_swap()。
* __sync_synchronize()原语是一个“内存屏障”,它限制编译器和 CPU 对指令 乱序执行的优化。在某些情况下,只限制编译器对指令 的优化就足够了,CPU 的优化可以保留,此时就需要使用 barrier()原语

5.4 Linux 内核中类似 POSIX 的操作

(不幸的是,远在各种标准委员会出现之前,线程操作,加锁、解锁原语和原 子操作就已经存在了。因此,这些操作有很多种变体。用汇编语言实现这些操作 也十分常见,不仅因为历史原因,还因为可以在某些特定场合获得更好的性能。 比如,gcc 的_sync族原语都是 供 memory-ordering 的语义,这激励许多程序 员实现自己的函数,来满足许多不需要 memory ordering 语义的场景。)

POSIX 原语与 Linux 内核函数对应表

Category POSIX Linux Kernel
Thread Management pthread_t struct task_struct
pthread_create() kthread_create
pthread_exit() kthread_should_stop() (rough)
pthread_join() kthread_stop() (rough)
poll(NULL, 0, 5) schedule_timeout_interruptible()
POSIX Locking pthread_mutex_t spinlock_t (rough)
struct mutex
PTHREAD_MUTEX_INITIALIZER DEFINE_SPINLOCK()
DEFINE_MUTEX()
pthread_mutex_lock() spin_lock() (and friends)
mutex_lock() (and friends)
pthread_mutex_unlock() spin_unlock() (and friends)
mutex_unlock()
POSIX Reader-Writer pthread_rwlock_t rwlock_t (rough)
Locking struct rw_semaphore
PTHREAD_RWLOCK_INITIALIZER DEFINE_RWLOCK()
DECLARE_RWSEM()
pthread_rwlock_rdlock() read_lock() (and friends)
down_read() (and friends)
pthread_rwlock_unlock() read_unlock() (and friends)
up_read()
thread_rwlock_wrlock() write_lock() (and friends)
down_write() (and friends)
pthread_rwlock_unlock() write_unlock() (and friends)
up_write()
Atomic Operations C Scalar Types atomic_t
atomic64_t
__sync_fetch_and_add() atomic_add_return()
atomic64_add_return()
__sync_fetch_and_sub() atomic_sub_return()
atomic64_sub_return()
__sync_val_compare_and_swap() cmpxchg()
__sync_lock_test_and_set() xchg() (rough)
__sync_synchronize() smp_mb()

比较 POSIX、gcc 原语和 Linux 内核中使用的版本。精准的对应关系很难给出,因为 Linux 内核有各种各样的加 锁、解锁原语,gcc 则有很多 Linux 内核中不能直接使用的原子操作。当然,一 方面,用户态的代码不需要 Linux 内核中各种类型的加锁、解锁原语,同时另一 方面,gcc 的原子操作也可以直接用 cmpxchg()来模拟。

6.计数

(计算机能做的事情里,计数也许是最简单也是最自然的了。不过在一台大型 的共享内存的多处理器系统上高效并且 scalably(可扩展性) 的计数,仍然具有相当的挑战性。 更进一步,计数背后隐含概念的简单性使得我们可以探索并发中的基本问题,而 无需被繁复的数据结构或者复杂的同步原语干扰。因此,计数是并行编程的极佳 切入对象。)

把一个数从1自增加到10亿(使用多线程来提高效率)

  1. 不加锁,非原子访问,最终可能只得到5亿左右。这与CUP读写原理有关,读的时候从主存复制一份到缓存,写入时更新主存
  2. 加锁-互斥锁,能够得到正确值,但性能太差。
    《深入理解并行编程》整理笔记_第2张图片

    图 4.4 是另一种全局原子自增的视角。为了让每个 CPU 得到机会增加一个指定全局变量,包含变量的缓存线需要在所有 CPU 间传播,如图中红箭头所示。这种传播相当耗时,从而导致了糟糕性能。
  3. 统计计数器,给每个线程一个计数器,那么总的计数值就是所有线程计数器值的简单相加。
    《深入理解并行编程》整理笔记_第3张图片

    这种做法不再需 要代价昂贵的跨越整个计算机系统的通信。但是这种在“更新”上扩展极佳的方 法,在存在大量线程时,会带来“读取”上的巨大代价。
    如何能在保留“更新”侧扩展性的同时,减少“读取”侧产生的代价?
  4. 结果一致的实现,之前每次读取前,都要更新所有线程的计数器以保证数据一致性。现在使用一种弱读取方式,只是在最终计算完成之后才更新得到正确的值。

近似上限计数器-另一种设计

  • 给每个线程分配固定计算上限(因为之前的做法可能会导致上限溢出,也就是说最终结果超过10亿),比如1000。

可是对于由某一个线程创建,但由另一个线程释放就无法处理了。

那么。。。

7.RCU(Read-Copy Update) 基础

  • 概述:读——拷贝——更新(RCU)是一种同步机制,2002 年 10 月引入 Linux 内 核。RCU 允许读操作可以与更新操作并发执行,这一点 升了程序的可扩展性。 常规的互斥锁让并发线程互斥执行,并不关心该线程是读者还是写者,而读写锁 在没有写者时允许并发的读者,相比于这些常规锁操作,RCU 在维护对象的多 个版本时确保读操作保持一致,同时保证只有所有当前读端临界区都执行完毕后 才释放对象。RCU 定义并使用了高效并且易于扩展的机制,用来发布和读取对 象的新版本,还用于延后旧版本对象的垃圾收集工作。这些机制恰当地在读端和 更新端分布工作,让读端非常快速。在某些场合下(比如非抢占式内核里),RCU 读端的函数完全是零开销。

RCU概念

  • RCU 实现必须遵从以下规则:如果 RCU 读临界区中的任何语句在一个 grace period 之前,那么 RCU 读临界区中的所有语句都必须在 grace period 结束 前完成。


    《深入理解并行编程》整理笔记_第4张图片

    红色边框 "Reader" 框是一个错误例子:任何这 样的 RCU 实现是错误的,它允许一个读临界区完全覆盖一个 grace period, 因为 这样的话,在读者仍然在使用内存的时候,内存可能已经被释放。

所以说,会有这样的 RCU 出现这样的情况吗?


《深入理解并行编程》整理笔记_第5张图片
  • 必须扩展 grace period,可能如图 D.59 所示。简单的说,RCU 实现必须确 保任何开始于特定 grace period 的 RCU 读临界区必须在 grace period 允许完成前 全部结束。这个事实可以允许 RCU 验证专注于如下一点:简单的证明任何开始 于 grace period 的 RCU 读临界区必须在 grace period 结束前结束。这需要充足的 屏障来防止编译器和 CPU 破坏 RCU 的作用。

1.Linux中RCU机制的原理

  • RCU机制是Linux2.6之后提供的一种数据一致性访问的机制,从RCU(read-copy-update)的名称上看,我们就能对他的实现机制有一个大概的了解,在修改数据的时候,首先需要读取数据,然后生成一个副本,对副本进行修改,修改完成之后再将老数据update成新的数据,此所谓RCU。

  • 在操作系统中,数据一致性访问是一个非常重要的部分,通常我们可以采用锁机制实现数据的一致性访问。例如,semaphore、spinlock机制,在访问共享数据时,首先访问锁资源,在获取锁资源的前提下才能实现数据的访问。这种原理很简单,根本的思想就是在访问临界资源时,首先访问一个全局的变量(锁),通过全局变量的状态来控制线程对临界资源的访问。但是,这种思想是需要硬件支持的,硬件需要配合实现全局变量(锁)的读-修改-写,现代CPU都会提供这样的原子化指令。采用锁机制实现数据访问的一致性存在如下两个问题:

  1. 效率问题。锁机制的实现需要对内存的原子化访问,这种访问操作会破坏流水线操作,降低了流水线效率。这是影响性能的一个因素。另外,在采用读写锁机制的情况下,写锁是排他锁,无法实现写锁与读锁的并发操作,在某些应用下回降低性能。

  2. 扩展性问题。当系统中CPU数量增多的时候,采用锁机制实现数据的同步访问效率偏低。并且随着CPU数量的增多,效率降低,由此可见锁机制实现的数据一致性访问扩展性差。

为了解决上述问题,Linux中引进了RCU机制。该机制在多CPU的平台上比较适用,对于读多写少的应用尤其适用。RCU的思路实际上很简单,下面对其进行描述:

  1. 对于读操作,可以直接对共享资源进行访问,但是前提是需要CPU支持访存操作的原子化,现代CPU对这一点都做了保证。但是RCU的读操作上下文是不可抢占的(这一点在下面解释),所以读访问共享资源时可以采用read_rcu_lock(),该函数的工作是停止抢占。

  2. 对于写操作,其需要将原来的老数据作一次备份(copy),然后对备份数据进行修改,修改完毕之后再用新数据更新老数据,更新老数据时采用了rcu_assign_pointer()宏,在该函数中首先屏障一下memory,然后修改老数据。这个操作完成之后,需要进行老数据资源的回收。操作线程向系统注册回收方法,等待回收。采用数据备份的方法可以实现读者与写者之间的并发操作,但是不能解决多个写着之间的同步,所以当存在多个写者时,需要通过锁机制对其进行互斥,也就是在同一时刻只能存在一个写者。

  3. 在RCU机制中存在一个垃圾回收的daemon(后台驻留程序),当共享资源被update之后,可以采用该daemon实现老数据资源的回收。回收时间点就是在update之前的所有的读者全部退出。由此可见写者在update之后是需要睡眠等待的,需要等待读者完成操作,如果在这个时刻读者被抢占或者睡眠,那么很可能会导致系统死锁。因为此时写者在等待读者,读者被抢占或者睡眠,如果正在运行的线程需要访问读者和写者已经占用的资源,那么死锁的条件就很有可能形成了。

可睡眠 RCU 实现

《深入理解并行编程》整理笔记_第6张图片

  • 经典 RCU 要求读临界区遵从与自旋锁临界区相同的规则:任何类型的阻塞 或者睡眠都是严格禁止的。这常常阻碍了 RCU 的使用,Paul 已经收到大量“可 睡眠 RCU”的请求,以允许在 RCU 读临界区中可以任意睡眠。以前 Paul 以难 以实现的理由拒绝了所有这些请求,这导致在 grace period 结束时,大量的内存 等待释放。最终会导致不幸的后果,如图 D.1 所示,几乎所有的不幸都是由于内存耗尽而将系统挂起.
  • 在内核中使用 RCU,还是强烈的要求“RCU 读临界区永不阻塞”。

从上述分析来看,RCU思想是比较简单的,其核心内容紧紧围绕“写时拷贝”,采用RCU机制,能够保证在读写操作共享资源时,基本不需要取锁操作,能够在一定程度上提升性能。但是该机制的应用是有条件的,对于读多写少的应用,机制的开销比较小,性能会大幅度提升,但是如果写操作较多时,开销将会增大,性能不一定会有所提升。总体来说,RCU机制是对rw_lock的一种优化。




SMP对称多处理结构:

  • SMP的全称是"对称多处理"(Symmetrical Multi-Processing)技术,是指在一个计算机上汇集了一组处理器(多CPU),各CPU之间共享内存子系统以及总线结构。
  • 要组建SMP系统,首先最关键的一点就是需要合适的CPU相配合
    • 1、CPU内部必须内置APIC(Advanced Programmable Interrupt Controllers)单元。
    • 2、相同的产品型号,同样类型的CPU核心。
    • 3、完全相同的运行频率。
    • 4、尽可能保持相同的产品序列编号。
  • 对称多处理 (SMP) 广泛应用于 PC 领域,能够显著提升台式计算机的性能。SMP 能使单芯片上多个相同的处理子系统运行相同的指令集,而且都对存储器、I/O 和外部中断具有同等的访问权限。操作系统 (OS) 的单份拷贝就能控制所有内核,使任何处理器都能运行所有的线程,而无需考虑内核、应用或中断服务的区分
  • SMP 可为各个层面的软件大幅提高性能。对于不支持 SMP 的软件,我们可使用操作系统任务管理器在每个内核上启动进程来实现并行工作。并行进程执行自然会提升性能,虽然其效率不如线程级处理那么高,但也不会对应用开发人员造成更多的设计麻烦。
  • SMP 可在软件的进程与线程层面上提高性能随着移动设备性能的不断提升,用户应用的复杂性也在不断增加,在此情况下,应用程序应更多地以并行方式进行编写(如采用线程方式),因此,我们便可充分发挥 SMP 的真正优势与增益。线程构成进程,不必反复返回操作系统寻求资源。应用开发人员不但要采用并行方式进行软件设计,而且还必须注意进程中线程的互动方式。 某些应用本身就是多线程的,从而使 SMP 能够实现更高的性能,更快的响应时间以及更出色的整体用户体验。如 Google 的 Chrome 等 web 浏览器就采用了多线程技术,因此能够与 SMP 技术实现高度互补。预计这些 PC web 浏览器所使用的这种技术也将用于移动领域。 Symbian 和 Linux 移动操作系统均全面支持 SMP。这种支持针对移动环境进行了专门优化,将使所有处理器内核的单一操作系统内核映像以及调度器中的负载平衡能够帮助确定在哪个内核上运行哪个任务或线程。 在处理原有软件时,我们必须注意正确的任务同步,以避免系统锁死。在 SMP 系统中,操作系统可在安排低优先级任务运行在一个不同内核上的同时,让一个具有较高优先级的任务运行在另一个内核上。如果软件包含不明确的同步,则会产生导致锁死情况的错误判断。通过正确使用信号量、互斥量以及自旋锁等软件技术,SMP 内核的编程软件将可实现 SMP 的全部优势。 SMP 系统上的开发与调试工具至关重要。设计人员需要进一步了解芯片情况才能紧跟软件处理技术。在多个内核上同时运行多个线程的情况下,功能强大的新型工具将可帮助制造商快速向市场推出令人惊奇的全新产品。

为何苹果双核性能比安卓4核要高

  • 为何苹果双核性能比安卓4核要高,实际上它们内部的晶体管数量几乎相等;因为苹果双核处理器的逻辑核心只有两个,所以它的多线程性能比安卓4核弱。
  • 苹果的设计思路选择了消耗两倍ARM资源去实现一个单线程好于ARM(Cortex A15)50%-70%的设计。这样的选择是基于IOS系统的需要——iOS是一个对于后台任务限制很严格的系统,同时也是一个高度私有化、高度优化的封闭体系,在这样的体系里苹果认为单线程性能更加重要一些,因此作出了这样的选择。
  • 这让我回想起之前看过的一篇介绍使用GCD和NSOperation来进行并性编程的建议:就是在主线程获取数据后,在子线程计算后回调给主线程,更新UI或者做其他的事。

Java

  1. Java是一门面向对象编程语言,不仅吸收了C++语言的各种优点,还摒弃了C++里难以理解的多继承、指针等概念,因此Java语言具有功能强大和简单易用两个特征。Java语言作为静态面向对象编程语言的代表,极好地实现了面向对象理论,允许程序员以优雅的思维方式进行复杂的编程。
  2. Java具有简单性、面向对象、分布式、健壮性、安全性、平台独立与可移植性、多线程、动态性等特点。
  3. Java可以编写桌面应用程序、Web应用程序、分布式系统和嵌入式系统应用程序等。
  4. 应用:
    • Android应用
    • 在金融业应用的服务器程序
    • 网站
    • 嵌入式领域
    • 大数据技术。。。

数据库常用压测工具

Tpcc-mysql

TPC(Tracsactin Processing Performance Council)事务处理性能协会是一个评价大型数据库系统软硬件性能的非盈利的组织,TPC-C是TPC协会制定的,用来测试典型的复杂OLTP系统的性能

你可能感兴趣的:(《深入理解并行编程》整理笔记)