“写时复制 (Copy-on-write: COW) "

写入时复制 (英语: Copy-on-write,简称COW) 是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者 (callers) 同时请求相同资源 (如内存或磁盘上的数据存储) ,他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本 (private copy) 给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的 (transparently) 。此作法主要的优点是如果调用者没有修改该资源,就不会有副本 (private copy) 被创建,因此多个调用者只是读取操作时可以共享同一份资源。

在java中的应用举例

java中有两个类: CopyOnWriteArrayList、CopyOnWriteArraySet使用了写时复制技术手段,CopyOnWriteArrayList的实现:

A thread-safe variant of java.util.ArrayList in which all mutative operations (add, set, and so on) are implemented by making a fresh copy of the underlying array.

在多线程环境,凡是读操作都没有进行加锁操作,而写操作都会在锁的保护下复制一份数据,在复制的数据上做修改,修改完后,再把底层数组的引用指向修改后的新数组。

复制数据意味着需要浪费内存空间,所以在读

写少的并发场景下比较合适。

/** * The lock protecting all mutators. (We have a mild preference * for builtin monitors over ReentrantLock when either will do.) */ final transient Object lock = new Object();

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

所有的修改操作都会使用lock对象内置的锁得以并发安全修改。Object[] array用volatile修饰,保证内存可见性就可以。

Linux系统创建新进程

unix操作系统中有两种创建新进程的几种,分别是fork和exec。

(1) fork可以创建当前进程的一个副本,父进程和子进程只有PID不同。在该系统调用执行之后,系统中有两个进程,都执行同样的操作。父进程内存的内容将被复制,至少从程序的角度来看是这样。 Linux 使用了一种写时复制技术来使 fork 操作更高效, 主要的原理是将内存复制操作延迟到父进程或子进程向某内存页面写入数据之前, 在只读访问的情况下父进程和子进程可以共用同一个内存页。

(2) exec将一个新程序加载到当前进程的内存中并执行。旧程序的内存页将刷出,其内容将替换为新的数据。然后执行新程序。

当然还有很多使用了写时复制优化性能的地方

参考:

https://unix.stackexchange.com/questions/58145/how-does-copy-on-write-in-fork-handle-multiple-fork

https://stackoverflow.com/questions/628938/what-is-copy-on-write

https://wikipedia.hk.wjbk.site/wiki/寫入時複製

http://ifeve.com/java-copy-on-write/

https://hackerboss.com/i-have-seen-the-future-and-it-is-copy-on-write/

https://hackerboss.com/copy-on-write-101-part-1-what-is-it/

深入Linux内核架构.pdf ———————————————— 版权声明: 本文为CSDN博主「A_Beaver」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接: https://blog.csdn.net/doctor_who2004/article/details/103551335

vfork(): 这个做法更加火爆,内核连子进程的虚拟地址空间结构也不创建了,直接共享了父进程的虚拟空间,当然了,这种做法就顺水推舟的共享了父进程的物理空间


https://blog.csdn.net/doctor_who2004/article/details/103551335
https://www.cnblogs.com/biyeymyhjob/archive/2012/07/20/2601655.html

“copy on write”

COW奶牛!Copy On Write机制了解一下 前言

在读《Redis设计与实现》关于哈希表扩容的时候,发现这么一段话:

执行BGSAVE命令或者BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制 (copy-on-write) 来优化子进程的使用效率,所以在子进程存在期间,服务器会提高负载因子的阈值,从而避免在子进程存在期间进行哈希表扩展操作,避免不必要的内存写入操作,最大限度地节约内存。

触及到知识的盲区了,于是就去搜了一下copy-on-write写时复制这个技术究竟是怎么样的。发现涉及的东西蛮多的,也挺难读懂的。于是就写下这篇笔记来记录一下我学习copy-on-write的过程。

本文力求简单讲清copy-on-write这个知识点,希望大家看完能有所收获。

一、Linux下的copy-on-write 在说明Linux下的copy-on-write机制前,我们首先要知道两个函数: fork()和exec()。需要注意的是exec()并不是一个特定的函数, 它是一组函数的统称, 它包括了execl()、execlp()、execv()、execle()、execve()、execvp()。

