learn-tech/专栏/Serverless进阶实战课/07运行时(上):不同语言形态下的函数在容器中是如何执行的?.md
2024-10-16 06:37:41 +08:00

272 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
07 运行时(上):不同语言形态下的函数在容器中是如何执行的?
你好,我是静远。
在生命周期这节课里,我和你提到了支撑函数运行起来的核心之一是运行时,而且我们还可以在函数计算的平台上选择不同语言的运行时来开发我们的函数,这其实也是函数的一大优势:多语言的运行时,可以极大降低开发者的语言门槛。
那么函数计算层面的运行时是什么呢?不同语言的运行时,它们的工作机制是一样的么?如果让你来自定义一个运行时,你打算怎么做呢?
带着这些问题今天我们来聊聊这背后的实现。我会从源码的角度以编译型语言Golang、解释型语言Python为代表来跟你层层分析函数计算运行时的机制带你抽象出通用的思路并体验如何构建一个自定义运行时。
希望通过这两节课,你能够对运行时的原理和特性有一定的理解,搞清楚函数计算平台究竟是如何打破编程语言技术栈的限制,为开发人员提供多种开发环境的。同时,相信这节课也会帮助你在后续的函数计算使用和开发中更加得心应手。
今天这节课我将重点介绍运行时的基本特性和实现原理并以编译型语言Golang为切入点来讲解它的运行流程让你先有一个从0到1的认知过程。
运行时究竟是什么?
我们对“运行时”这个名词并不陌生任何编程语言都有它自己的运行时。比如Java的运行时叫Java Runtime它能够让机器读懂这些Java的代码并且运行起来换个说法就是它让代码可以和机器“打交道”进而实现你的业务逻辑。
那么迁移过来理解函数计算运行时Runtime就是能够让函数在机器或容器中执行起来实现业务逻辑功能的执行环境它通常由特定语言构建的框架构成。函数计算的运行时依赖语言的运行时存在。不过由于更贴近上层应用分析它的工作原理要相对简单一些。
上面的示意图展示的是运行时、函数的组合与初始化进程的关系。在函数实例初始化时函数运行时一般会由一个初始化进程给加载起来然后Runtime就可以进行服务请求的内部通信正常接收、处理请求。当请求到达后你的代码就会在对应的语言运行时中被加载起来的代码处理。
因此我们可以简单地将Runtime理解为一个特定语言环境下的服务框架环境这个服务将以一个进程的形态运行在用户容器中并与用户代码相关联。当服务启动后会一直等待请求的到来。一旦请求到达运行时就会关联你的代码去执行执行完成后又会继续处理下一个请求。
这里我要强调的是,这个执行不一定是串行的,有的架构为了提升并发,也会采用协程或者线程的方式去执行。
这个流程看起来比较容易理解对不对?那么,我们再深入流程看一眼,想一想,不同的语言运行时的实现,是一样的吗?
实现原理
运行时,归根到底还是一个编程语言编写出来的具体程序,所以对于上面这个问题,我们先来看看编程语言本身有哪些区别。
语言类型
我们知道,计算机只能执行二进制指令,而我们根据不同编程语言转换为二进制指令的时机,将不同的编程语言分为了解释型语言和编译型语言。
编译型语言比如C、C++、GoLang在编译时需要将所有用到的静态依赖、源码一起打包编译完成后就可以直接执行。而像Python、Node.js这种解释型语言则只需要通过解释器执行因此完全可以做到业务代码与依赖分离的形式。
这里需要注意一点我们常用的Java虽然需要经过编译但是编译产生的机器码需要由JVM再次转换为二进制指令因此是具有解释型和编译型两种特性的。我个人更偏向将其定位为编译型语言因为在你使用Java开发函数的时候也会发现我们通常是将所有依赖包打包成一个Jar包或者War包的方式上传比较符合编译语言的风格。
另外如果你使用过不同云厂商的函数计算平台你会发现像GoLang、Java这类编译型语言通常在开发过程中都需要强依赖一个平台提供的包而Python、Node.js则不需要这是为什么呢
在上面提到的语言类型的区别中我们提到因为编译型语言需要将所有关联的静态代码依赖一起打包所以在函数实例上具体的体现就是你的业务代码和运行时会生成一个完整二进制文件、Jar包或者War包。
了解完语言的不同,你应该也做好这两种函数计算运行时的实现也会不同的心理准备了。接下来,我会从从编译型和解释型两个角度来分别跟你聊一聊运行时的实现。
Golang运行时
上面我提到,对于编译型语言,用户代码通常需要和运行时一起编译,所以一般的云厂商都会将编译型语言的运行时开源。
这里我们以阿里云函数计算FC的GoLang Runtime运行时为例一起看看它的实现原理。
GoLang Runtime运行时主要做的事情有三点。
获取请求
在GoLang Runtime中平台会提前将RUNTIME API写到环境变量里Runtime会通过初始化客户端对象runtimeAPIClient从这个接口中获取到请求。
关联用户的入口函数
用户入口函数也就是图中的UserHandler。在Golang 的运行时中其实是通过反射机制获取的并将UserHandler封装到一个统一格式的结构体Handler中再将Handler作为Function结构体类型的一个属性进行赋值这样做的好处是用户完全可以按照自己的编程习惯去定义而不用对UserHandler的结构做出任何限制。
这里解释一下在源代码中作者沿用的是handler图中的UserHandler是我为了区分主进程中的handler而替换的一个名字下文在用户侧定义的handler我们都统一用UserHandler来做区分。
调用UserHandler对请求进行处理
在获取请求与UserHandlerHandleRequest(ctx context.Context, event string)就可以用第二步创建的Function对象去执行请求了。
接下来我将从GoLang用户侧代码的入口函数main函数开始 ,详细地梳理一遍上面的处理过程。
入口
当整个二进制被加载起来后程序首先会进入main函数并将用户定义的函数入口方法userHandler作为参数传入Start方法并对Start方法进行调用。
Start的入参为interface{}这样的传参方式可以让你的userHandler被定义为任何类型。
/**
* base function type
* eventFunction functionType = 101
* httpFunction functionType = 102
**/
func Start(userhandler interface{}) {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Println("start")
StartWithContext(context.Background(), userhandler, eventFunction)
}
func StartWithContext(ctx context.Context, userhandler interface{}, funcType functionType) {
StartHandlerWithContext(ctx, userhandler, funcType)
}
进入Start()方法后再深入下去最终会调用StartHandlerWithContext这个方法。接着会将全局变量runtimeAPIStartFunction赋值给startFunction这个局部变量。
关于runtimeAPIStartFunction我也列出了下方的代码。你可以发现它包含了一个环境变量名env以及一个之后循环处理请求的方法startRuntimeAPILoop。
func StartHandlerWithContext(ctx context.Context,
userhandler interface{}, funcType functionType) {
startFunction := runtimeAPIStartFunction
// 获取RUNTIMEAPI
config := os.Getenv(startFunction.env)
...
err := startFunction.f(ctx, config, handlerWrapper{userhandler, funcType}, lifeCycleHandlers)
...
}
// runtimeAPIStartFunction是提前定义好的全局变量
runtimeAPIStartFunction = &startFunction{
env: "FC_RUNTIME_API",
f: startRuntimeAPILoop,
}
最后在获取到startFunction.env的环境变量真实值后你会发现前面我们传入的userHandler、上下文Context以及刚刚获取的环境变量都传递给了startRuntimeAPILoop并对它进行了调用这些其实就是请求处理流程之前所需要的关键信息。
准备工作
拿到了函数请求需要的参数后就需要拉取请求并对其进行处理了你可以先通过代码来感知一下startRuntimeAPILoop是如何工作的
func startRuntimeAPILoop(ctx context.Context, api string, baseHandler handlerWrapper, lifeCycleHandlers []handlerWrapper) (e error) {
...
// 创建连接RuntimeAPI接口的客户端之后客户端会从这个接口获取请求信息
client := newRuntimeAPIClient(api)
// 根据传入的UserHandler转化为运行时中标准的Function结构体
function := NewFunction(baseHandler.handler, baseHandler.funcType).withContext(ctx)
...
for {
// 获取请求信息
req, err := client.next()
...
// 启动新的协程让function对请求进行处理
go func(req *invoke, f *Function) {
err = handleInvoke(req, function)
...
}(req, function)
}
}
首先程序会根据前面获取到的RUNTIMEAPI创建一个客户端这样就保证了请求的获取途径。接着会根据之前传入的userHandler以及userHandler类型创建出一个Function类型的对象。
这个Function对象的创建会根据start入口传递下来的function type的值来确定是创建event类型的handler还是http类型的handler分别对应处理事件的请求和Http的请求。
这里我提供的代码中传输的是eventFuntion后文就通过这一条方法调用流来进行追踪。
func NewFunction(handler interface{}, funcType functionType) *Function {
f := &Function{
funcType: funcType,
}
// 这里根据传入funcType来决定构造哪种类型的handler
if f.funcType == eventFunction {
f.handler = NewHandler(handler)
} else {
f.httpHandler = NewHttpHandler(handler)
}
return f
}
event类型的handler会通过NewHandler获得该函数的返回值要求返回一个Handler类型的接口这个接口需要实现一个标准的Invoke方法。
type Handler interface {
Invoke(ctx context.Context, payload []byte) ([]byte, error)
}
func NewHandler(userhandler interface{}) Handler {
...
// 获取userhandler的动态值
handler := reflect.ValueOf(userhandler)
// 获取userhandler的类型信息
handlerType := reflect.TypeOf(userhandler)
...
return fcHandler(func(ctx context.Context, payload []byte) (interface{}, error) {
...
// 通过动态值对userhandler进行调用
response := handler.Call(args)
...
return val, err
})
}
在NewHandler中也会利用GoLang中的反射机制获取到userhandler的类型和动态值并通过反射信息构造出一个有标准传参以及返回值的fcHandler类型的方法。在fcHandler中由于代码中的handler本身为Value类型因此可以通过Call方法调用其本身所代表的函数。如果你对反射的细节感兴趣也可以看看关于Go语言反射的官方手册介绍。
fcHandler是很特殊的它本身是一个函数类型并且已经实现了Invoke方法因此也是一个Handler类型这就解释了上文为什么以fcHandler作为返回值。而fcHandler中的Invoke最后调用了自己本身对应函数对请求进行了处理。
当准备工作就绪后,程序就开始对请求进行处理了,通过上述代码分析不难得出,主协程在这个函数中主要做了三件事:
准备获取用户请求的客户端newRuntimeAPIClient以及Funtion对Handler封装
不断通过客户端获取新的请求也就是代码中的client.next()方法;
分配一个新的协程并让Function在新协程中处理获取到的请求。
执行流程
那么进入新的协程以后请求才会真正地被最初传入的userHandler所执行我们深入到协程中的handleInvoke方法会发现存在这样的调用关系
->handleInvoke 1
->function.Invoke 2
->function.invokeEventFunc || function.invokeHttpFunc) 3
->function.handler.Invoke 4
在代码标号的第三步里如果我们在入口start处传递的是httpFunction类型这里就会调用function.invokeHttpFunc。当然我们还是继续沿着上面提到的event事件请求来追踪继续调用function.invokeEventFunc在这个函数里面会调用fn.handler.Invoke。
结合上面的函数调用关系来看当执行到f.handler.Invoke时实际上Invoke会对fcHandler进行一次调用最后fcHandler通过handler.Call完成了对userHandler的调用。
type fcHandler func(context.Context, []byte) (interface{}, error)
func (handler fcHandler) Invoke(ctx context.Context, payload []byte) ([]byte, error) {
response, err := handler(ctx, payload)
if err != nil {
return nil, err
}
responseBytes, err := json.Marshal(response)
if err != nil {
return nil, err
}
return responseBytes, nil
}
我将上面的流程梳理成了如下的示意图你可以对照着再回溯一遍GoLang运行时的主流程
这就是GoLang Runtime运行时在event调用的主流程那么针对更细的流程和定义你可以从Github上将代码下载下来按照这个思路逐一理解就可以了。
通过GoLang Runtime运行时的学习相信你已经清楚了解了运行时需要完成的工作以及它整个的处理流程。
今天我们就先讲到这里,你可以先消化一下。解释型语言的运行时如何运行?在云厂商不开源情况下,我们又要如何剖析它?这些问题,我们下一节课再来继续讨论。
小结
最后我来小结一下我们今天的内容。这节课我给你介绍了以Golang为代表的编译型语言运行时在Serverless函数计算形态下的实现原理函数计算运行时Runtime本质上就是一个让函数在容器中执行起来的代码框架。
运行时通常会由一个初始化进程加载起来,然后进行内部服务的通信,接收和处理该函数收到的请求。
根据编程语言类型的不同运行时的实现上也会略微有所不同。编译型语言的运行时需要和用户代码一起打包成二进制文件或者其他特定语言类型的包如Jar包、War包而解释型语言的运行时则可以与用户代码分离存在。所以厂商一般都会将编译型运行时的代码进行开源以SDK的形式提供给开发者使用。
从Golang Runtime的代码框架中我们可以看出运行时主要就是获取请求、关联用户的函数入口Handler、执行用户的实现。
希望你通过今天的课程,能够对函数计算形态下的语言运行时有一定的了解,不仅会用,更知道它如何实现的,在后续遇到问题或者开发更复杂的功能时,能够做到心中有数。
思考题
好了,这节课到这里也就结束了,最后我给你留了一个思考题。
我们上一节课中讲了Knative那么Knative涉不涉及运行时一说呢运行时只存在在云厂商的平台上么
欢迎在留言区写下你的思考和答案,我们一起交流讨论。感谢你的阅读,也欢迎你把这节课分享给更多的朋友一起交流学习。