跬步 On Coding

编程语言漫谈

半年前还是2021年春节的时候, 在家休假的我, 在B站上发现了一门叫RUST语言的课程, 学习的过程中, 发现RUST语言为了绝对的安全性, 在语法本身上做了很多的妥协, 所以想着等我学完这门课, 再基于自己以往编程语言的学习经历, 写一篇<如何学习一门新的编程语言>的文章. 但是时过境迁, 我并没有学完这门RUST课, 所以<如何学习一门新的编程语言>也就无疾而终了. 回过头来再思考下以往我学习的那些编程语言, 就有了这篇文章<编程语言漫谈>. 我希望以一种比较轻松的散文的形式来阐述我过完学习的一些经验与思考.

rust

1. 程序是如何运行的

关于程序是如何运行的是一个很大的话题, 我个人认为最好的教材是这本深入理解计算机系统, 值得每个程序员深入的阅读, 这里我就不再对这本书的内容做详细的总结, 只提2个点, CPU与内存.

CPU

preview

说到CPU就绕不过冯诺依曼结构, 在这个体系中, CPU就是一个非常傻的元器件, 它只做3件事:

  1. 获取输入指令
  2. 执行指令
  3. 输出结果

然后循环做以上三件事, 只要不断电, 永不停歇.

img

1.取指令阶段

取指令(Instruction Fetch,IF)阶段是将一条指令从主存中取到指令寄存器的过程。

程序计数器PC中的数值,用来指示当前指令在主存中的位置。当一条指令被取出后,PC中的数值将根据指令字长度而自动递增。若为单字长指令,则(PC)+1PC,若为双字长指令,则(PC)+2PC,依此类推。

2.指令译码阶段

取出指令后,计算机立即进入指令译码(Instruction Decode,ID)阶段。

在指令译码阶段,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别和区分出不同的指令类别及各种获取操作数的方法。

在组合逻辑控制的计算机中,指令译码器对不同的指令操作码产生不同的控制电位,以形成不同的微操作序列;在微程序控制的计算机中,指令译码器用指令操作码找到执行该指令的微程序的入口,并从此入口开始执行。

在传统的设计里,CPU中负责指令译码的部分是无法改变的硬件。不过,在众多运用微程序控制技术的新型CPU中,微程序有时是可重写的,可以通过修改成品CPU来改变CPU的译码方式。

3.执行指令阶段

在取指令和指令译码阶段之后,接着进入执行指令(Execute,EX)阶段。

此阶段的任务是完成指令所规定的各种操作,具体实现指令的功能。为此,CPU的不同部分被连接起来,以执行所需的操作。

例如,如果要求完成一个加法运算,算术逻辑单元(ALU)将被连接到一组输入和一组输出,输入端提供需要相加的数值,而输出端将含有最后的运算结果。

4.访存取数阶段

根据指令需要,有可能要访问主存,读取操作数,这样就进入了访存取数(Memory,MEM)阶段。

此阶段的任务是:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算。

5.结果写回阶段

作为最后一个阶段,结果写回(Writeback,WB)阶段把执行指令阶段的运行结果数据“写回”到某种存储形式。结果数据经常被写到CPU的内部寄存器中,以便被后续的指令快速地存取。在有些情况下,结果数据也可被写入相对较慢、但较廉价且容量较大的主存。许多指令还会改变程序状态字寄存器中标志位的状态,这些标志位标识着不同的操作结果,可被用来影响程序的动作。

在指令执行完毕、结果数据写回之后,若无意外事件(如结果溢出等)发生,计算机就接着从程序计数器PC中取得下一条指令地址,开始新一轮的循环,下一个指令周期将正常地顺序取出下一条指令。

许多新型CPU可以同时取出、译码和执行多条指令,体现出并行处理的特性。

以上内容引用至CPU的工作过程, 虽然有5个阶段, 但是仔细看现代CPU还是脱离不了冯诺依曼体系结构, 仍然是在循环着取指令, 执行指令, 输出结果的过程.

内存

从以上CPU结构, 我们可以看出无论是取指令还是输出结果, CPU都依赖于总线与外部存储, 也就是内存做交互. 那么程序是如何使用内存的呢, 这里以Linux的程序内存布局为例来说明.

memory

在Linux系统中, 每个程序在运行时, 都会由操作系统将自己的可执行二进制文件载入到内存中, 通过虚拟内存地址空间, 每个程序看到的自己的内存高地址与低地址都是一样的, 其中用于运行函数本地变量的地址空间叫栈, 而需要程序自行管理内存的空间叫堆. 每个函数运行时都会在栈上创建一块栈帧, 函数返回时pop出栈帧, 空间被回收. 在堆上的内存分配则需要手动管理, 程序自行申请空间, 回收空间.