1.1简单来用用fork 首先我们来看一下fork()函数是什么鬼:

fork is an operation whereby a process creates a copy of itself.

fork是类Unix操作系统上创建进程的主要方法。fork用于创建子进程(等同于当前进程的副本)。

新的进程要通过老的进程复制自身得到,这就是fork! 如果接触过Linux,我们会知道Linux下init进程是所有进程的爹(相当于Java中的Object对象)

Linux的进程都通过init进程或init的子进程fork(vfork)出来的。 下面以例子说明一下fork吧:

include <unistd.h>

include <stdio.h>

int main () { pid_t fpid; //fpid表示fork函数返回的值
int count=0;

// 调用fork,创建出子进程  
fpid=fork();

// 所以下面的代码有两个进程执行!
if (fpid < 0)   
    printf("创建进程失败!/n");   
else if (fpid == 0) {  
    printf("我是子进程,由父进程fork出来/n");   
    count++;  
}  
else {  
    printf("我是父进程/n");   
    count++;  
}  
printf("统计结果是: %d/n",count);  
return 0;  

}
复制代码 得到的结果输出为:

我是子进程,由父进程fork出来

统计结果是: 1

我是父进程

统计结果是: 1

复制代码 解释一下:

fork作为一个函数被调用。这个函数会有两次返回,将子进程的PID返回给父进程,0返回给子进程。(如果小于0,则说明创建子进程失败)。 再次说明: 当前进程调用fork(),会创建一个跟当前进程完全相同的子进程(除了pid),所以子进程同样是会执行fork()之后的代码。 所以说:

父进程在执行if代码块的时候,fpid变量的值是子进程的pid 子进程在执行if代码块的时候,fpid变量的值是0 1.2再来看看exec()函数 从上面我们已经知道了fork会创建一个子进程。子进程的是父进程的副本。

exec函数的作用就是: 装载一个新的程序 (可执行映像) 覆盖当前进程内存空间中的映像,从而执行不同的任务。

exec系列函数在执行时会直接替换掉当前进程的地址空间。 我去画张图来理解一下:

exec函数的作用 参考资料:

程序员必备知识——fork和exec函数详解blog.csdn.net/bad_good_ma… linux中fork () 函数详解 (原创!!实例讲解) : blog.csdn.net/jason314/ar… linux c语言 fork() 和 exec 函数的简介和用法: blog.csdn.net/nvd11/artic… Linux下Fork与Exec使用: www.cnblogs.com/hicjiajia/a… Linux 系统调用 —— fork()内核源码剖析: blog.csdn.net/chen8927040… 1.3回头来看Linux下的COW是怎么一回事 fork()会产生一个和父进程完全相同的子进程(除了pid)

如果按传统的做法,会直接将父进程的数据拷贝到子进程中,拷贝完之后,父进程和子进程之间的数据段和堆栈是相互独立的。

父进程的数据拷贝到子进程中 但是,以我们的使用经验来说: 往往子进程都会执行exec()来做自己想要实现的功能。

所以,如果按照上面的做法的话,创建子进程时复制过去的数据是没用的(因为子进程执行exec(),原有的数据会被清空) 既然很多时候复制给子进程的数据是无效的,于是就有了Copy On Write这项技术了,原理也很简单:

fork创建出的子进程,与父进程共享内存空间。也就是说,如果子进程不对内存空间进行写入操作的话,内存空间中的数据并不会复制给子进程,这样创建子进程的速度就很快了!(不用复制,直接引用父进程的物理空间)。 并且如果在fork函数返回之后,子进程第一时间exec一个新的可执行映像,那么也不会浪费时间和内存空间了。 另外的表达方式:

在fork之后exec之前两个进程用的是相同的物理空间 (内存区) ,子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。

当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。

如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间 (至此两者有各自的进程空间,互不影响) ,而代码段继续共享父进程的物理空间 (两者的代码完全相同) 。

而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。

Copy On Write技术实现原理:

fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断 (page-fault) ,陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。

Copy On Write技术好处是什么?

COW技术可减少分配和复制大量资源时带来的瞬间延时。 COW技术可减少不必要的资源分配。比如fork进程时,并不是所有的页面都需要复制,父进程的代码段和只读数据段都不被允许修改,所以无需复制。 Copy On Write技术缺点是什么?

