综述
程序想要运行起来就需要运行的环境,这个环境我们一般将他叫做运行时(runtime)。
在很多编程语言中我们都听过runtime的概念,例如java的runtime又被叫做jre,js有个叫v8的runtime;在faas云服务中,上传代码选择对应语言版本的runtime就能够运行;
似乎源码code
+运行时runtime
就能够构成我们程序启动的状态机了。
本文我们从几个topic展开讨论来探索不同编程语言和运行模式下,运行时所扮演的角色。
topic1 C的运行时--c/os/libc
c
是最特殊的编程语言,与其他高级语言不同,c是万物根基,因为操作系统(os)就是c
写的一个程序(注:本文中操作系统特指linux)。
天然的,os
就是c
的运行环境,或者叫运行时runtime
,runtime
本质就是要给程序提供运行的所有条件,例如c
的程序运行在os中几乎不需要任何条件,很容易的就能够将c
的代码编译->汇编->链接得到机器码,启动时os就能根据ABI
将不同的段还有栈的内存给初始化,然后将pc(程序计数器)指向入口函数,程序的执行就开始了。(这是execve系统调用实现的)
但是,其实c也不是完全不需要环境,入参环境变量等则由_start
函数来进行简单的处理,并最后调用main
函数,而_start
才是真正的入口函数。如下,我们用readelf
观察可执行文件hello
的文件头,入口函数的地址是0x640
,通过nm
查看改地址的符号对应的是_start
。

类似的,如果我们把入口函数指向main
,程序可能无法正常启动。

那我们可以说提供_start
函数的库,就是c的runtime
,事实也确实如此,这个符号通过crt1.o
目标文件提供,还有另外几个crt
为前缀的目标文件,都做着初始化相关的工作,crt也就是c runtime缩写,只不过这个runtime做的工作很简单。

此外,libc
也尝尝被人称为是c
的runtime,这种说法不算准确,因为我们可以不依赖libc
来写程序,如果要脱离libc
写一个hello程序也是可以的,如下
c
// 注意:这是在 Linux x86_64 架构下的示例
#define SYS_write 1 // 这是write系统调用的代号
#define SYS_exit 60 // 这是exit系统调用的代号
// 定义 _start 函数,这是执行时的程序入口点
void _start() {
// 要写入的消息
const char message[] = "Hello, World!\n";
// 消息长度
unsigned long length = sizeof(message) - 1;
// 使用内联汇编进行系统调用
// syscall(SYS_write, STDOUT_FILENO, message, length)
__asm__("movq $1, %%rax\n\t" // 系统调用号 SYS_write
"movq $1, %%rdi\n\t" // 文件描述符 STDOUT_FILENO
"movq %0, %%rsi\n\t" // 消息缓冲区的地址
"movq %1, %%rdx\n\t" // 消息的长度
"syscall\n\t"
:
: "r"(message), "r"(length)
: "%rax", "%rdi", "%rsi", "%rdx");
// 使用内联汇编执行退出系统调用
// syscall(SYS_exit, 0)
__asm__("movq $60, %%rax\n\t" // 系统调用号 SYS_exit
"xor %%rdi, %%rdi\n\t" // Exit status 0
"syscall"
:
:
: "%rax", "%rdi");
}
通过以下指令编译
bash
$ gcc -static -nostdlib -nostartfiles -o mini mini.c
$ ./mini
Hello, World!
要知道内核和我们运行的程序是两个独立的程序,而作为独裁者内核控死了所有的外部资源的访问权限,我们的程序要想与外界交互,就必须通过内核,而唯一的方式就是系统调用,可以说没有系统调用我们的程序几乎什么都做不了,而系统调用都被封装到了libc
库。所以c的标准库libc
也通常被列为c的runtime的一员。如下,open
read
epoll_xx
等都是系统调用

libc
提供了包含内核系统调用、posix规范的api、还有一些封装好的库函数。gnu libc
或者叫glibc
是最常见的libc
实现方案,近些年代码简洁、功能强大的musl libc
也逐渐受到追捧,alpine
镜像中使用的musl libc
,os程序本身也依赖libc
,一些依赖glibc
和gnu tool chain
的linux又被叫做gnu linux
。
最为topic1的最后,我们来思考一个问题,既然说os或者说内核是一个c写的程序,那又是谁来加载的内核呢,谁作为了os的execve嘞?是写死在主板硬件中的Boot Loader,这也是一段程序做了类似execve
类似的事情。
topic2 解释型语言的运行时
诸如shell
、python
、javascript
等文件,我们可以在文件的头部写类似#!/usr/bin/bash
这样的标注,系统就知道要用这个文件来运行当前脚本。这也是execve
系统调用在装载程序中判断并实现这样的行为的。
那么python a.py
运行python脚本的时候,运行时是如何支撑这个过程的呢?
操作系统不是python写的,本身没有必要提供python的运行时环境,当然有些发行版的linux预装了python环境,python
这个指令,它本身执行的时候就会调度起来一个虚拟机环境,并开始运行解释器,这个上下文环境就是python的runtime。这里就不得不提到另一个话题,编程语言只是一种表达,同一种编程语言可以用不同的runtime来运行,就想python的解释器也不止Cpython(用c写的python解释器)这一种,还有用java写的Jython
、还有PyPy
、以及近几年有浏览器wasm
中提供python运行时的。
同样的事情在js上也在发生,js作为一种非常简单易上手的语言,连续11年冠绝Stack Overflow的语言排行榜

