原文:
zh.annas-archive.org/md5/62FC08F1461495F0676A88A03EA0ECBA
译者:飞龙
第十六章:使用 CGO
本章将向您介绍 CGO,这是一个用于 C 语言的 Go 运行时。它使得可以从 Go 应用程序中调用 C 代码,而由于 C 有大量可用的库,这意味着它们可以在 Go 中被利用。
本章将涵盖以下主题:
-
从 C 和 Go 中使用 CGO
-
理解类型差异
技术要求
本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述。
此外,它需要安装 GCC 编译器在你的机器上。在你的 Unix 机器上可以很容易地使用包管理器来完成这个任务。对于 Ubuntu,命令如下:
sudo apt install gcc
CGO 简介
CGO 是一种工具,可以让我们在 Go 应用程序中运行 C 代码。这个功能自从 Go 在 2009 年达到 1.0 版本以来就一直存在,当时标准库之外可用的包比现在少,所以我们可以使用现有的 C 库。
C 代码通过 C
伪包访问,通过包名和标识符访问和调用,例如 C.print
。
import
声明前面有一系列特殊的注释,指定应用程序应该导入哪个 C 源文件:
package example
// #include <stdio.h>
import "C"
这个语句也可以是一个多行注释,可以包含更多的 include
指令,就像之前的例子中的那个,甚至可以直接包含实际的 C 代码:
package example
/*
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
void someFunction(char* s) {
printf("%s\n", s);
}
*/
import "C"
重要的是在 C 注释和 import
语句之间避免空行,否则库和代码将被 CGO 导入应用程序。
从 Go 调用 C 代码
为了使用我们自己或他人制作的现有 C 代码,我们需要从 Go 中调用 C。让我们进行一个快速的完整示例,使用只有 C 功能打印一个字符串到标准输出:
package main
/*
#include <stdio.h>
#include <stdlib.h>
void customPrint(char* s) {
printf("%s\n", s);
}
*/
import "C"
import "unsafe"
func main() {
s := C.CString(`Printing to stdout with CGO
Using <stdio.h> and <stdlib.h>`)
defer C.free(unsafe.Pointer(s))
C.customPrint(s)
}
我们在这里导入了两个 C 核心库,它们分别是:
-
stdio.h
:这包含了输入和输出方法。我们正在使用printf
。 -
stdlib.h
:这包含了一般函数,包括内存管理。
从前面的代码中可以看到,我们注意到我们要打印的变量不是普通的 Go string
,而是通过 C.CString
函数获得的,它接受一个字符串并返回一个 char
切片,因为这就是 C 中字符串的处理方式。该函数定义如下:
func C.CString(string) *C.char
我们可以观察到的第二件事是,我们在延迟调用 C.free
,传递了我们定义的 s
变量,但转换成了不同的类型。这个函数调用是必要的,因为语言没有垃圾回收,为了释放内存,应用程序需要明确调用 C 的 free
函数。这个函数接收一个通用指针,它在 Go 中被表示为 unsafe.Pointer
类型。根据 Go 文档,以下内容适用:
“任何类型的指针值都可以转换为指针。”
这正是我们正在做的,因为字符串变量的类型是 *C.char
指针。
从 C 中调用 Go 代码
我们刚刚看到了如何使用 C 包和 import
语句从 Go 应用程序中调用 C 代码。现在,我们将看到如何从 C 中调用 Go 代码,这需要使用另一个特殊的语句叫做 export
。这是一个需要放在我们想要导出的函数上面的注释,后面跟着那个函数的名称:
//export theAnswer
func theAnswer() C.int {
return 42
}
Go 函数需要在 C 代码中声明为外部函数。这将允许 C 代码使用它:
extern int theAnswer();
我们可以通过创建一个导出函数的 Go 应用程序来测试这个功能,这个函数被一个 C 函数使用。这个函数在 Go 的 main
函数中被调用:
package main
// extern int goAdd(int, int);
//
// static int cAdd(int a, int b) {
// return goAdd(a, b);
// }
import "C"
import "fmt"
//export goAdd
func goAdd(a, b C.int) C.int {
return a + b
}
func main() {
fmt.Println(C.cAdd(1, 3))
}
在前面的示例中,我们有一个 goAdd
函数,它使用 export
语句导出到 C。导出的名称与函数的名称匹配,注释和函数之间没有空行。
我们可以注意到在导出函数的签名中使用的类型不是常规的 Go 整数,而是C.int
变量。我们将在下一节中看到 C 和 Go 系统的不同之处。
C 和 Go 类型系统
为了在 C 和 Go 之间传递数据,我们需要通过执行正确的转换来传递正确的类型。
字符串和字节切片
Go 中的基本类型string
在 C 中不存在。它有char
类型,表示一个字符,类似于 Go 的rune
类型,并且字符串由以\0
结尾的char
类型的数组表示。
该语言允许直接声明字符数组作为数组或字符串。第二个声明不以0
值结束以结束字符串:
char lang[7] = {'G', 'o', 'l', 'a', 'n', 'g', '\0'};
char lang[] = "Golang";
我们已经看到如何使用以下函数将 Go 字符串转换为 C 字符数组:
func C.CString(string) *C.char
此函数将在堆中分配字符串,因此应用程序有责任使用C.free
函数释放此内存。
为了将字节片转换为名为*char
的 C 字符指针,我们可以使用以下函数:
func C.CBytes([]byte) unsafe.Pointer
对于C.CString
,应用程序在堆中分配数据,并将释放的责任留给 Go 应用程序。
这两个函数之间的主要区别在于第一个生成char[]
,而另一个创建*char
。这两种类型相当于 Go 的string
和[]byte
,因为第一种类型的字节不能更改,而第二种类型的字节可以更改。
有一系列函数用于将 C 类型转换回 Go 类型。就字符串而言,有两个函数:C.GoString
从整个数组创建字符串,C.GoStringN
允许使用显式长度创建字符串:
func C.GoString(*C.char) string
func C.GoStringN(*C.char, C.int) string
要将 C 的*char
转换回 Go 的[]byte
,有一个单独的函数:
func C.GoBytes(unsafe.Pointer, C.int) []byte
我们可以使用C.CBytes
函数使用 C 修改字节片并将其转换回 Go 片:
package main
/*
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char* reverseString(char* s) {
int l = strlen(s);
for (int i=0; i < l/2; i++) {
char a = s[i];
s[i] = s[l-1-i];
s[l-1-i] = a;
}
return s;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
b1 := []byte("A byte slice")
c1 := C.CBytes(b1)
fmt.Printf("Go ptr: %p\n", b1)
fmt.Printf("C ptr: %p\n", c1)
defer C.free(c1)
c2 := unsafe.Pointer(C.reverseString((*C.char)(c1)))
b2 := C.GoBytes(c2, C.int(len(b1)))
fmt.Printf("Go ptr: %p\n", b2)
fmt.Printf("%q -> %q", b1, b2)
}
执行此应用程序将显示,将字节片b1
转换为 C 类型作为c1
变量时,地址将更改。由 C 函数返回的 C 片段c2
将具有与c1
相同的地址,因为它是相同的片段。再次转换回 Go 并分配给b2
时,它将具有与初始 Go 字节片b1
不同的另一个地址。
我们可以使用 C 字符串函数来实现相同的结果。让我们使用上一个示例中的相同 C 代码并更改其余部分:
package main
/*
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char* reverseString(char* s) {
int l = strlen(s);
for (int i=0; i < l/2; i++) {
char a = s[i];
s[i] = s[l-1-i];
s[l-1-i] = a;
}
return s;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
s1 := "A byte slice"
c1 := C.CString(s1)
defer C.free(unsafe.Pointer(c1))
c2 := C.reverseString(c1)
s2 := C.GoString(c2)
fmt.Printf("%q -> %q", s1, s2)
}
重要的是要注意,将 Go 字符串和字节值传输到 C 时,这些值会被复制。因此,C 代码无法直接编辑它们,而是将编辑副本,保持原始 Go 值不变。
整数
在 C 中,可用的整数类型与 Go 有许多相似之处,因为两种语言中每种整数类型都有带符号和无符号版本,但它们在名称和字节大小方面有所不同。 C 的sizeof
函数可以检查每种类型的大小。
以下是 C 中可用的整数类型列表:
有符号类型
类型 | 大小 | 范围 |
---|---|---|
char | 1 字节 | [-128, +127] |
int | 2 或 4 字节 | 参见short 和long |
short | 2 字节 | [-32 768, +32 767] |
long | 4 字节 | [-2 147 483 648, +2 147 483 647] |
long long | 8 字节 | [-9 223 372 036 854 775 808, +9 223 372 036 854 775 807] |
无符号类型
类型 | 大小 | 范围 |
---|---|---|
无符号char | 1 字节 | [0, +255] |
无符号int | 2 或 4 字节 | 参见无符号short 或无符号long |
无符号short | 2 字节 | [0, +65 535] |
无符号long | 4 字节 | [0, +4 294 967 295] |
无符号long long | 8 字节 | [0, +18 446 744 073 709 551 615 ] |
在C
中,int
的大小取决于架构-在 16 位处理器上曾经是 2 字节,但在现代处理器(32 位和 64 位)上是 4 字节。
当我们从 Go 的领域移动到 C 的领域,反之亦然,我们失去了所有变量溢出的信息。当我们尝试将一个整数变量适应另一个没有足够大小的整数变量时,编译器不会警告我们。我们可以通过一个简短的例子来看到这一点,如下所示:
package main
import "C"
import "fmt"
func main() {
a := int64(0x1122334455667788)
// a fits in 64 bits
fmt.Println(a)
// short overflows, it's 16
fmt.Println(C.short(a), int16(0x7788))
// long also overflows, it's 32
fmt.Println(C.long(a), int32(0x55667788))
// longlong is okay, it's 64
fmt.Println(C.longlong(a), int64(0x1122334455667788))
}
我们可以看到a
的值是一个确定的数字,但short
和long
变量没有足够的字节,所以它们将有不同的值。转换显示了在转换时只有最后的字节被取自变量,其他字节被丢弃。
这是一个有用的 C 类型和可比较的 Go 类型的列表,以及如何在 Go 代码中使用它们:
C 类型 | Go 类型 | CGO 类型 |
---|---|---|
char | int8 | C.char |
short | int16 | C.short |
long | int32, rune | C.long |
long long | int64 | C.longlong |
int | int | C.int |
无符号的char | uint8, byte | C.uchar |
无符号的short | uint16 | C.ushort |
无符号的long | uint32 | C.ulong |
无符号的long long | uint64 | C.ulonglong |
无符号的int | uint | C.uint |
在执行转换时,您可以使用此表作为参考,并避免使用错误类型导致的错误,因为在使用 CGO 时没有溢出警告。
浮点类型
在 C 中,float
类型与 Go 中的类型非常相似:
-
C 提供了 32 位的
float
和 64 位的double
。 -
Go 有
float32
和float64
。
当从 64 位值转换为 32 位值时,可能会导致四舍五入误差,如下面的代码所示:
package main
import "C"
import (
"fmt"
"math"
)
func main() {
a := float64(math.Pi)
fmt.Println(a)
fmt.Println(C.float(a))
fmt.Println(C.double(a))
fmt.Println(C.double(C.float(a)) - C.double(a))
}
前面的例子显示了math.Pi
的值从3.141592653589793
变为3.1415927
,导致了约1/10⁷
的错误。
不安全的转换
现在我们将看到如何使用unsafe
包从 C 中直接编辑 Go 变量。
直接编辑字节切片
还可以使用一个不正当的技巧直接编辑 Go 字节切片。从 Go 的角度来看,切片是一组值:
-
第一个元素的指针
-
切片的大小
-
切片的容量
在 C 中,字节切片只是一系列字节,字符串是以\0
结尾的字符切片。
如果我们使用unsafe
包将指针传递给切片的第一个元素,我们将能够直接编辑现有的字节切片,而无需执行复制和转换。我们可以看到如何在以下应用程序中执行此转换:
package main
/*
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void reverseString(char* s) {
int l = strlen(s);
for (int i=0; i < l/2; i++) {
char a = s[i];
s[i] = s[l-1-i];
s[l-1-i] = a;
}
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
b1 := []byte("A byte slice")
fmt.Printf("Slice: %s\n", b1)
C.reverseString((*C.char)(unsafe.Pointer(&b1[0])))
fmt.Printf("Slice: %s\n", b1)
}
转换是使用表达式(*C.char)(unsafe.Pointer(&b1[0]))
执行的,它执行以下操作:
-
获取切片的第一个元素的指针
-
将其转换为不安全的指针
-
将
byte
指针转换为C.char
指针,共享内存表示
数字
使用unsafe
包,我们还可以将数字变量的指针转换为其 C 对应项。这使我们能够直接在 C 代码中编辑它:
package main
/*
void half(double* f) {
*f = *f/2;
}
*/
import "C"
import (
"fmt"
"math"
"unsafe"
)
func main() {
a := float64(math.Pi)
fmt.Println(a)
C.half((*C.double)(unsafe.Pointer(&a)))
fmt.Println(a)
}
前面的示例确实做到了这一点;它在 C 函数中将a
的值减半,而不是在 Go 中复制并分配新值。
使用切片
Go 切片和 C 切片在一个基本方面有所不同——Go 版本嵌入了长度和容量,而在 C 中,我们只有指向第一个元素的指针。这意味着在 C 中,长度和容量必须存储在其他地方,比如另一个变量中。
让我们来看看以下的 Go 函数,它计算一系列float64
数字的平均值:
func mean(l []float64) (m float64) {
for _, a := range l {
m += a
}
return m / float64(len(l))
}
如果我们想在 C 中有一个类似的函数,我们需要传递一个指针以及它的长度。这将避免诸如分段错误之类的错误,当应用程序尝试访问未分配给它的内存时会发生这种错误。如果内存仍然分配给应用程序,结果是它提供对具有未知值的内存区域的访问,导致不可预测的结果:
double mean(int len, double *a) {
if (a == NULL || len == 0) {
return 0;
}
double m = 0;
for (int i = 0; i < len; i++) {
m+=a[i];
}
return m / len;
}
我们可以尝试使用一个 Go 包装器来使用这个函数,该包装器接受一个切片,并将长度传递给 C 函数:
func mean(a []float64) float64 {
if len(a) == 0 {
return 0
}
return float64(C.mean(C.int(len(a)), (*C.double)(&a[0])))
}
为了验证发生了什么,我们还可以创建一个传递了不正确长度的类似函数:
func mean2(a []float64) float64 {
if len(a) == 0 {
return 0
}
return float64(C.mean(C.int(len(a)*2), (*C.double)(&a[0])))
}
使用这个函数时,我们会看到应用程序不会引发任何分段错误,但得到的结果会有所不同。这是因为第二个将在平均计算中添加一系列额外的值,如下所示:
var a = make([]float64, 10)
func init() {
for i := range a {
a[i] = float64(i + 1)
}
}
func main() {
cases := [][]float64{a, a[1:4], a[:0], nil}
for _, slice := range cases {
fmt.Println(slice, mean(slice))
}
for _, slice := range cases {
fmt.Println(slice, mean2(slice))
}
}
使用结构
在了解了切片的工作原理之后,我们将知道如何在 C 和 Go 中使用结构处理复杂的数据。接下来让我们看看以下几节。
Go 中的结构
Go 结构使用一种称为对齐的技术,它包括向数据结构添加一个或多个字节,以使其更好地适应内存地址。考虑以下数据结构:
struct {
a string
b bool
c []byte
}
使用 64 位架构在这个结构上调用unsafe.Sizeof
,这将给我们一个意外的结果。我们期望的是以下结果:
-
16 字节来自字符串;8 字节用于指向第一个元素,8 字节用于长度
-
布尔值占 1 字节
-
24 用于切片;8 用于地址,8 用于长度,8 用于容量
总数应该是 41,但函数返回 48。这是因为编译器在布尔值之后插入了额外的字节,以达到 8 字节(64 位),并优化 CPU 的操作。该结构可以在内存中表示如下:
我们可以看到布尔变量占用 1 位,并且编译器添加了 7 位额外的位。这非常有帮助,因为它避免了其他变量存储在一个内存槽中的一半,另一半存储在另一个内存槽中。这将导致每次操作需要两次读取和两次写入,性能显著下降。
如果两个或更多字段足够小,可以适应 64 位的一个槽,它们将被顺序存储。我们可以通过以下示例看到这一点:
struct {
a, b bool
c rune
d byte
e string
}
这个结构在 64 位架构上的内存表示如下:
我们可以清楚地看到布尔变量,rune
和byte
都在同一个内存地址上,并且在最后一个字段上添加了一个字节的填充以对齐。
手动填充
Go 使得可以使用空白标识符手动指定结构中的填充。考虑以下数据结构:
struct{
a int32
b int32
}
这将有以下表示:
我们可以使用空白标识符手动指定填充,并为 64 位架构优化数据结构:
struct{
a int32
_ int32
b int32
_ int32
}
这将允许应用程序将每个int32
存储在自己的内存位置,因为空白字段将充当填充:
C 中的结构
C 中的结构共享与 Go 相同的对齐概念,但它们总是使用 4 字节填充对齐。与 Go 不同的是,可以完全避免填充,这有助于通过减少内存使用量来节省空间。让我们在以下几节中了解更多。
未打包的结构
除非另有说明,否则我们定义的每个结构都将是未打包的。我们可以在 C 中定义一个结构如下:
typedef struct{
unsigned char a;
char b;
int c;
unsigned int d;
char e[10];
} myStruct;
我们可以直接从我们的 Go 代码中使用它并填充它的值,而不会出现任何问题:
func main() {
v := C.myStruct{
a: C.uchar('A'),
b: C.char('Z'),
c: C.int(100),
d: C.uint(10),
e: [10]C.char{'h', 'e', 'l', 'l', 'o'},
}
log.Printf("%#v", v)
}
这个小测试将给我们以下输出:
main._Ctype_struct___0{
a:0x41,
b:90,
c:100,
d:0xa,
e:[10]main._Ctype_char{104, 101, 108, 108, 111, 0, 0, 0, 0, 0},
_:[2]uint8{0x0, 0x0},
}
这告诉我们有一个额外的空白字段用于填充,因为最后一个字段是 10 字节,比 4 的倍数(即 12 字节)短 2 字节。
紧凑结构
我们可以使用pragma pack
指令在 C 中定义一个紧凑的结构。我们可以将之前的结构打包如下:
#pragma pack(1)
typedef struct{
unsigned char a;
char b;
int c;
unsigned int d;
char e[10];
} myStruct;
如果我们尝试在我们的 Go 代码中使用 C 结构,如果使用字段c
和d
,我们将获得编译错误:
pack := C.myStruct{
a: C.uchar('A'),
b: C.char('Z'),
c: C.int(100),
d: C.uint(10),
e: [10]C.char{},
}
如果我们尝试像我们对未打包版本所做的那样打印结构,我们将看到原因:
main._Ctype_struct___0{
a:0x41,
b:90,
_:[8]uint8{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
e:[10]main._Ctype_char{104, 101, 108, 108, 111, 0, 0, 0, 0, 0},
}
从输出中我们可以看到,4 字节的c
和d
字段被一个 8 字节的空字段替换,Go 无法访问。因此,我们无法从 Go 中填充结构,但可以在应用程序的 C 部分访问这个字段:
myStruct makeStruct(){
myStruct p;
p.a = 'A';
p.b = 'Z';
p.c = 100;
p.d = 10;
p.e[0] = 'h';
p.e[1] = 'e';
p.e[2] = 'l';
p.e[3] = 'l';
p.e[4] = 'o';
p.e[5] = '\0';
p.e[6] = '\0';
p.e[7] = '\0';
p.e[8] = '\0';
p.e[9] = '\0';
return p;
}
这将允许我们返回一个带有正确值的结构。我们可以打印它并看到_
字段包含c
和d
的值:
main._Ctype_struct___0{
a:0x41,
b:90,
_:[8]uint8{0x64, 0x0, 0x0, 0x0, 0xa, 0x0, 0x0, 0x0},
e:[10]main._Ctype_char{104, 101, 108, 108, 111, 0, 0, 0, 0, 0}
}
现在我们有了数据,我们需要创建一个能够承载它的 Go 结构:
type myStruct struct {
a uint8
b int8
c int32
d uint32
e [10]uint8
}
现在,我们需要从 C 结构中读取原始字节并手动解包它:
func unpack(i *C.myStruct) (m myStruct) {
b := bytes.NewBuffer(C.GoBytes(unsafe.Pointer(i), C.sizeof_myStruct))
for _, v := range []interface{}{&m.a, &m.b, &m.c, &m.d, &m.e} {
binary.Read(b, binary.LittleEndian, v)
}
return
}
我们可以使用C.GoBytes
函数,它适用于任何指针(不仅仅是字节),并指定我们定义的结构的大小,该大小存储在常量C.sizeof_myStruct
中。然后,我们可以使用binary.Read
函数按顺序读取每个字段,使用小端(LE)编码。
我们可以看到生成的结构包含所有正确字段中的数据:
main.myStruct{
a:0x41,
b:90,
c:100,
d:0xa,
e:[10]uint8{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0, 0x0, 0x0, 0x0, 0x0},
}
CGO 建议
我们现在已经看到了如何在整数、浮点数、切片和结构体中使用 CGO。这是一个非常强大的工具,可以方便我们在 Go 应用程序中使用大量现有的 C 代码。就像我们在上一章中对反射所做的那样,我们现在要谈谈 CGO 的不太明显的缺点。
编译和速度
Go 的一个特点是编译速度非常快。使用 CGO 时,编译所涉及的工作量要大得多,不仅仅是将所有.go
文件传递给 Go 编译器。编译过程大致如下:
-
CGO 需要创建 C 到 Go 和 Go 到 C 的存根。
-
需要调用
make
命令来编译所有 C 源文件。 -
所有文件都合并在一个
.o
文件中。 -
系统的链接器需要验证 Go 和 C 之间的所有引用是否有效。
如果这个过程顺利进行,您可以启动您的应用程序,但如果遇到任何问题,您需要检查 C 和 Go 之间的错误,这并不像调试纯 Go 应用程序那样容易。
另一个缺点是,并非每个操作系统都自带make
命令。C 部分可能需要一些额外的标志才能正确编译,这不能由go install
或go build
处理。您需要为您的应用程序创建一个编译脚本,比如一个makefile
脚本。
性能
在讨论如何让 C 和 Go 相互通信时,我们看到对于每种类型,都需要执行一个转换操作。这对于数字来说可能很简单,但对于字符串、字节或切片来说可能会更复杂,当涉及到结构时甚至更复杂。这些操作不是免费的,无论是在内存使用还是性能方面。这对许多应用程序来说不是问题,但如果您试图实现高性能,这可能成为瓶颈。
C 代码并不知道其 Go 对应部分发生了什么。当需要调用它时,Go 需要以适合它的格式将有关其堆栈的信息传递给 C。当 C 代码完成执行时,需要将有关堆栈状态和使用的变量的信息从 C 传递回 Go。
来自 C 的依赖
使用 CGO 时,您面临着其他语言在创建与 C 代码绑定或包装时面临的相同问题。您完全依赖于它。
Go 应用程序必须处理 C 如何使用内存和其他资源,而 C 应用程序不知道 Go 在做什么,也不使用任何并发,既不是 goroutine 也不是线程。
除此之外,C 代码很难调试、维护和替换,如果您不是 C 开发人员。因此,有时最好从头开始编写一个库,而不是依赖现有的 C 实现。
一个很好的例子是go-git
(github.com/src-d/go-git
),它通过模仿现有的 C 库libgit2,在纯 Go 中实现了 Git 协议功能。
总结
在本章中,我们看到了 Go 工具中非常强大的一个工具:CGO。这允许 Go 应用程序运行 C 代码,反过来又可以调用 Go 函数。我们看到它需要一个特殊的import
语句,import "C"
,这是一个伪包,其中包含所有可供 Go 使用的 C 代码。要导出 Go 代码并使其可供 C 使用,有一个特殊的注释//export
,它使 Go 函数在 C 命名空间中可用。
我们看到 C 和 Go 类型系统在某些方面非常相似,但在其他方面非常不同。我们看到字符串和字节数组可以转换为 C 类型,反之亦然。C 和 Go 中的整数也非常相似,主要区别在于int
类型。在 C 中,这是 4 个字节,而在 Go 中,它是 4 或 8 个字节,取决于架构。浮点数也非常相似,在 C 和 Go 中都有 4 位和 8 位版本,只是名称不同。
还可以直接编辑数字 Go 变量或字节切片,而不创建副本。这是通过使用unsafe.Pointer
函数来强制进行转换,否则是不允许的。C 中的切片只是指向第一个元素的指针,切片的长度需要存储在另一个变量中。这就是为什么我们创建了接受切片并将两个参数传递给它们的 C 对应函数的 Go 函数。
在谈论数据结构之前,我们必须提到对齐是什么,Go 是如何实现对齐的,以及 C 对齐与 Go 的不同之处。CGO 中的数据结构使用对齐,并且非常容易使用。如果它们没有打包,我们可以轻松地传递它们并提取值。如果结构被打包,我们就无法访问其中的一些字段,需要一种解决方法来手动执行转换到 Go。
最后一个主题集中讨论了 CGO 的缺点,从构建时间较慢到性能下降,因为需要转换,以及由于 C 代码的存在,应用程序将变得更难维护。
希望你迄今为止享受了这段 Go 之旅,并且它将帮助你编写现代、并发和高效的应用程序。
问题
-
CGO 是什么?
-
如何从 Go 调用 C 代码?
-
如何在 C 中使用 Go 代码?
-
Go 和 C 之间的数据类型有什么区别?
-
如何在 C 代码中编辑 Go 值?
-
打包数据结构的主要问题是什么?
-
CGO 的主要缺点是什么?
第十七章:评估
第一章
- 应用程序和系统编程之间有什么区别?
应用程序编程侧重于为最终用户解决问题,而系统编程是关于创建其他软件使用的软件。
- 什么是 API?API 为什么如此重要?
API 是软件公开的用于控制其控制资源访问的接口。它描述了其他应用程序应该如何与软件通信。
- 你能解释一下保护环是如何工作的吗?
保护环是一种用于防止故障并增加安全性的系统。它以层次化的安全级别安排安全性,并通过使用特定的网关允许对更强大级别的功能进行中介访问。
- 你能举一些在用户空间无法执行的例子吗?
用户空间中的应用程序不能将其当前空间更改为内核,也不能忽略文件系统访问硬盘,并且不能更改页表。
- 什么是系统调用?
系统调用是操作系统提供的 API,用于访问计算机的资源。
- Unix 用哪些调用来管理进程?
Unix 用于管理进程的调用如下:fork
、exit
和wait
。
-
POSIX 为什么有用?各种 POSIX 标准定义了进程控制、信号、分段、非法指令、文件和目录操作、管道、I/O 控制和 C 库、shell 和实用程序,以及实时和多线程扩展。对于开发人员来说,它非常有用,因为它有助于构建与共享此标准的不同操作系统兼容的应用程序。
-
Windows 是否符合 POSIX?Windows 不符合 POSIX,但正在尝试提供 POSIX 框架,例如 Windows Linux 子系统。
第二章
- 现代操作系统使用哪种文件系统?
现代操作系统使用不同的文件系统:Windows 和 macOS 使用各自专有的格式 NTFS 和 APFS,而 Linux 系统主要使用 EXT4。
- 什么是 inode?Unix 中的 inode
0
是什么?
inode 是表示文件的文件系统数据结构。它存储有关文件的信息,但不包括名称和数据。
inode 0
保留给/
文件夹。
- PID 和 PPID 之间有什么区别?
PID 是现有进程的唯一标识符,而 PPID 是父进程的标识符。当现有进程创建另一个进程时,新进程的 PPID 等于现有进程的 PID。
- 如何终止后台运行的进程?
虽然SIGINT
信号可以通过按Ctrl + C发送给前台进程,但对于后台进程,信号需要使用kill
命令发送,此时为kill -2 PID
。
- 用户和组之间有什么区别?
用户标识一个可以拥有文件和进程的帐户,而组是在文件上共享权限的机制。
- Unix 权限模型的范围是什么?
Unix 权限模型使得可以通过三种不同级别的权限来限制对文件的访问:所有者、组和所有其他用户。
- 你能解释一下信号和退出代码之间的区别吗?
信号和退出代码都是进程之间的通信方法,但信号是从任何进程到另一个进程,而退出代码用于从子进程到其父进程的通信。
- 什么是交换文件?
交换文件是用于存储不需要的页面以释放主内存的物理内存的扩展。
第三章
- 导出符号和未导出符号有什么区别?
导出符号可以被其他软件包使用,而未导出符号不能。第一组具有以大写字母开头的标识符,而第二组没有。
- 自定义类型为什么重要?
自定义类型允许定义方法并有效地使用接口,或者继承另一种类型的数据结构,但是要摆脱它的方法。
- 短声明的主要限制是什么?
短声明不允许推断出值的变量类型。通过对值进行类型转换,可以克服这种限制。
- 作用域是什么,它如何影响变量遮蔽?
变量的作用域代表了它的生命周期和可见性,可以是包、函数或块。当相同的标识符在内部作用域中使用时,会发生遮蔽,阻止外部作用域共享该标识符的符号访问。
- 如何访问一个方法?
方法是一种特殊类型的函数,它们具有与其所属类型相关联的命名空间。它们可以作为其类型实例的属性访问,也可以作为类型本身的属性访问,将实例作为第一个参数传递。
- 解释一下一系列
if
/else
语句和switch
语句之间的区别。
一系列的if
和else
语句允许对每个if
语句执行一个简短的声明,并且只会执行一个 case,跳过后续的声明。switch
语句只允许一个声明,并且可以使用continue
和break
语句修改流程。
- 在典型的用例中,通常谁负责关闭通道?
通道应该由发送方关闭,因为发送方负责通知没有更多信息要发送。此外,向关闭的通道发送会引发恐慌,而从中接收是一个非阻塞操作。
- 什么是逃逸分析?
逃逸分析是 Go 编译器执行的优化过程,试图通过验证变量是否超出了它们定义的函数的生存期来减少在堆中分配的变量。
第四章
- 绝对路径和相对路径有什么区别?
绝对路径以/
(根)路径开头,而相对路径不是。要从相对路径获取绝对路径,必须将其连接到当前工作目录。
- 如何获取或更改当前工作目录?
要找出当前工作目录,os
包提供了Getwd
函数,它返回当前工作目录。要更改当前工作目录,必须使用Chdir
函数。它接受相对路径和绝对路径。
- 使用
ioutil.ReadAll
的优缺点是什么?
ioutil.ReadAll
函数将整个文件内容放入一个字节切片中,因此文件的大小会影响分配和释放的内存量。由于这种方式分配的内存没有回收利用,这些切片在不再使用时会被垃圾回收。
- 为什么对于读取操作来说,缓冲区很重要?
字节缓冲区限制了读取操作分配的内存量,但它们也需要一定数量的读取操作,每个操作都带有一些影响速度和性能的开销。
-
何时应该使用
ioutil.WriteFile
?如果内容的大小不是太大,可以使用ioutil.WriteFile
函数,因为整个内容需要在内存中。在短期应用中最好使用它,并且避免在频繁写入操作中使用它。 -
使用允许窥视的缓冲区读取时可以进行哪些操作?
窥视操作允许检查下一个字节的内容,而不会推进当前读取器的光标,这使我们能够进行上下文操作,例如读取单词、读取行或任何基于自定义标记的操作。
- 何时最好使用字节缓冲区读取内容?
使用读取缓冲区是降低应用程序内存使用的一种方式。当不需要一次性获取所有内容时,可以使用它。
- 缓冲区如何用于写入?使用它们的优势是什么?
在写入操作中,应用程序已经处理了即将写入的字节,因此使用底层缓冲区来优化系统调用的次数,只有当缓冲区满时才会进行系统调用的添加,以避免在传递给写入器的数据不足时增加系统调用开销。
第五章
- 什么是流?
流是表示通用传入或传出数据流的抽象。
- 哪些接口抽象了传入流?
io.Reader
接口是用于传入流的抽象。
- 哪个接口代表传出流?
io.Writer
接口是用于传出流的抽象。
- 何时应该使用字节读取器?何时应该使用字符串读取器?
当原始数据是字节片时应该使用字节读取器,而当原始数据是字符串时应该使用字符串读取器。从一种数据类型转换为另一种会导致复制并且不方便。
- 字符串构建器和字节缓冲区有什么区别?
字节缓冲区可以被重用和覆盖。字符串构建器用于创建一个字符串而不是复制,因此它使用一个字节切片并将其转换为字符串而不复制,使用unsafe
包。
- 为什么读取器和写入器的实现应该接受接口作为输入?
接受接口作为输入意味着对具有相同行为的不同类型持开放态度。这使得现有的读取器和写入器,如缓冲区和文件,可以被使用。
- 管道与
TeeReader
有何不同?
管道将写入器连接到读取器。无论写入了什么,读取器都会读取。TeeReader
则相反,将读取器连接到写入器,因此读取的内容也会被写入到其他地方。
第六章
-
什么是终端,什么是伪终端? 终端是一个行为类似于电传打字机的应用程序,通过显示一个 2x2 的字符矩阵。伪终端是在终端下运行并通过交互来模拟其行为的应用程序。
-
伪终端应该具备什么功能? 伪终端应用程序应该能够接收用户输入,根据接收到的指令执行操作,并将结果显示给用户。
-
我们使用了哪些 Go 工具来模拟终端? 为了管理用户输入,我们在标准输入中使用了一个缓冲扫描器,它将逐行读取用户输入。每个命令都是使用相同的接口实现的。为了理解调用的命令,我们使用了第一个参数和可用命令之间的比较。一个写入器被传递给命令来打印它们的输出。
-
我的应用程序如何从标准输入获取指令? 应用程序可以使用标准输入结合扫描器,每次遇到新行时都会返回一个新的标记。
-
使用接口命令有什么优势? 使用接口命令允许我们和我们包的用户通过实现他们自己的接口版本来扩展行为。
-
什么是莱文斯坦距离?为什么在伪终端中有用? 莱文斯坦距离是将一个字符串转换为另一个字符串所需的更改次数。当用户指定一个不存在的命令时,它可以用于向用户建议其他命令。
第七章
- Go 应用程序内部的当前进程可用的应用程序有哪些?
进程可用的应用程序有 PID(进程 ID)、PPID(父进程 ID)、UID 和 GID(用户和组 ID)以及工作目录。
- 如何创建子进程?
exec.Cmd
数据结构可用于定义子进程。当调用Run
、Start
、Output
和CombinedOutput
方法之一时,进程将被创建。
- 如何确保子进程在其父进程之后继续存在?
在 Unix 系统中,默认情况下,如果父进程终止,子进程会继续存在。此外,您可以更改子进程的进程组和会话 ID,以确保其继续存在。
- 可以访问子属性吗?它们如何使用?
最大的优势之一是访问子 PID 以将其持久化在某个地方,例如磁盘上。这将允许应用程序的另一个实例或任何其他应用程序知道子进程的标识符,并验证它是否仍在运行。
- 在 Linux 中,守护进程是什么,它们是如何处理的?
在 Linux 中,守护进程是在后台运行的进程。为了创建一个守护进程,进程可以创建自身的一个分支并终止,将init
进程设置为分支的父进程,将当前工作目录设置为分支的根目录,将子进程的输入设置为null
,并使用日志文件进行输出和错误处理。
第八章
- 退出代码是什么?谁使用它?
退出代码是从进程传递给其父进程的整数值,用于表示进程结束的结果。如果没有错误,则为0
。父进程可以使用此值决定下一步该做什么,例如,如果出现错误,则再次运行进程。
- 应用程序发生 panic 时会发生什么?返回什么退出代码?
如果panic
没有被恢复,应用程序将执行所有延迟函数,并以状态2
退出。
- Go 应用程序在接收所有信号时的默认行为是什么?
Go 应用程序在处理信号时的默认行为是早期退出。
- 你如何拦截信号并决定应用程序的行为?
可以使用signal.Notify
方法在通道上拦截接收到的信号,指定要处理的信号类型。通道接收到的值可以与信号值进行比较,并相应地应用程序可以表现出不同的行为。
- 你能向其他进程发送信号吗?如果可以,怎么做?
在 Go 应用程序中,可以向另一个进程发送信号。为了做到这一点,应用程序需要使用查找函数获取os.Process
结构的实例,然后可以使用该结构的Signal
方法发送信号。
- 管道是什么,它们为什么重要?
管道是两个流,一个是输出流,另一个是输入流,它们连接在一起。输出中写入的内容可以在输入中使用,这有助于将一个进程的输出连接到另一个进程的输入。
第九章
- 使用通信模型的优势是什么?
通信模型允许您抽象处理模型中处理的数据类型,使不同端点之间的通信变得容易。
- TCP 和 UDP 连接之间有什么区别?
TCP 是面向连接的,这使得它可靠,因为它在发送新数据之前验证目标是否正确接收数据。UDP 连接持续发送数据,而不确认目标是否接收了数据包。这可能导致数据包丢失,但它使连接更快,不会积累延迟。
- 发送请求时,谁关闭请求体?
在进行 HTTP 调用时关闭请求是应用程序的责任。
- 在服务器接收请求时,谁关闭请求体?
当连接关闭时,请求体会自动关闭,但服务器也可以在更早的时候关闭它,如果它愿意的话。
第十章
- 文本和二进制编码之间的权衡是什么?
基于文本的编码对人类来说更容易阅读,也更容易调试和编写,但由于这个原因占用更多的空间。二进制编码对人类来说更难编写、阅读和调试,但尺寸更小。
- Go 在编码时默认如何处理数据结构?
Go 的默认行为是使用反射来读取字段及其值。
- 这种行为如何改变?
通过实现你正在使用的编码的编组器接口,如json.Marshaller
用于 JSON,可以改变这种行为。
- 结构字段如何在 XML 属性中编码?
结构字段需要在其标签中指定,attr
值。
- 解码
gob
接口值需要什么操作?
实现接口的数据类型需要使用gob.Register
函数在gob
包中注册。
- **什么是协议缓冲编码?**协议缓冲是由谷歌制定的一种编码协议,它使用定义文件来定义数据结构和服务。该文件用于生成数据模型、客户端和服务器存根,只留下服务器的实现给开发人员。
第十一章
- 什么是线程,谁负责它?
线程是进程的一部分,可以由特定的核心或 CPU 分配。它携带有关应用程序状态的信息,就像进程一样,并由操作系统调度程序管理。
- goroutine 与线程有什么不同?
与线程相比,goroutine 非常小,比例为 1:100,并且它们不受操作系统管理。Go 运行时负责调度 goroutine。
- 在启动 goroutine 时何时评估参数?
启动 goroutine 的函数传递的所有参数在创建 goroutine 时进行评估。这意味着如果参数的值在 goroutine 实际被调度程序选中并启动之前发生变化,那么这种变化不会反映在 goroutine 中。
- 缓冲和非缓冲通道有什么不同?
如果未指定容量,或者为0
,则make
函数创建非缓冲通道。对这样的通道的每次发送操作都会阻塞当前的 goroutine,直到另一个 goroutine 执行接收操作。缓冲通道可以支持等于其容量的非阻塞发送操作数量。这意味着如果通道的容量为n
,那么前n-1
个未被任何接收操作匹配的发送操作将不会阻塞。
- 为什么单向通道有用?
它们只允许一部分操作,清楚地告诉用户通道的范围。只接收通道不允许发送数据,或关闭它,这是有道理的,因为这不是接收者的责任。只发送通道不允许接收数据,但允许发送和关闭通道,并暗示发送者关闭通道以表示没有更多数据。
- 当对
nil
或关闭的通道执行操作时会发生什么?
向nil
通道发送或接收会永久阻塞,关闭它会导致恐慌。从关闭的通道接收会立即返回零值和false
,而向关闭的通道发送会引发恐慌,如果再次尝试关闭它也会发生相同的情况。
- 计时器和滴答器用于什么?
计时器和滴答器都创建一个只接收通道。计时器可以在循环中与select
语句一起使用,而不是使用default
,以减少选择的频率并降低应用程序在空闲时的 CPU 使用率。滴答器非常适用于在固定时间间隔内执行操作,而一个实际的用途是速率限制器,它限制了在应用程序的某个部分内在一定时间段内执行的次数。
第十二章
- 什么是竞争条件?
竞争条件是应用程序试图同时在同一资源上执行两个操作的情况,而资源的性质只允许一次操作。
- 当尝试在映射上同时执行读取和写入操作时会发生什么?
当同时发生对映射的读取和写入操作时,会导致运行时错误:concurrent map writes
。
- **
Mutex
和RWMutex
**之间有什么区别?
常规互斥锁允许锁定和解锁资源,并且每个操作的优先级相同。读/写互斥锁有两种类型的锁,一种用于每个操作(读/写)。读锁允许同时进行多个操作,同时它是排他的。如果资源上有许多连续的读操作,写锁可能会受到延迟。这被称为写饥饿。
- 等待组有什么用?
等待组是与不同 goroutine 的执行同步的完美工具。这使得在经典设置中有多个并发操作时,主 goroutine 必须等待它们结束才能继续的解决方案变得干净而优雅。
- **
sync.Once
**的主要用途是什么?
sync.Once
可用于在一次性执行并发操作。例如,它可用于一次关闭通道并避免恐慌。另一个用例是延迟初始化变量以实现单例设计模式的线程安全版本。
- 你如何使用池?
池允许重复使用短暂的项目。池的一个很好的用例是字节片和字节缓冲区,因为池将防止这些资源被垃圾收集器回收,同时防止分配新的池。
- 使用原子操作的优势是什么?
对于数字变量使用互斥锁会有很大的开销。原子操作可以减少这种开销,并在数字变量上执行线程安全操作。它的主要用途是整数,但通过一些转换,我们可以对其他类型进行相同的操作,例如布尔值和浮点数。
第十三章
- Go 中的上下文是什么?
上下文是一个包,包含一个通用接口和一些辅助函数来返回上下文实例。它用于在应用程序的各个部分之间同步操作和携带值。
- 取消、截止日期和超时之间有什么区别?
上下文有三种不同类型的过期——取消是应用程序显式调用取消函数,截止日期是上下文超过指定时间,超时是上下文经历特定持续时间。
- 传递上下文值时的最佳实践是什么?
使用上下文传递的值应与当前范围或请求相关。它们不应该被用作传递可选函数参数或对应用程序至关重要的变量的方式。使用自定义私有类型作为键也是一个好主意,因为内置值可能被其他包覆盖。指向值的指针也是解决此类问题的一种方法。
- 哪些标准包已经使用了上下文?
有不同的包使用上下文。最值得注意的是net/http
,它使用上下文进行请求和服务器关闭;net
使用上下文进行Dial
或Listen
等功能;database/sql
使用上下文来取消查询等操作。
第十四章
- 生成器是什么?它的责任是什么?
生成器是一个返回一系列值的工具——每次调用时,它返回系列中的下一个值。它负责按需生成序列中的值。在 Go 中,可以通过使用通道来接收由创建它们的 goroutine 发送的值来实现这一点。
- 你会如何描述一个管道?
管道是一种将执行分割成不同阶段的应用程序流程。这些阶段通过某种通信方式进行通信,例如网络,或者运行时内部,例如通道。
- 什么类型的阶段获取一个通道并返回一个?
中间阶段将从一个只接收通道接收并返回另一个只接收通道。
- 扇入和扇出之间有什么区别?
Fan-in 也被称为分集,它涉及从不同来源收集消息到一个地方。Fan-out,或多路复用,是相反的——它涉及将单个消息源分发给更多的接收者。
第十五章
- 在 Go 中,接口的内存表示是什么?
在 Go 中,接口由两个值表示——第一个是接口的具体类型,而第二个是该类型的值。
- 当接口类型转换为另一个接口类型时会发生什么?
由于接口值需要是一个具体的值,而不能是另一个接口,所以会创建一个具有不同类型和相同具体值的新接口。
- 在反射中,
Value
,Type
和Kind
是什么?
正如其名称所示,Value
代表变量的内容;Type
代表变量的 Go 类型;Kind
是Type
的内存表示,仅指内置类型。
- 值是可寻址的意味着什么?
可寻址值是可以被编辑的值,因为它是通过指针获得的。
- 为什么 Go 中的结构字段标签很重要?
结构字段标签是一种简单的方法,可以使用反射Type
接口添加关于结构字段的额外信息,这样做很容易阅读。
- 反射的一般权衡是什么?
反射允许您的代码处理未知类型的数据,并使您的包或应用程序通用化,但它会带来性能成本。它还使代码更加晦涩和难以维护。
- 您能描述在使用反射时的一个良好方法吗?
反射的最佳方法是我们在标准库的许多不同部分找到的方法;例如,在encoding
包中。它们将反射作为最后的手段,并通过为编码和解码操作提供接口来实现。如果这些接口由某种类型满足,包将使用相应的方法,而不是依赖于反射。
第十六章
- CGO 是什么?
CGO 是一个强大的 Go 工具,用于处理 C 代码和 Go 代码之间的通信。这允许 C 代码在 Go 应用程序中使用,并利用现有的大量 C 库。
- 如何从 Go 调用 C 代码?
Go 提供了一个名为C
的伪包,暴露了 C 类型,如C.int
,以及一些函数,将 Go 字符串和字节转换为C
字符数组,反之亦然。在导入C
包之前的注释将被解释为 C 代码,并且其中定义的所有函数(无论是直接定义还是通过导入文件)都将作为C
包的函数在 Go 中可用。
- 如何在 C 中使用 Go 代码?
如果 Go 函数前面有一个特殊的注释//export
,这个函数将对 C 代码可用。它还必须在 C 中定义为外部函数。
- Go 和 C 之间的数据类型有什么区别?
即使它们具有不同的数据类型,C 和 Go 共享大部分内置的数字类型。Go 中的字符串是一种内置的不可变类型,但在 C 中,它们只是以\0
值终止的字符数组。
- 如何在 C 代码中编辑 Go 值?
使用unsafe
包,您可以将在 C 和 Go 中具有相同内存表示的数据类型进行转换。您需要将指针转换为其 C 对应值,这将允许您从应用程序的C
部分编辑指针内容。
- 紧凑数据结构相关的主要问题是什么?
紧凑的数据结构可以节省内存空间,但它们的字段可能不对齐,这意味着它们分布在多个内存区域之间。这意味着读写操作需要两倍的时间。还有另一个不便之处——一些紧凑的字段无法直接从 Go 中访问。
- CGO 的主要缺点是什么?
即使它是一个非常强大的工具,CGO 也有许多缺点——从 C 到 Go 的性能成本,反之亦然;编译时间增加,因为 C 编译器参与了这个过程;以及你的 Go 代码依赖于你的 C 代码工作,这可能更难以维护和调试。