如果在fork()之后,父子进程都还需要继续进行写操作,那么会产生大量的分页错误(页异常中断page-fault),这样就得不偿失。 几句话总结Linux的Copy On Write技术:

fork出的子进程共享父进程的物理空间,当父子进程有内存写入操作时,read-only内存页发生中断,将触发的异常的内存页复制一份(其余的页还是共享父进程的)。 fork出的子进程功能实现和父进程是一样的。如果有需要,我们会用exec()把当前进程映像替换成新的进程文件,完成自己想要实现的功能。 参考资料:

Linux进程基础: www.cnblogs.com/vamei/archi… Linux写时拷贝技术(copy-on-write)www.cnblogs.com/biyeymyhjob… 当你在 Linux 上启动一个进程时会发生什么?zhuanlan.zhihu.com/p/33159508 Linux fork()所谓的写时复制(COW)到最后还是要先复制再写吗?www.zhihu.com/question/26… 写时拷贝 (copy-on-write) COW技术blog.csdn.net/u012333003/… Copy-On-Write 写时复制原理blog.csdn.net/ppppppppp20… 二、解释一下Redis的COW 基于上面的基础,我们应该已经了解COW这么一项技术了。

下面我来说一下我对《Redis设计与实现》那段话的理解:

Redis在持久化时,如果是采用BGSAVE命令或者BGREWRITEAOF的方式,那Redis会fork出一个子进程来读取数据,从而写到磁盘中。 总体来看,Redis还是读操作比较多。如果子进程存在期间,发生了大量的写操作,那可能就会出现很多的分页错误(页异常中断page-fault),这样就得耗费不少性能在复制上。 而在rehash阶段上,写操作是无法避免的。所以Redis在fork出子进程之后,将负载因子阈值提高,尽量减少写操作,避免不必要的内存写入操作,最大限度地节约内存。 参考资料:

fork()后copy on write的一些特性: zhoujianshi.github.io/articles/20… 写时复制: miao1007.github.io/gitbook/jav… 三、文件系统的COW 下面来看看文件系统中的COW是啥意思:

Copy-on-write在对数据进行修改的时候,不会直接在原来的数据位置上进行操作,而是重新找个位置修改,这样的好处是一旦系统突然断电,重启之后不需要做Fsck。好处就是能保证数据的完整性,掉电的话容易恢复。

比如说: 要修改数据块A的内容,先把A读出来,写到B块里面去。如果这时候断电了,原来A的内容还在! 参考资料:

文件系统中的 copy-on-write 模式有什么具体的好处?www.zhihu.com/question/19… 新一代 Linux 文件系统 btrfs 简介:www.ibm.com/developerwo… 最后 最后我们再来看一下写时复制的思想(摘录自维基百科):

写入时复制 (英语: Copy-on-write,简称COW) 是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者 (callers) 同时请求相同资源 (如内存或磁盘上的数据存储) ,他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本 (private copy) 给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的 (transparently) 。此作法主要的优点是如果调用者没有修改该资源,就不会有副本 (private copy) 被建立,因此多个调用者只是读取操作时可以共享同一份资源。

至少从本文我们可以总结出:

Linux通过Copy On Write技术极大地减少了Fork的开销。 文件系统通过Copy On Write技术一定程度上保证数据的完整性。 其实在Java里边,也有Copy On Write技术。

Java中的COW 这部分留到下一篇来说,敬请期待~

如果大家有更好的理解方式或者文章有错误的地方还请大家不吝在评论区留言,大家互相学习交流~~~

参考资料:

写时复制,写时拷贝,写时分裂,Copy on write: my.oschina.net/dubenju/blo… 不会产奶的COW(Copy-On-Write)www.jianshu.com/p/b2fb2ee5e…


https://juejin.cn/post/6844903702373859335

“CPU 执行程序”

前言 代码写了那么多,你知道 a = 1 + 2 这条代码是怎么被 CPU 执行的吗?

软件用了那么多,你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?

CPU 看了那么多,我们都知道 CPU 通常分为 32 位和 64 位,你知道 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?

不知道也不用慌张,接下来就循序渐进的、一层一层的攻破这些问题。