js最早的runtime就是浏览器,直到nodejs出现是基于v8引擎进行了封装称为了一种后端运行时,其作者后来撤出代码维护并用rust重新写了一个还是基于v8但是组织形式上与node
有较大差异的deno
,近些年deno
的风头又被去年推出的bun
给盖过去这也是一个js的运行时但并不基于v8。当然v8
引擎本身就提供了很好的跨平台性,使得nodejs不再需要一个专门的虚拟机环境来运行。
有了这些例子,我们或许能更好的理解这样一件事了,语言就是语言,他只是一些语法,同一个代码文件可以在不同的runtime上运行(如下图1:终端、浏览器运行的js)。不同的运行时可能会提供略有区别的sdk
,比如node
中访问文件使用的库为fs
,而bun则使用全局变量Bun中的方法,如下图2


解释型语言都需要这样的解释器,而解释器可能是另一种脚本语言或Native语音(如c)写的,当然追本溯源最终都是可执行的ELF文件来驱动的解释,于是又回到了topic1.
topic3 java JIT与AOT
对于java,需要专门开一个小的section来讲,很多人认为java(包括其他jvm语言:scala kotlin等)是编译型语言,因为在开发的时候明明有个按钮是compile。但是其实本质上java是解释型语言,javac
只是将.java
后缀的文件"转换成"了.class
后缀,而后者并非机器码,只是更容易被jvm
执行的脚本描述罢了,如下图。

既然是解释型语言,所以很多地方总是拿java
和c++
或者golang
这些编译型语言比较性能,这是不公平的,java
的优势显然不在纯的计算和执行性能。
但是java
又确实有着很强的性能,尤其在leetcode
的运行耗时中,我们发现java
并不会比c++
差太多。这主要是JIT(Just In time Compilation)
即时编译带来的好处,即时编译通俗讲,就是程序启动后,一开始代码都是解释运行的,但是JIT
的机制会自动侦测"热"代码,将这部分代码在运行阶段编译成机器码,这样后续这部分代码都是编译运行而非解释运行。JIT
机制可以说是使得解释型语言兼顾了性能和跨平台的灵活性,java
、Lua
、v8引擎
、PyPy
以及PHP8
等,都引入了JIT
。
如下图是java和c++(llvm)的运行时环境对比,在JIT
部分的java也会生成Native
的机器码,但是其他部分还是靠解释器来运行。

但是JIT
也会带来一些问题,比如运行时的代码会变得复杂,运行效率存在不确定性,例如项目经常需要warm up
其实就是在预热JIT
,还有占用更多的内存。像python的官方runtime(cpython)就没有引入jit。
与JIT
相对应的另一种编译形式就是AOT(Ahead of Time Compilation)
也就是提前全部编译好,编译型语言C/C++
、Rust
、Golang
等都是这种形式,AOT
出道即巅峰,纯粹的机器码运行,不在需要运行时的编译,总体性能上高于JIT
,但JIT
经过学习和优化后,核心链路的性能与AOT
接近。AOT
的主要缺点就是Native
的运行文件,无法跨平台运行,此外对于超大型项目的编译时间非常长,有些项目可能需要本地configue make make install
,例如nginx
如果有些定制化功能的开启或关闭都需要走一遍这个过程。
java
在graalVM上也在进行着一些AOT
的尝试,目前还没有很多公司使用,主要是java很多框架比如spring使用了较多的字节码技术,这对于从JIT
迁移到AOT
提出了很多的挑战。

topic4 容器时代的运行时-docker
docker诞生后随即成为容器技术的代名词,现在几乎所有的大公司都在使用docker作为线上的运行时环境。java做的一件重要的事情是在每一种cpu架构的每一种操作系统,都提供一套jre
,你到官网下载安装后。一套代码(jar包)就可以在多套机器上运行,类比golang
需要对于每一种os每一种cpu架构都生成一个二进制可执行文件才行。而docker诞生后,时代变了,docker提供了一个进程级别的os虚拟,你可以编译不同os 不同libc 甚至是不同cpu架构(需要模拟器,不建议)的Native文件,都可以套一层docker engine来磨平所有的不兼容。
换句话说,我们写完jar包,能在不同的环境下跑,是因为中间套了一层"适配层"jre
;现在我们写完Native
的二进制包也可以在不同环境下跑了,因为中间套了一层"适配层"Docker Engine
。
这个进程级别的虚拟,并不像Hypervisor
类型的VM
,最大的不同就是所有的容器是通过宿主机Control Group
和Namespace
进行资源分组与控制,即所有容器其实共用了宿主机的内核,自身只提供了库文件和应用程序文件。如下面俩图(一个docker官方介绍,一个是微软的容器介绍):


比如你宿主机是ubuntu glibc 2.35,然后运行了一个alpine musl libc的镜像,上面运行了一个hello的本地程序。在容器内,直观感觉是运行在了alpine系统上,但是其实是Docker Engine
将其进行了翻译,最终调用了宿主机内核的系统调用,进行的打印。

在云原生时代,像java这种编程语言的优势已经逐渐丧失,docker出来之后最新涌现的编程语言也很少有像java这种的带有vm的解释型语言了。更多的是像zig
、rust
这种面向底层和更高性能的语言,稍微有点像的julia
瞄准的则是科学计算、数据分析领域了。java能坚挺住,更多的靠的还是目前的生态和开发者现状。
容器其实也有一些缺点,比如启动还是稍微慢了点,尤其是对于faas场景秒级的启动速度已经算慢了,另外容器的隔离性较差,共用内核容易出现老鼠屎的问题。特定的场景下也可能会用到像webassembly (wasm)runtime
这种运行时,但是wasm
的底层后端规范wasi还没有完全制定完毕,但是在CNCF中已经看到很多项目了wasmEdge
、wasmer
等等,有机会再写文章介绍下这些。