汇编

汇编语言(英语:assembly language)[注 1][1]是任何一种用于电子计算机微处理器微控制器,或其他可编程器件的低级语言。在不同的设备中,汇编语言对应着不同的机器语言指令集。一种汇编语言专用于某种计算机系统结构,而不像许多高级语言,可以在不同系统平台之间移植。

使用汇编语言编写的源代码,然后通过相应的汇编程序将它们转换成可执行的机器代码。这一过程被称为汇编过程

汇编语言使用助记符(Mnemonics)来代替和表示特定低级机器语言的操作。特定的汇编目标指令集可能会包括特定的操作数。许多汇编程序可以识别代表地址和常量的标签(Label)和符号(Symbols),这样就可以用字符来代表操作数而无需采取写死的方式。普遍地说,每一种特定的汇编语言和其特定的机器语言指令集是一一对应的。

许多汇编程序为程序开发、汇编控制、辅助调试提供了额外的支持机制。有的汇编语言编写工具经常会提供,它们也被称为宏汇编器。

现在汇编语言已不像其他大多数的程序设计语言一样被广泛用于程序设计,在今天的实际应用中,它通常被应用在底层硬件操作和高要求的程序优化的场合。驱动程序、嵌入式操作系统和实时运行程序中都会需要汇编语言。

以上是维基百科对于汇编的解释, 简单的理解汇编就是对于特定的硬件的指令的一种符号表示, 每个汇编指令与二进制的CPU指令机器码是一一对应的, 是为了避免让程序员直接写二进制程序被发明出来的一种便捷方式. 从汇编语言转换成可执行的二进制文件需要执行汇编过程.

直接写汇编也不方便, 需要程序员了解整个CPU的结构, 了解所有CPU提供的指令, 了解每个寄存器的作用, 了解内存中每个地址该存什么, 而程序员通常并不想详细了解这些. 那怎么办呢:

遇到问题不会解决的时候,多加一层就可以解决

这是计算机领域的一条金句, 为了解决汇编的上述问题, 通过抽象一门新的编程语言, 向程序员屏蔽掉以上细节, 可以大大的提高程序员的生产效率, 下面将介绍C语言来如何解决汇编带来的问题.

2. C语言

C语言编译

C语言是计算机世界的基石级语言, 它通过抽象的函数, 循环, 分支语句使得程序员不需要了解计算中的底层实现细节, 向程序员屏蔽了不同计算机指令架构的差异, 通过不同的编译器实现, 只需要写一份代码就可以在多个不同指令架构的机器上执行相同的逻辑.

在Google有很多关于 Jeff Dean的笑话, 其中有一个关于编译器的:

Jeff Dean每次写完代码, 都会编译一遍, 不是为了检查自己写的程序是否正确, 而是检查编译器是否有BUG

C语言并不是一门完美的语言, 写C语言的程序员往往关注程序的性能, 这就需要程序员了解性能的瓶颈在哪里, 往往又需要了解到程序的每一条指令的执行细节, 这就造成了一个矛盾的点, C语言本身是想向程序员屏蔽计算机的实现细节, 但是想要用C语言写好程序又需要程序员充分了解C语言编译生成的每条汇编的细节.

同时C语言使用手动管理内存的方式, 或多或少会制造出各种内存的安全性问题, 比如空指针, 野指针等等问题, 对于程序员来说, 应付这些问题并不轻松.

随着计算机硬件性能的大幅提升, 使得程序员可以不再那么多的关注程序本身的运行性能, 更多的关注程序的逻辑实现, 互联网的兴起, 又导致IO密集的应用占比大幅提高, 为了解放生产力, 需要更高层次的语言, Python就是其中的一种.

3. Python语言

问: Python语言是解释型语言还是编译型语言?

Python语言既是解释型语言又是编译型语言, 下面通过解释Python的运行过程来进一步说明

执行过程

Python执行过程

  1. 启动编译

Python在启动时首先加载所有的.py文件并编译成字节码.pyc文件, pyc文件保存的Python代码编译出来的PyCode对象, 所谓的字节码到底是什么东西呢, 其实是一系列的指令, 这些指令不同于机器码的指令集, 它是Python自己定义指令集, 运行在Python VM上

  1. 虚拟机翻译

Python虚拟机就是用来执行Python虚拟指令的虚拟机, VM与实体CPU类似, 通过一个大循环, 不停的执行PyCode中的指令, 与实体CPU不同的是, 它是一个中间层, 它会把Python的指令翻译成对应的C代码的调用, 最终通过CPython解释器来完成Python指令到CPU指令的翻译

  1. 机器码执行

我们所写的Python代码最终还是会被CPython解释成机器码到CPU上执行, 这里可以思考一下, 增加了CPython这一层后, 带来的好处, 以及坏处是什么?