正文 图灵机的工作方式 要想知道程序执行的原理,我们可以先从「图灵机」说起,图灵的基本思想是用机器来模拟人们用纸笔进行数学运算的过程,而且还定义了计算机由哪些部分组成,程序又是如何执行的。

图灵机长什么样子呢?你从下图可以看到图灵机的实际样子:

图来源自: http://www.kristergustafsson.me/turing-machine/

图灵机的基本组成如下:

有一条「纸带」,纸带由一个个连续的格子组成,每个格子可以写入字符,纸带就好比内存,而纸带上的格子的字符就好比内存中的数据或程序; 有一个「读写头」,读写头可以读取纸带上任意格子的字符,也可以把字符写入到纸带的格子; 读写头上有一些部件,比如存储单元、控制单元以及运算单元:

  1. 存储单元用于存放数据;
  2. 控制单元用于识别字符是数据还是指令,以及控制程序的流程等;
  3. 运算单元用于执行运算指令; 知道了图灵机的组成后,我们以简单数学运算的 1 + 2 作为例子,来看看它是怎么执行这行代码的。

首先,用读写头把 「1、2、+」这 3 个字符分别写入到纸带上的 3 个格子,然后读写头先停在 1 字符对应的格子上;

接着,读写头读入 1 到存储设备中,这个存储设备称为图灵机的状态;

然后读写头向右移动一个格,用同样的方式把 2 读入到图灵机的状态,于是现在图灵机的状态中存储着两个连续的数字, 1 和 2;

读写头再往右移动一个格,就会碰到 + 号,读写头读到 + 号后,将 + 号传输给「控制单元」,控制单元发现是一个 + 号而不是数字,所以没有存入到状态中,因为 + 号是运算符指令,作用是加和目前的状态,于是通知「运算单元」工作。运算单元收到要加和状态中的值的通知后,就会把状态中的 1 和 2 读入并计算,再将计算的结果 3 存放到状态中;

最后,运算单元将结果返回给控制单元,控制单元将结果传输给读写头,读写头向右移动,把结果 3 写入到纸带的格子中;

通过上面的图灵机计算 1 + 2 的过程,可以发现图灵机主要功能就是读取纸带格子中的内容,然后交给控制单元识别字符是数字还是运算符指令,如果是数字则存入到图灵机状态中,如果是运算符,则通知运算符单元读取状态中的数值进行计算,计算结果最终返回给读写头,读写头把结果写入到纸带的格子中。

事实上,图灵机这个看起来很简单的工作方式,和我们今天的计算机是基本一样的。接下来,我们一同再看看当今计算机的组成以及工作方式。

冯诺依曼模型 在 1945 年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储,还定义计算机基本结构为 5 个部分,分别是中央处理器 (CPU) 、内存、输入设备、输出设备、总线。

这 5 个部分也被称为冯诺依曼模型,接下来看看这 5 个部分的具体作用。

内存 我们的程序和数据都是存储在内存,存储的区域是线性的。

数据存储的单位是一个二进制位 (bit) ,即 0 或 1。最小的存储单位是字节 (byte) ,1 字节等于 8 位。

内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。

中央处理器 中央处理器也就是我们常说的 CPU,32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据:

32 位 CPU 一次可以计算 4 个字节; 64 位 CPU 一次可以计算 8 个字节; 这里的 32 位和 64 位,通常称为 CPU 的位宽。

之所以 CPU 要这样设计,是为了能计算更大的数值,如果是 8 位的 CPU,那么一次只能计算 1 个字节 0~255 范围内的数值,这样就无法一次完成计算 10000 * 500 ,于是为了能一次计算大数的运算,CPU 需要支持多个 byte 一起计算,所以 CPU 位宽越大,可以计算的数值就越大,比如说 32 位 CPU 能计算的最大整数是 4294967295。

CPU 内部还有一些组件,常见的有寄存器、控制单元和逻辑运算单元等。其中,控制单元负责控制 CPU 工作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能又不尽相同。

CPU 中的寄存器主要作用是存储计算时的数据,你可能好奇为什么有了内存还需要寄存器?原因很简单,因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会很快。

常见的寄存器种类:

通用寄存器,用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。 程序计数器,用来存储 CPU 要执行下一条指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令的地址。 指令寄存器,用来存放程序计数器指向的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。 总线 总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:

地址总线,用于指定 CPU 将要操作的内存地址; 数据总线,用于读写内存的数据; 控制总线,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线; 当 CPU 要读写内存数据的时候,一般需要通过两个总线:

首先要通过「地址总线」来指定内存的地址; 再通过「数据总线」来传输数据; 输入、输出设备 输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。

线路位宽与 CPU 位宽 数据是如何通过地址总线传输的呢?其实是通过操作电压,低电压表示 0,高压电压则表示 1。

如果构造了高低高这样的信号,其实就是 101 二进制数据,十进制则表示 5,如果只有一条线路,就意味着每次只能传递 1 bit 的数据,即 0 或 1,那么传输 101 这个数据,就需要 3 次才能传输完成,这样的效率非常低。

这样一位一位传输的方式,称为串行,下一个 bit 必须等待上一个 bit 传输完成才能进行传输。当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。

为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。CPU 要想操作的内存地址就需要地址总线,如果地址总线只有 1 条,那每次只能表示 「0 或 1」这两种情况,所以 CPU 一次只能操作 2 个内存地址;如果想要 CPU 操作 4G 的内存,那么就需要 32 条地址总线,因为 2 ^ 32 = 4G。

知道了线路位宽的意义后,我们再来看看 CPU 位宽。

CPU 的位宽最好不要小于线路位宽,比如 32 位 CPU 控制 40 位宽的地址总线和数据总线的话,工作起来就会非常复杂且麻烦,所以 32 位的 CPU 最好和 32 位宽的线路搭配,因为 32 位 CPU 一次最多只能操作 32 位宽的地址总线和数据总线。

如果用 32 位 CPU 去加和两个 64 位大小的数字,就需要把这 2 个 64 位的数字分成 2 个低位 32 位数字和 2 个高位 32 位数字来计算,先加个两个低位的 32 位数字,算出进位,然后加和两个高位的 32 位数字,最后再加上进位,就能算出结果了,可以发现 32 位 CPU 并不能一次性计算出加和两个 64 位数字的结果。

对于 64 位 CPU 就可以一次性算出加和两个 64 位数字的结果,因为 64 位 CPU 可以一次读入 64 位的数字,并且 64 位 CPU 内部的逻辑运算单元也支持 64 位数字的计算。

但是并不代表 64 位 CPU 性能比 32 位 CPU 高很多,很少应用需要算超过 32 位的数字,所以如果计算的数额不超过 32 位数字的情况下,32 位和 64 位 CPU 之间没什么区别的,只有当计算超过 32 位数字的情况下,64 位的优势才能体现出来。

另外,32 位 CPU 最大只能操作 4GB 内存,就算你装了 8 GB 内存条,也没用。而 64 位 CPU 寻址范围则很大,理论最大的寻址空间为 2^64。

程序执行的基本过程 在前面,我们知道了程序在图灵机的执行过程,接下来我们来看看程序在冯诺依曼模型上是怎么执行的。

程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。

那 CPU 执行程序的过程如下:

第一步,CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。 第二步,CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行; 第三步,CPU 执行完指令后,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4; 简单总结一下就是,一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。

CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为 CPU 的指令周期。

a = 1 + 2 执行具体过程 知道了基本的程序执行过程后,接下来用 a = 1 + 2 的作为例子,进一步分析该程序在冯诺伊曼模型的执行过程。

CPU 是不认识 a = 1 + 2 这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成汇编语言的程序,这个过程称为编译成汇编代码。

针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,这一条条机器码,就是一条条的计算机指令,这个才是 CPU 能够真正认识的东西。

下面来看看 a = 1 + 2 在 32 位 CPU 的执行过程。

程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置:

数据 1 被存放到 0x100 位置; 数据 2 被存放到 0x104 位置; 注意,数据和指令是分开区域存放的,存放指令区域的地方称为「正文段」。

编译器会把 a = 1 + 2 翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x200 ~ 0x20c 的区域中:

