原文地址:mukulrathi.co.uk/create-your…
原文作者:mukulrathi.co.uk/
发布时间:2020年12月28日-9分钟阅读
系列。创建BOLT编译器
- 第1部分:为什么要写自己的编程语言?为什么要写自己的编程语言?
- 第2部分:那么如何架构一个编译器项目?
- 第3部分:使用OCamllex和Menhir编写一个Lexer和解析器。
- 第4部分:类型理论和实现类型检查器的易懂介绍。
- 第5部分:关于活泼度和别名数据流分析的教程。
- 第6部分:Desugaring--将我们的高级语言简单化!
- 第7部分:OCaml和C++的Protobuf教程。
- 第8部分: 编程语言创建者的LLVM完全指南。
- 第9部分:实现并发和我们的运行库
- 第10部分:Bolt中的继承和方法重写。
- 第11部分:通用性--为Bolt添加多态性。
即将推出
运行时库的作用
到现在为止,我们的语言Bolt中的构造直接翻译成LLVM IR指令。我对并发的最大误解是,它的工作方式是一样的。认为编译器可以发出一些 spawn 指令来创建一个新的线程。事实并非如此。LLVM没有一条指令可以为你创建线程。你问为什么没有?线程有什么特别之处?
创建线程是一个复杂的例行指令,是平台特有的。线程由操作系统内核管理,所以创建线程需要按照该平台的惯例创建系统调用。
LLVM在这里画了一条线。LLVM能提供多少功能而不至于让自己变得庞大,这是一个极限。想想看,从嵌入式系统到移动设备,再到不同的桌面操作系统,它要支持的平台有多少种。
进入你的运行时库。你的语言的运行时库为这些与平台/运行时环境交互的例程提供了实现。编译器在编译我们的Bolt程序时,会插入对这些运行时库函数的调用。一旦我们有了编译后的LLVM IR,我们就会链接到运行时库函数的实现,这样可执行程序就可以调用这些函数。
你知道编译到LLVM IR最好的部分是什么吗?我们不必编写自己的运行时库。C编译到LLVM IR。我们只需要用C函数来引导我们的运行时库,然后用clang把它们链接进去就可以了!
那么,我们的运行时库中存在哪些类型的函数呢?
- I/O,如printf
- 内存管理。(记得在上一篇文章中,我提到LLVM没有提供堆。) 或者手动实现(malloc和free),或者通过垃圾收集算法(比如标记和扫除)。要么手动实现(malloc和free),要么通过垃圾收集算法(如mark-and-sweep)。
- 当然也可以通过C语言的pthread API来实现线程。pthread是POSIX Thread的简称,是这些线程系统调用的标准化API。
我们并不局限于C语言提供的函数,我们可以编写自己的C语言函数,并使用本篇文章中描述的技术将它们连接起来。
我们将看看printf
回顾。LLVM模块
下面是一个LLVM模块的结构草图(来自上一篇文章)。如图所示,我们对模块感兴趣的部分是函数声明。要使用一个C库函数,我们需要在模块的函数声明符号表中插入它的函数签名。
打印
我们用printf来热身。C函数的类型签名是
int printf ( const char* format, ... );
要将这个C类型签名翻译成LLVM FunctionType。
- 去掉const限定符
- 将C类型转换为等价的LLVM类型:int和char分别映射为i32和i8。
- 的...表示printf是可变的。因此,LLVM的API代码如下。
module->getOrInsertFunction(
"printf",
FunctionType::get(
IntegerType::getInt32Ty(*context),
Type::getInt8Ty(*context)->getPointerTo(),
true /* this is variadic func */
)
);
以及调用printf函数的相应代码。
Value *IRCodegenVisitor::codegen(const ExprPrintfIR &expr) {
Function *printf = module->getFunction("printf");
std::vector<Value *> printfArgs;
Value *formatStrVal = builder->CreateGlobalStringPtr(expr.formatStr);
printfArgs.push_back(formatStrVal);
// add variadic arguments
for (auto &arg : expr.arguments) {
printfArgs.push_back(arg->codegen(*this););
}
return builder->CreateCall(printf, printfArgs);
};
CreateGlobalStringPtr是一个有用的IRBuilder方法,它接收一个字符串并返回一个i8*指针(所以我们有一个正确类型的参数)。
Bolt内存模型
每个线程都有自己的堆。为了在线程之间共享对象,我们将引入一个全局的对象堆。我们将使用堆栈来存储基元,如ints、bool,并在堆上存储指向对象的指针。
Malloc
我们可以使用malloc将对象分配到堆中。(这就是C++的新关键字在引擎盖下的作用!)
malloc的类型签名如下。
void *malloc(size_t size);
换算成等效的LLVM IR类型,void *和size_t映射成i8 *和i64。确定了LLVM类型后,LLVM API代码就掉了出来。
Type *voidPtrTy = Type::getInt8Ty(*context)->getPointerTo();
module->getOrInsertFunction(
"malloc",
FunctionType::get(
voidPtrTy,
IntegerType::getInt64Ty(*context),
/* has variadic args */ false
)
);
不过只有一个问题。当我们在堆上创建一个结构时,malloc要求我们指定要分配的字节数。然而该结构的大小是机器特定的信息(它取决于数据类型的大小,结构填充等)。在与机器无关的LLVM IR中,我们如何做到这一点呢?
我们可以通过下面的hack来计算。我们知道,一个Foo类型的结构数组只是一个连续的内存块。指向相邻索引的指针相隔size(Foo)字节。因此,如果我们以地址0x0000(特殊的NULL地址)开始一个数组,那么数组的第一个索引就在地址size(Foo)。
我们可以使用getelementptr(GEP)指令来计算这个数组地址,并将数组的基指针作为空值传入。你可能会想,等等,这个数组不存在。这不会引起seg故障吗?
记住,GEP指令的作用只是计算指针偏移量。而不是检查结果指针是否有效。而不是实际访问内存。只是执行这个计算。没有访问内存=没有seg故障。
// calculate index of array[1] using GEP instruction
Value *objDummyPtr = builder->CreateConstGEP1_64(
Constant::getNullValue(objType->getPointerTo()), 1, "objsize");
// cast to i64 for malloc
Value *objSize =
builder->CreatePointerCast(objDummyPtr,Type::getInt64Ty(*context));
我们将objSize传给malloc,malloc返回一个void 指针,然而由于我们以后要访问结构的字段,我们需要将类型转为objType。记住,LLVM需要显式类型!
// allocate the object on the heap
Value *objVoidPtr =
builder->CreateCall(module->getFunction("malloc"), objSize);
// cast (void *) to (objType *)
Value *obj =
builder->CreatePointerCast(objVoidPtr, objType->getPointerTo());
奖励:垃圾收集
把malloc换成GC malloc。如果我们使用GC_malloc,我们就可以得到免费的垃圾回收! 这多酷啊! 我们不需要free()我们的对象。
如果你想实现自己的垃圾收集器,请查看这个LLVM页面。
用Pthreads实现硬件线程
到目前为止,我们已经研究了printf和malloc。我们发现,声明它们的函数签名的最大障碍是将C类型翻译成LLVM IR类型。一旦你有了LLVM IR类型,一切都会迎刃而解。在pthread API中,这个过程是一样的,只是从C类型翻译成LLVM IR类型的过程更复杂一些。
了解Pthread API
我们要使用的两个函数是pthread_create和pthread_join。链接到的Linux手册页面给出了对这两个函数的完整描述,但它们有点密集。
让我们解开相关信息,从函数的签名开始。
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
int pthread_join(pthread_t thread, void **retval);
pthread_create和pthread_join都是习惯性的C语言函数。
- 它们返回一个int,其中0=成功,其他值=错误代码。
- 如果要返回额外的值,例如一个类型为Foo的val,我们传入一个类型为
Foo*的指针p作为参数。函数会将指针的值更新为返回的值(*p=val)。然后,我们可以通过解除引用指针(*p)来访问返回的值。如果你不熟悉这种 "逐个传递指针 "模式,请参见本教程。
如果你不熟悉C指针语法,虚空*(*start_routine)(void *)是很拗口的。这说明start_routine是一个指向函数的指针,该函数接收一个void *参数并返回一个void *值。void *是代表任何指针的通用类型(它超级灵活--我们可以把它投向任何我们喜欢的类型,例如int *或Foo *)。
pthread_create创建了一个线程,它将异步执行start_routine指向的函数和参数。不透明的pthread_t类型代表一个线程对象的句柄(可以把它想象成一个线程id)。我们传递一个pthread_t *指针,pthread_create将把创建的线程对应的pthread_t句柄分配给这个对象。不透明的pthread_attr_t类型表示我们希望线程拥有的任何属性。pthread_attr_t *参数让我们指定我们希望创建的线程拥有的属性。传递NULL将用默认属性初始化线程,这对我们来说已经足够好了。
我们把pthread_t句柄传递给pthread_join,告诉它我们要加入(等待完成)的线程。pthread_join用在该线程上执行的start_routine(arg)的void *返回值更新void **指针参数。如果我们不想要这个返回值,我们可以传递NULL。
这里有一个很好的最小化C例子,演示了Pthreads的使用。
将Pthread类型翻译成LLVM IR。
我们之前已经看到过int和void :它们可能是i32和i8。void *则是i8。我们在pthread_t和pthread_attr_t上遇到了一点麻烦,因为它们的类型定义是不透明的。
啊,糟糕,我们被卡住了。解决的办法(就像大多数被LLVM IR卡住的情况一样)是用C语言进行实验,看看编译后的LLVM IR输出。
我们可以使用clang将那个优秀的最小化C例子编译成LLVM IR。对foo.c文件的编译命令是。
clang -S -emit-llvm -O1 foo.c
Clang LLVM IR的输出相当混乱。阅读输出的最好方法是找到LLVM IR中与C程序中有趣的代码行相对应的行,并忽略它们周围的噪声。在这篇优秀的Reddit评论中,有更多关于用C和C++实验来理解LLVM IR的信息。
对我们来说,有趣的行是那些分配pthread_t堆栈变量的行,以及pthread_create和pthread_join调用。
// C
pthread_t inc_x_thread;
...
pthread_create(&inc_x_thread, NULL, inc_x, &x)
...
pthread_join(inc_x_thread, NULL)
// LLVM IR
%4 = alloca %struct._opaque_pthread_t*, align 8
...
%9 = call i32 @pthread_create(%struct._opaque_pthread_t** %4, %struct._opaque_pthread_attr_t* null, i8* (i8*)* @inc_x, i8* %8)
...
%23 = call i32 @pthread_join(%struct._opaque_pthread_t* %22, i8** null)
如果我们把函数的类型定义匹配起来。
// C type -> LLVM IR type
pthread_t = %struct._opaque_pthread_t*
pthread_attr_t = %struct._opaque_pthread_attr_t
很好,我们已经确定pthread_t是一个指向%struct._opaque_pthread_t类型结构的指针。这个结构体的类型是什么呢?让我们看看前面文件中定义的类型定义。
struct.__sFILE = type { i8*, i32, i32, i16, i16, %struct.__sbuf, i32, i8*, i32
(i8*)*, i32 (i8*, i8*, i32)*, i64 (i8*, i64, i32)*, i32 (i8*, i8*, i32)*, %struc
t.__sbuf, %struct.__sFILEX*, i32, [3 x i8], [1 x i8], %struct.__sbuf, i32, i64 }
%struct.__sFILEX = type opaque
%struct.__sbuf = type { i8*, i32 }
%struct._opaque_pthread_t = type { i64, %struct.__darwin_pthread_handler_rec*, [
8176 x i8] }
%struct.__darwin_pthread_handler_rec = type { void (i8*)*, i8*, %struct.__darwin
_pthread_handler_rec* }
%struct._opaque_pthread_attr_t = type { i64, [56 x i8] }
呀,这是一个烂摊子。事情是这样的。我们不需要声明结构的内部结构,因为我们在程序中不使用它们。所以就像上面定义的%struct.__sFILEX是一个不透明的结构一样,我们可以定义自己的不透明结构。pthread库的文件会在实际操作结构类型的内部时指定结构类型的主体。
Type *pthread_t = StructType::create(*context, "struct_pthread_t") ->getPointerTo();
Type *pthread_attr_t = StructType::create(*context,"struct_pthread_attr_t")
眼尖的人可能会注意到这些结构的名字与文件中的名字不一致,例如 struct_pthread_t 与 struct._opaque_pthread_t。是什么原因呢?
LLVM的类型是结构化解析的,而不是按名字解析的。因此,即使我们的程序有两个独立的结构Foo和Bar,如果它们的字段类型相同,LLVM也会把它们当作一样的。名字并不重要--我们可以用一个结构代替另一个结构,而不会出现任何错误。
// Foo == Bar
%Foo = type {i32, i1}
%Bar = type {i32, i1}
原来我们可以利用LLVM的类型系统的结构特性,进一步简化我们的类型。
请看pthread_attr_t只在一个地方使用:pthread_create,在那里我们传递NULL作为pthread_attr_t *参数。NULL不管是什么类型都是一样的值,所以我们可以用void *来表示一个通用的NULL指针,而不是定义pthread_attr_t *类型。
接下来我们来看一下pthread_t。我们知道pthread_t是一个指向某个不透明结构的指针,但是我们在程序中从来没有在任何地方访问过这个结构。事实上,pthread_t的类型唯一重要的地方是当我们在堆栈上为它分配内存的时候--我们需要知道它的类型才能知道要分配多少字节。
Type *pthreadTy = codegenPthreadTy();
Value *pthreadPtr =
builder->CreateAlloca(pthreadTy, nullptr, "pthread");
问题是:所有的指针都有相同的大小,不管类型如何,因为它们都存储内存地址。所以我们也可以为pthread_t使用一个通用指针类型void *。
Type *voidPtrTy = Type::getInt8Ty(*context)->getPointerTo();
Type *pthreadTy = codegenPthreadTy();
Type *pthreadPtrTy = pthreadTy->getPointerTo();
// (void *) fn (void * arg)
FunctionType *funVoidPtrVoidPtrTy = FunctionType::get(
voidPtrTy, ArrayRef<Type *>({voidPtrTy}),
/* has variadic args */ false);
// int pthread_create(pthread_t * thread, const pthread_attr_t * attr,
// void * (*start_routine)(void *), void * arg)
// we use a void * in place of pthread_attr_t *
FunctionType *pthreadCreateTy = FunctionType::get(
Type::getInt32Ty(*context),
ArrayRef<Type *>({pthreadPtrTy, voidPtrTy,
(funVoidPtrVoidPtrTy)->getPointerTo(),
voidPtrTy}),
/* has variadic args */ false);
module->getOrInsertFunction("pthread_create", pthreadCreateTy);
// int pthread_join(pthread_t thread, void **value_ptr)
FunctionType *pthreadJoinTy = FunctionType::get(
Type::getInt32Ty(*context),
ArrayRef<Type *>({pthreadTy, voidPtrTy->getPointerTo()}),
/* has variadic args */ false);
module->getOrInsertFunction("pthread_join", pthreadJoinTy);
在运行时库中进行链接
当我们编译foo.ll文件到./foo可执行文件时,我们可以使用clang来链接库。
我们在pthread中使用-pthread标志进行链接。
要链接GC_malloc,我们需要做两件事。
- 包含它的头文件(这里它们在/usr/local/include/gc/文件夹中)。我们使用-I标志来添加它的文件夹。
- 添加静态库.a文件。将静态库.a文件:/usr/local/lib/libgc.a添加到正在编译的文件列表中。
clang -O3 -pthread -I/usr/local/include/gc/ foo.ll /usr/local/lib/libgc.a -o foo
使用C函数的通用引导方法
我们已经看到了在引导我们的运行库时使用的3个C函数的例子:printf、malloc和pthread API。通用方法(如果你已经了解了函数,可以跳过第2-4步
- 获取C函数类型签名
- 用C语言写一个最小的例子
- 将C语言的例子编译成LLVM IR,并将例子中的关键行与IR中的相应行进行匹配,例如函数调用。
- 将任何不透明的类型简化为更通用的类型:只定义你所需要的类型信息!例如,如果你不需要知道它是一个
struct *指针(因为你没有加载它或执行GEP指令),使用void *。 - 将C类型翻译成LLVM的IR类型
- 在你的模块中声明函数原型。
- 在LLVM IR中,无论你在哪里需要它,都可以调用该函数。
- 在编译可执行文件时,将该函数链接进去。
在Bolt中实现并发
完成-同步结构
Bolt使用finish-async结构进行并发。async关键字生成(创建)一个线程,并设置它执行该async块中的表达式。finish块使用async关键字对其内部产生的任何线程的寿命进行了限定--因此所有产生的线程必须在块的最后加入。这样,我们已经明确定义了线程的寿命是完成块的寿命。
before();
finish{
async{
f();
}
async{
g();
h();
}
during();
}
after();
以图示方式说明执行情况。
Bolt并发程序图解--每种颜色代表不同的线程。
创建我们的线程
计算自由变量
当在我们的异步块内部,我们可以访问完成块开始时(异步线程被生成之前)作用域内的任何对象。例如
let a = new Foo();
let b = new Bar();
let y = true;
let z = 2;
finish{
async{
// This thread accesses a, b
let w = 1;
f(a, b, w);
}
...
}
但是,有一个问题。在Bolt的内存模型中,每个线程都有自己的栈。let a = ... 的定义发生在主线程上,所以指向a的对象的指针就存储在主线程的栈中。当我们使用async生成第二个线程时,这个新线程有自己的栈,而这个栈是空的(所以不包含a或b)。
第一步是计算需要复制到新栈中的自由变量。如果你有兴趣的话,这里有代码的链接;我们将跳过细节,因为它很机械。如果你想回顾一下去ugaring阶段,请链接到之前关于去ugaring的文章。
async{
// a, b are free variables
let w = 1;
f(a, b, w);
}
将异步块转换为函数调用。
现在我们需要以某种方式将这个表达式转换为pthread_create可以运行的函数。
async{
let w = 1;
f(a, b, w);
}
// need to convert this to a function
void *asyncFun(void *arg){
let w = 1;
f(a, b, w);
return null; // we return null but
// you could return the last value instead
}
由于我们需要定义函数体中的所有变量,所以我们需要将自由变量作为参数传递给函数。
function void *asyncFun(Foo a, Bar b){
let w = 1;
f(a, b, w);
}
let a = new Foo();
let b = new Bar();
let y = true;
let z = 2;
finish{
asyncFun(a, b);
...
}
然而,这不符合我们正在寻找的参数类型:asyncFun只能接受一个void *参数。
解决方案:创建一个包含所有值的单一结构。我们可以将该struct *投向一个void *指针,并从该指针投出,以匹配类型。
ArgStructType *argStruct = {a, b};
// we can cast ArgStructType * to void *
asyncFun(argStruct);
很好,现在我们有了void *asyncFun(void *argStruct)函数类型,正如pthread_create所要求的那样。
我们需要将其解包到函数中。
function void *asyncFun(void * arg){
// cast pointer
ArgStructType *argStruct = (ArgStructType *) arg;
// unpack variables
let a = argStruct.a;
let b = argStruct.b;
// execute body of function
let w = 1;
f(a, b, w);
}
创建Pthreads
最后,在定义了我们的arg和异步函数后,我们可以调用pthread_create。
其高层结构如下。
// create async function and argument
StructType *argStructTy = codegenAsyncFunArgStructType(freeVarList);
Value *argStruct = codegenAsyncFunArgStruct(asyncExpr, argStructTy);
Function *asyncFun = codegenAsyncFunction(asyncExpr, argStructTy);
...
// spawn thread
Function *pthread_create =
module->getFunction("pthread_create");
Value *voidPtrNull = Constant::getNullValue(
Type::getInt8Ty(*context)->getPointerTo());
Value *args[4] = {
pthread,
voidPtrNull,
asyncFun,
builder->CreatePointerCast(argStruct, voidPtrTy),
};
builder->CreateCall(pthread_create, args);
codegenAsyncFunArgStructType、codegenAsyncFunArgStruct和codegenAsyncFunction只是实现了我们用散文概述的步骤。
加入Pthreads
我们将每个异步表达式的线程的pthread_t句柄连接起来。 正如我们前面提到的,我们并没有从asyncFun中返回任何东西,所以我们可以传入NULL作为第二个参数。
void IRCodegenVisitor::codegenJoinPThreads(
const std::vector<Value *> pthreadPtrs) {
Function *pthread_join =
module->getFunction("pthread_join");
Type *voidPtrPtrTy =
Type::getInt8Ty(*context)->getPointerTo()->getPointerTo();
for (auto &pthreadPtr : pthreadPtrs) {
Value *pthread = builder->CreateLoad(pthreadPtr);
builder->CreateCall(pthread_join,
{pthread, Constant::getNullValue(voidPtrPtrTy)});
}
在LLVM IR中实现Finish-Async功能
现在我们已经讲了如何创建线程和如何加入线程,我们可以给出finish-async并发构造的整体代码生成。
Value *IRCodegenVisitor::codegen(
const ExprFinishAsyncIR &finishAsyncExpr) {
std::vector<Value *> pthreadPtrs;
// spawn each of the pthreads
for (auto &asyncExpr : finishAsyncExpr.asyncExprs) {
Type *pthreadTy = codegenPthreadTy();
Value *pthreadPtr =
builder->CreateAlloca(pthreadTy, nullptr, Twine("pthread"));
pthreadPtrs.push_back(pthreadPtr);
codegenCreatePThread(pthreadPtr, *asyncExpr);
};
// execute the current thread's expressions
Value *exprVal;
for (auto &expr : finishAsyncExpr.currentThreadExpr) {
exprVal = expr->codegen(*this);
}
// join the threads at the end of the finish block
codegenJoinPThreads(pthreadPtrs);
return exprVal;
收尾
在这篇文章中,我们已经研究了运行时库的作用,以及如何用C函数来引导我们的Bolt运行时。我们看了一种通用的方式,将C函数声明添加到我们的模块中,然后将它们与我们编译的.ll文件链接起来。我鼓励你在运行时库中添加更多的C函数,例如scanf来配合我们的printf函数。
这篇文章的后半部分是对Bolt如何做并发的深入探讨。我们已经使用pthread为每个async表达式生成一个硬件线程。一个扩展可能是使用线程池来代替!
通过www.DeepL.com/Translator(免费版)翻译