好处很明显, Python相对于C语言大大降低了编程的门槛, 实现同样的功能, Python代码量会比C代码少的多. 从上面的执行过程可以看出不同于C语言的可执行二进制直接加载到机器的虚拟内存空间中, Python代码需要启动时的编译过程, 执行时的VM翻译过程, 最终转换成机器码执行, 无论是VM的翻译过程, 还是最终执行机器码, 相对于C实现的机器指令的数量都会大大增加, 这个增加是千百倍的增加, 带来的差异就是同样的功能, Python的实现会比C实现慢得多.

自动内存管理(GC)

上面说完了Python的代码执行, 接下来进入内存管理, 首先问一个问题, Python对象是分配在堆还是栈上的?

在 Python 中,内存管理涉及到一个包含所有 Python 对象和数据结构的私有堆(heap)。这个私有堆的管理由内部的 Python 内存管理器(Python memory manager) 保证。Python 内存管理器有不同的组件来处理各种动态存储管理方面的问题,如共享、分割、预分配或缓存。

在最底层,一个原始内存分配器通过与操作系统的内存管理器交互,确保私有堆中有足够的空间来存储所有与 Python 相关的数据。在原始内存分配器的基础上,几个对象特定的分配器在同一堆上运行,并根据每种对象类型的特点实现不同的内存管理策略。例如,整数对象在堆内的管理方式不同于字符串、元组或字典,因为整数需要不同的存储需求和速度与空间的权衡。因此,Python 内存管理器将一些工作分配给对象特定分配器,但确保后者在私有堆的范围内运行。

Python 堆内存的管理是由解释器来执行,用户对它没有控制权,即使他们经常操作指向堆内内存块的对象指针,理解这一点十分重要。Python 对象和其他内部缓冲区的堆空间分配是由 Python 内存管理器按需通过本文档中列出的 Python/C API 函数进行的。

垃圾回收
  1. 引用计数

最基本的垃圾回收方式, PyObject对象每增加一个引用, 引用计数+1, 减少一个引用, 引用计数-1, 引用计数更新为0时, 等待被回收, 但是循环引用的对象, 计数永远不会清零, 这就需要标记清楚来辅助处理

  1. 标记清除

python

Python解释器隔一段时间会进行一次全对象的扫描, 对于隔离于ROOT节点的孤立引用, 执行回收, 没有被ROOT节点引用的孤立节点, 即使引用计数大于0, 也会被回收.

但是每次执行扫描, 都会产生STW, 影响程序的执行, 所以就有了内存分代

  1. 分代回收

Python解释器将将对象分为三代:

  • 0代表幼年对象
  • 1代表青年对象
  • 2代表老年对象

对象所在分代级别越低, GC回收的频率越低, 低级别的对象如果一直没有回收, 会被转移到高级别的代中, 这样就区分了不同活跃度的对象, 提高了GC回收的效率

小结

从以上Python执行过程与内存管理中可以看出, Python并不适用于高性能需求的场景, 而GC带来延时也使Python不适用于实时性要求高的应用, 这些场景是C语言的主战场. 但是Python由于简单的语法, 高效的编码方式, 大大提高了程序员的生产效率, 在互联网时代, 要求快速实现, 快速实验的前提下, Python也越来越流行.

那么有没有一种性能够用, 又兼具开发效率的语言呢, Golang就提供了这样一种折中的选择.

4. Golang

Golang是一门编译型的语言, 同C语言一样它也是通过编译器把代码直接编译为二进制的机器码来保证执行性能, 与C语言不同的时, 它在编译时会在二进制中插入一个Golang的Runtime, Runtime帮助做用户态的Goroutine的调度, 做自动内存管理. 可以说Golang继承了C语言的精华, 并对C语言程序容易出错的点, 提出了自己的改进方案.

golang

Golang内存分配

Golang语言也不是完美的, 它的性能还是比不上极致优化的C语言, GC带来的STW也导致不能应用于实时性领域. 但是在云原生时代, 它的原生支持并发, 优秀的自动内存管理, 在开发效率与性能之间的平衡性, 给程序员提供了一个新的选择.

5. 总结

笔者主要使用的语言是Python与Golang, 对Python的运用更多一些, 原因是Python在Web后端开发的应用会更方便一些, 能够快速实现功能, 解决产品需求. Golang更多应用于一些对性能/并发要求比较高的场景, 当前我们会使用Golang构建的鉴权中间承载整个部门的健全需求.

没有银弹!!! 没有完美的技术, 没有完美的方案, 只有在某个时间点, 某个环境下合适的技术或方案, 如何在这些技术与方案中做抉择, 平衡各方需求, 是对程序员最大的挑战.