0x200 的内容是 load 指令将 0x100 地址中的数据 1 装入到寄存器 R0; 0x204 的内容是 load 指令将 0x104 地址中的数据 2 装入到寄存器 R1; 0x208 的内容是 add 指令将寄存器 R0 和 R1 的数据相加,并把结果存放到寄存器 R2; 0x20c 的内容是 store 指令将寄存器 R2 中的数据存回数据段中的 0x108 地址中,这个地址也就是变量 a 内存中的地址; 编译完成后,具体执行程序的时候,程序计数器会被设置为 0x200 地址,然后依次执行这 4 条指令。

上面的例子中,由于是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔 4 个字节。

而数据的大小是根据你在程序中指定的变量类型,比如 int 类型的数据则占 4 个字节,char 类型的数据则占 1 个字节。

指令 上面的例子中,图中指令的内容我写的是简易的汇编代码,目的是为了方便理解指令的具体内容,事实上指令的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。

不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码,接下来选用最简单的 MIPS 指集,来看看机器码是如何生成的,这样也能明白二进制的机器码的具体含义。

MIPS 的指令是一个 32 位的整数,高 6 位代表着操作码,表示这条指令是一条什么样的指令,剩下的 26 位不同指令类型所表示的内容也就不相同,主要有三种类型R、I 和 J。

一起具体看看这三种类型的含义:

R 指令,用在算术和逻辑操作,里面由读取和写入数据的寄存器地址。如果是逻辑位移操作,后面还有位移操作的「位移量」,而最后的「功能码」则是再前面的操作码不够的时候,扩展操作码来表示对应的具体指令的; I 指令,用在数据传输、条件分支等。这个类型的指令,就没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或一个常数; J 指令,用在跳转,高 6 位之外的 26 位都是一个跳转后的地址; 接下来,我们把前面例子的这条指令: 「add 指令将寄存器 R0 和 R1 的数据相加,并把结果放入到 R3」,翻译成机器码。

加和运算 add 指令是属于 R 指令类型:

add 对应的 MIPS 指令里操作码是 000000,以及最末尾的功能码是 100000,这些数值都是固定的,查一下 MIPS 指令集的手册就能知道的; rs 代表第一个寄存器 R0 的编号,即 00000; rt 代表第二个寄存器 R1 的编号,即 00001; rd 代表目标的临时寄存器 R2 的编号,即 00010; 因为不是位移操作,所以位移量是 00000 把上面这些数字拼在一起就是一条 32 位的 MIPS 加法指令了,那么用 16 进制表示的机器码则是 0x00011020。

编译器在编译程序的时候,会构造指令,这个过程叫做指令的编码。CPU 执行程序的时候,就会解析指令,这个过程叫作指令的解码。

现代大多数 CPU 都使用来流水线的方式来执行指令,所谓的流水线就是把一个任务拆分成多个小任务,于是一条指令通常分为 4 个阶段,称为 4 级流水线,如下图:

四个阶段的具体含义:

CPU 通过程序计数器读取对应内存地址的指令,这个部分称为 Fetch (取得指令) ; CPU 对指令进行解码,这个部分称为 Decode (指令译码) ; CPU 执行指令,这个部分称为 Execution (执行指令) ; CPU 将计算结果存回寄存器或者将寄存器的值存入内存,这个部分称为 Store (数据回写) ; 上面这 4 个阶段,我们称为指令周期 (Instrution Cycle) ,CPU 的工作就是一个周期接着一个周期,周而复始。

事实上,不同的阶段其实是由计算机中的不同组件完成的:

取指令的阶段,我们的指令是存放在存储器里的,实际上,通过程序计数器和指令寄存器取出指令的过程,是由控制器操作的; 指令的译码过程,也是由控制器进行的; 指令执行的过程,无论是进行算术操作、逻辑操作,还是进行数据传输、条件分支操作,都是由算术逻辑单元操作的,也就是由运算器处理的。但是如果是一个简单的无条件地址跳转,则是直接在控制器里面完成的,不需要用到运算器。 指令的类型 指令从功能角度划分,可以分为 5 大类:

