计算机程序与运行时

综述

程序想要运行起来就需要运行的环境,这个环境我们一般将他叫做运行时(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等等,有机会再写文章介绍下这些。

相关推荐
福大大架构师每日一题3 小时前
22.1 k8s不同role级别的服务发现
容器·kubernetes·服务发现
莹雨潇潇3 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
tangdou3690986554 小时前
1分钟搞懂K8S中的NodeSelector
云原生·容器·kubernetes
tangdou3690986557 小时前
Docker系列-5种方案超详细讲解docker数据存储持久化(volume,bind mounts,NFS等)
docker·容器
later_rql7 小时前
k8s-集群部署1
云原生·容器·kubernetes
大G哥12 小时前
记一次K8S 环境应用nginx stable-alpine 解析内部域名失败排查思路
运维·nginx·云原生·容器·kubernetes
大道归简13 小时前
Docker 命令从入门到入门:从 Windows 到容器的完美类比
windows·docker·容器
爱跑步的程序员~13 小时前
Docker
docker·容器
福大大架构师每日一题14 小时前
23.1 k8s监控中标签relabel的应用和原理
java·容器·kubernetes
程序那点事儿14 小时前
k8s 之动态创建pv失败(踩坑)
云原生·容器·kubernetes