计算机程序与运行时

综述

程序想要运行起来就需要运行的环境,这个环境我们一般将他叫做运行时(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的运行环境,或者叫运行时runtimeruntime本质就是要给程序提供运行的所有条件,例如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,一些依赖glibcgnu tool chain的linux又被叫做gnu linux

最为topic1的最后,我们来思考一个问题,既然说os或者说内核是一个c写的程序,那又是谁来加载的内核呢,谁作为了os的execve嘞?是写死在主板硬件中的Boot Loader,这也是一段程序做了类似execve类似的事情。

topic2 解释型语言的运行时

诸如shellpythonjavascript等文件,我们可以在文件的头部写类似#!/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执行的脚本描述罢了,如下图。

既然是解释型语言,所以很多地方总是拿javac++或者golang这些编译型语言比较性能,这是不公平的,java的优势显然不在纯的计算和执行性能。

但是java又确实有着很强的性能,尤其在leetcode的运行耗时中,我们发现java并不会比c++差太多。这主要是JIT(Just In time Compilation)即时编译带来的好处,即时编译通俗讲,就是程序启动后,一开始代码都是解释运行的,但是JIT的机制会自动侦测"热"代码,将这部分代码在运行阶段编译成机器码,这样后续这部分代码都是编译运行而非解释运行。JIT机制可以说是使得解释型语言兼顾了性能和跨平台的灵活性,javaLuav8引擎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++RustGolang等都是这种形式,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 GroupNamespace进行资源分组与控制,即所有容器其实共用了宿主机的内核,自身只提供了库文件和应用程序文件。如下面俩图(一个docker官方介绍,一个是微软的容器介绍):

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

在云原生时代,像java这种编程语言的优势已经逐渐丧失,docker出来之后最新涌现的编程语言也很少有像java这种的带有vm的解释型语言了。更多的是像zigrust这种面向底层和更高性能的语言,稍微有点像的julia瞄准的则是科学计算、数据分析领域了。java能坚挺住,更多的靠的还是目前的生态和开发者现状。

容器其实也有一些缺点,比如启动还是稍微慢了点,尤其是对于faas场景秒级的启动速度已经算慢了,另外容器的隔离性较差,共用内核容易出现老鼠屎的问题。特定的场景下也可能会用到像webassembly (wasm)runtime这种运行时,但是wasm的底层后端规范wasi还没有完全制定完毕,但是在CNCF中已经看到很多项目了wasmEdgewasmer等等,有机会再写文章介绍下这些。

相关推荐
极简网络科技42 分钟前
Docker、Wsl 打包迁移环境
运维·docker·容器
江湖有缘1 小时前
【Docker管理工具】部署Docker可视化管理面板Dpanel
运维·docker·容器
猫咪老师19953 小时前
多系统一键打包docker compose下所有镜像并且使用
java·docker·容器
Nazi63 小时前
docker数据管理
运维·docker·容器
孔令飞5 小时前
Go 为何天生适合云原生?
ai·云原生·容器·golang·kubernetes
Altairr6 小时前
Docker基础(二)
运维·docker·容器
藥瓿亭8 小时前
K8S认证|CKS题库+答案| 5.日志审计
linux·运维·docker·云原生·容器·kubernetes·cka
David爱编程8 小时前
Docker 存储卷详解:数据持久化的正确打开方式
后端·docker·容器
藥瓿锻9 小时前
2024 CKA题库+详尽解析| 15、备份还原Etcd
linux·运维·数据库·docker·容器·kubernetes·cka
zyjyyds11311 小时前
win11系统 Docker Desktop 突然提示Docker Engine stopped解决情况之一
运维·docker·容器