数据传输类型的指令,比如 store/load 是寄存器与内存间数据传输的指令,mov 是将一个内存地址的数据移动到另一个内存地址的指令; 运算类型的指令,比如加减乘除、位运算、比较大小等等,它们最多只能处理两个寄存器中的数据; 跳转类型的指令,通过修改程序计数器的值来达到跳转执行指令的过程,比如编程中常见的 if-else、swtich-case、函数调用等。 信号类型的指令,比如发生中断的指令 trap; 闲置类型的指令,比如指令 nop,执行后 CPU 会空转一个周期; 指令的执行速度 CPU 的硬件参数都会有 GHz 这个参数,比如一个 1 GHz 的 CPU,指的是时钟频率是 1 G,代表着 1 秒会产生 1G 次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。

对于 CPU 来说,在一个时钟周期内,CPU 仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。

一个时钟周期一定能执行完一条指令吗?答案是不一定的,大多数指令不能在一个时钟周期完成,通常需要若干个时钟周期。不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条 CPU 指令,但是乘法需要的时钟周期就要比加法多。

如何让程序跑的更快?

程序执行的时候,耗费的 CPU 时间少就说明程序是快的,对于程序的 CPU 执行时间,我们可以拆解成 CPU 时钟周期数 (CPU Cycles) 和时钟周期时间 (Clock Cycle Time) 的乘积。

时钟周期时间就是我们前面提及的 CPU 主频,主频越高说明 CPU 的工作速度就越快,比如我手头上的电脑的 CPU 是 2.4 GHz 四核 Intel Core i5,这里的 2.4 GHz 就是电脑的主频,时钟周期时间就是 1/2.4G。

要想 CPU 跑的更快,自然缩短时钟周期时间,也就是提升 CPU 主频,但是今非彼日,摩尔定律早已失效,当今的 CPU 主频已经很难再做到翻倍的效果了。

另外,换一个更好的 CPU,这个也是我们软件工程师控制不了的事情,我们应该把目光放到另外一个乘法因子 —— CPU 时钟周期数,如果能减少程序所需的 CPU 时钟周期数量,一样也是能提升程序的性能的。

对于 CPU 时钟周期数我们可以进一步拆解成: 「指令数 x 每条指令的平均时钟周期数 (Cycles Per Instruction,简称 CPI) 」,于是程序的 CPU 执行时间的公式可变成如下:

因此,要想程序跑的更快,优化这三者即可:

指令数,表示执行程序所需要多少条指令,以及哪些指令。这个层面是基本靠编译器来优化,毕竟同样的代码,在不同的编译器,编译出来的计算机指令会有各种不同的表示方式。 每条指令的平均时钟周期数 CPI,表示一条指令需要多少个时钟周期数,现代大多数 CPU 通过流水线技术 (Pipline) ,让一条指令需要的 CPU 时钟周期数尽可能的少; 时钟周期时间,表示计算机主频,取决于计算机硬件。有的 CPU 支持超频技术,打开了超频意味着把 CPU 内部的时钟给调快了,于是 CPU 工作速度就变快了,但是也是有代价的,CPU 跑的越快,散热的压力就会越大,CPU 会很容易奔溃。 很多厂商为了跑分而跑分,基本都是在这三个方面入手的哦,特别是超频这一块。

总结 最后我们再来回答开头的问题。

64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?

64 位相比 32 位 CPU 的优势主要体现在两个方面:

64 位 CPU 可以一次计算超过 32 位的数字,而 32 位 CPU 如果要计算超过 32 位的数字,要分多步骤进行计算,效率就没那么高,但是大部分应用程序很少会计算那么大的数字,所以只有运算大数字的时候,64 位 CPU 的优势才能体现出来,否则和 32 位 CPU 的计算性能相差不大。 64 位 CPU 可以寻址更大的内存空间,32 位 CPU 最大的寻址地址是 4G,即使你加了 8G 大小的内存,也还是只能寻址到 4G,而 64 位 CPU 最大寻址地址是 2^64,远超于 32 位 CPU 最大寻址地址的 2^32。 你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?

64 位和 32 位软件,实际上代表指令是 64 位还是 32 位的:

如果 32 位指令在 64 位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是如果 64 位指令在 32 位机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令; 操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位,比如 64 位操作系统,指令也就是 64 位,因此不能装在 32 位机器上。 总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽。


https://cloud.tencent.com/developer/article/1729507