本篇博客旨在提醒读者注意一些在 Go 语言中易犯的错误,不仅包括语法和语义级别的问题,还包括一些最佳实践和规范。通过深入理解这些错误,我们可以更好地规避潜在的风险,写出更高效、更稳定的 Go 代码。
在我们开始探索这些错误之前,让我们一同回顾一下“在错误中学习,不断成长”的理念。编程世界中,错误不是失败的代名词,而是成长的机会。当我们深入了解常见错误时,我们更能够逐步提升自己的编程技能,写出更加健壮的代码。
愿这篇博客能够帮助你更好地使用 Go 语言,避免一些不必要的困扰。让我们开始我们的探索之旅,一同领略 Go 语言的优雅之美,并在编程的路上越走越远。
Happy coding! 🚀
append操作是并发不安全的,在使用过程中,需要特别注意。下面代码中是有问题的:
1 | func append_to_slice(s []int, i int) { |
解决办法之一是我们可以使用sync.Mutex进行加锁处理。
使用内置copy函数进行切片复制时候,目标切片长度需要设置为要复制的数量。下面代码是有问题的,即使设置了dist容量,但由于dist长度是0,最终没有任何src数据会复制到dist中。
1 | var src = []int{1, 2, 3} |
修复后版本:
1 | var src = []int{1, 2, 3} |
切片包含了指向底层数据的指针,是一种“引用类型”数据。在使用切片作为函数或方法参数传递过程中,需要注意函数内部如果修改了该切片,可能导致函数外部使用时候会造成问题。
我们可以看下这个例子:
1 | func myfunc(nums []int) { |
解决办法使用copy函数复制一份:
1 | func myfunc(nums []int) { |
安全套接字层 (SSL,全称Secure Sockets Layer) 和传输层安全 (TLS,全称Transport Layer security) 是通过计算机网络或链接提供安全通信的协议。它们通常用于网页浏览和电子邮件。在本教程中,我们将了解学习到:
TLS 基于 SSL,并作为替代方案而开发以应对 SSLv3 中的已知漏洞。SSL 是常用术语,我们说的 SSL 通常指的就是 TLS。
SSL/TLS 提供数据加密、数据完整性和身份验证功能。这意味着当使用 SSL/TLS 时,你可以确保:
在两方之间发送消息时,你需要解决两个问题。
这些问题的解决办法是:
这两个过程都需要使用密钥。这些密钥只是数字(常见的是 128 位),然后使用特定方法(通常称为算法)与消息组合,例如RSA,对消息进行加密或签名。
当今使用的几乎所有加密方法都使用公钥和私钥。这些被认为比旧的对称密钥布置安全得多。
使用对称密钥时,使用一个密钥对消息进行加密或签名,并使用同一密钥对消息进行解密这和我们日常生活中接触到的钥匙(门、车钥匙)是一样的。这种钥匙布置的问题是,如果你丢失了钥匙,任何找到它的人都可以打开你的门。
对于公钥和私钥,使用两个在数学上相关的密钥(它们属于密钥对),但又不同。
这意味着用公钥加密的消息不能用相同的公钥解密。
要解密消息,你需要私钥。如果你的汽车使用了这种类型的钥匙布置。然后你可以锁车,并将钥匙留在锁中,因为同一把钥匙无法解锁汽车。
这种类型的密钥排列非常安全,并且用于所有现代加密/签名系统。
SSL/TLS 使用公钥和私钥系统进行数据加密和数据完整性。公钥可以提供给任何人,因此称为“公钥”。
正应为公钥提供给任何人,因此存在信任问题,具体来说:你如何知道特定的公钥属于它声称的个人/实体。例如,你收到一把声称属于你的银行的钥匙。
你怎么知道它确实属于你的银行?答案是使用数字证书(digital certificate)。
证书的用途与日常生活中的护照相同。护照在照片和个人之间建立了链接,并且该链接已由受信任的机构(护照办公室)验证。
数字证书提供公钥和实体(企业、域名等)之间的链接,该实体已由受信任的第三方(证书颁发机构)验证(签名)。数字证书提供了一种分发可信公共加密密钥的便捷方法。
你从公认的 证书颁发机构 (CA,全称 Certificate authority) 获得数字证书。就像你从护照办公室领取护照一样,事实上,两者过程非常相似。
首先你需要填写适当的表格,添加你的公钥(它们只是数字)并将其发送给 证书颁发机构(Issuing Certificate authority) 。 这个过程叫做证书请求(certificate Request)。
接着证书颁发机构会进行一些检查(取决于颁发机构),然后将证书中包含的密钥发回给你。
由于证书是由颁发证书的机构进行了签名,这保证了密钥安全性。现在,当有人想要你的公钥时,你向他们发送证书,他们验证证书上的签名,如果验证通过,那么他们就可以信任你的密钥。
为了说明这一点,我们将查看使用SSL(https)的典型Web浏览器和Web服务器之间的连接。此连接用于在互联网上通过 Gmail 等发送电子邮件以及进行网上银行、购物等。
这是一个视频,更详细地介绍了上述内容:
如果你尝试为网站购买证书或用于加密 MQTT,你将遇到两种主要类型:
两种类型的区别在于对证书的信任程度,EVC它具有更严格的验证,不过他们提供的加密级别是相同的。
域验证证书(DV)是X.509数字证书,通常用于传输层安全性(TLS),其中通过证明对DNS域的某些控制来验证申请人的身份。
DV验证过程通常是完全自动化的,这使得它们成为最便宜的证书形式。它们非常适合在像本网站这样提供内容的网站上使用,而不是用于敏感数据。
扩展验证证书 (EV) 是用于 HTTPS 网站和软件的证书,用于证明控制网站或软件包的法人实体。获取 EV 证书需要证书颁发机构 (CA) 验证请求实体的身份。它们通常比域验证证书更昂贵,因为它们涉及手动验证。
通常,证书可在单个 完全限定域名 (FQDN) 上使用。也就是说,为在 www.mydomain.com 上使用而购买的证书不能在 mail.mydomain.com 或 www.otherdomain.com 上使用。但是,如果你需要保护多个子域以及主域名,那么你可以购买通配符证书。通配符证书涵盖特定域名下的所有子域。
例如,*.mydomain.com 的通配符证书可用于:
它不能用于保护 mydomain.com 和 myotherdomain.com。
要在单个证书中涵盖多个不同的域名,你必须购买具有 SAN(Subject Alternative Name) 的证书。
除了主域名之外,这些域名通常允许你获得 4 个额外的域名。例如,你可以在以下位置使用相同的证书:
你还可以更改所涵盖的域名,但需要重新颁发证书。
使用免费软件工具可以非常轻松地创建你自己的 SSL 证书和加密密钥。这些密钥和证书与商业密钥和证书一样安全,并且在大多数情况下可以被认为更安全。
当你的证书需要广泛支持时,商业证书是必要的。这是因为大多数 Web 浏览器和操作系统都内置了对主要商业证书颁发机构的支持。
如果你访问此站点时我在该站点上安装了自己生成的证书,你将看到一条类似下面的消息,告诉你该站点不受信任。
证书可以编码为:
数字证书使用的常见文件扩展名是:
**注意:**文件扩展名和编码之间没有真正的关联。这意味着 .crt 文件可以是 .der 编码文件或 .pem 编码文件。
我如何知道你是否有 .der 或 .pem 编码文件?
你可以使用 openssl 工具查找编码类型并在编码之间进行转换。请参阅本教程 – DER 与 CRT 与 CER 与 PEM 证书。
你还可以打开该文件,如果它是 ASCII 文本,那么它是 .PEM 编码的证书
由于 .pem 编码的证书是 ASCII 文件,因此可以使用简单的文本编辑器读取它们。
需要注意的重要一点是,它们以“Begin Certificate”和“ End Certificate ”行开始和结束。证书可以存储在自己的文件中,也可以一起存储在称为捆绑(bundle)包的单个文件中。
尽管根证书作为单个文件存在,但它们也可以组合成一个包(bundle)。
在基于 Debian 的 Linux 系统上,这些根证书与名为 ca-certificates.crt 的文件一起存储在 /etc/ssl/certs 文件夹中。该文件是系统上所有根证书的捆绑包。它由系统创建,如果使用 update-ca-certificates 命令添加新证书,则可以更新它。
ca-certifcates.crt 文件内容格式如下所示:
certs 文件夹还包含每个单独的证书或证书的符号链接以及哈希值。
哈希文件由 c_rehash 命令创建,并在指定目录而不是文件时使用。例如,mosquitto_pub 工具可以运行为:
1 | mosquitto_pub --cafile /etc/ssl/certs/ca-certificates.crt |
证书颁发机构可以创建负责向客户端颁发证书的从属证书颁发机构。
对于要验证证书真实性的客户端,它需要能够验证链中所有 CA 的签名,这意味着客户端需要访问链中所有 CA 的证书。客户端可能已经安装了根证书,但可能还没有安装中间 CA 的证书。
因此,证书通常作为 证书包(certificate bundle) 的一部分提供。该捆绑包将包含单个文件中链中的所有 CA 证书,通常称为 CA-Bundle.crt。如果你的证书是单独发送的,你可以创建自己的捆绑包。
如果你遇到证书链问题,那么此站点有一个测试工具,并提供有关如何解决问题的详细信息
Q: 什么是值得信赖的商店?
A: 这是你信任的 CA 证书的列表。所有网络浏览器都带有受信任的 CA 列表。
Q: 我可以将自己的 CA 添加到浏览器信任存储中吗?
A: 是的,在 Windows 上,如果右键单击证书,你应该会看到一个安装选项
Q: 什么是自签名证书(self signed certificate)?
A: 自签名证书是由证书验证的同一实体签署的证书。这就像你批准自己的护照申请一样。参见维基百科。
Q: 什么是证书指纹(certificate fingerprint)?
A: 它是实际证书的哈希值,可用于验证证书,而无需安装 CA 证书。这对于没有大量内存来存储 CA 文件的小型设备非常有用。手动验证证书时也会使用它。请参阅此处了解更多详情。
Q: 如果服务器证书被盗,会发生什么情况?
A: 可以撤销。客户端(浏览器)可以通过多种方式检查证书是否被吊销,请参阅此处。
]]>Rust
是一个相当新的编程语言(它诞生于20101年),但在开发嵌入式固件方面显示出巨大的潜力。它首先被设计为一种系统编程语言,这使得它特别适合用于微控制器。它试图通过实现一个强大的所有权模型(可以消除整个错误类的发生)来改进 C/C++
的一些最大缺点,这对固件也非常适用。
截至2022年,C
和 C++
编程语言仍然是嵌入式固件的事实标准。然而 Rust
在固件中的角色看起来很光明。Rust
对固件的支持并不是后面才考虑到,而是一开始就考虑支持。 为此,Rust
专门有官方的 嵌入式设备工作组 和 介绍如何使用 Rust
进行嵌入式开发的 嵌入式Rust之书。下图就是Rust嵌入式设备工作组logo2。
本篇文章旨在探索在微控制器(这里指的是低级嵌入式固件,而不是在 Linux
等主机环境上运行)上运行 Rust
,涵盖以下内容:
让我们探索 Rust
的一些语言特性以及把它们如何应用于嵌入式固件中。
Rust
与 C/C++
的核心区别之一是 Rust
实现了一个强大的所有权模型。这可以预防 C/C++
中可能出现的许多与内存相关的错误(例如内存泄漏、悬挂指针等)。Rust
的这些优势,不仅适用于软件,也适用于嵌入式固件。
对于除了基本原始数据类型之外的任何存在于堆栈上的数据类型(基本原始数据类型包括 u32
、bool
、f64
等),Rust
在使用赋值运算符时都会移动数据,而不是执行复制。下面示例显示编译器如何强制一次只有一个变量可以拥有一段数据:
1 | let s1 = String::from("hello"); // Create complex data type which involves the use of the heap |
如果你确实想执行拷贝,正确作用法是 s2 = s1.clone()
。这些所有权规则也适用于向函数传递变量。你可以在《Rust编程语言》第4章“理解所有权”中找到详细介绍。
除了转移所有权, Rust还允许你通过引用“借用”数据。你只被允许:
编写固件的一个重要部分是与外围设备(外围设备简称外设,比如 GPIO
、UART
、USB
、DMA
等)进行交互。大多数外设都是内存映射的——即你需要通过读/写“魔术”内存地址(magic adress)来控制外设。在 Rust
中访问外设的标准方式是使用外设访问((Peripheral Access)库或 PAC
。很有可能你使用的特定微控制器已经有了PAC
。
例如,cortex_m
库 提供对所有 Cortex-M
设备共享的外设的访问(例如 NVIC 中断
、SysTick
)。你可以通过调用 take().unwrap()
来“声明”外设:
1 | use cortex_m::interrupt; |
请注意,这里采用了单例模式 - 你只能调用 take()
一次并使其返回 Some<T>
。下次它将返回 None
,然后调用 unwrap()
会导致 panic
。你通常需要会在 main()
或 App类的开始进行 take()
。
对于那些不是 Cortex-M
架构一部分的其他外设(例如 UART
、定时器、PWM
等)通常可以在特定微控制器的不同库中找到。例如,如果我使用 STM32F30x
微控制器,我会添加适当的 PAC
库,然后可以编写:
1 | let mut peripherals = stm32f30x::Peripherals::take().unwrap(); // Again, will work only once! |
Rust 还可以在编译时提供编译时检查,确保硬件已根据代码的使用方式进行了正确配置。正如《嵌入式Rust之书》一书所说:
当应用于嵌入式程序时,这些静态检查可以用于确保正确配置I/O接口。例如,可以设计一个API,其中只有首先配置串口接口将使用的引脚,才能初始化串口接口。
还可以静态检查仅在正确配置的外设上执行操作(如设置引脚为低电平)是否有效。例如,试图改变配置为浮空输入模式的引脚的输出状态会引发编译错误。——来自《嵌入式Rust之书》中静态保证3
让我们用一个例子来解释这一点。我们遵循《嵌入式Rust之书》指南,使用 GPIO
引脚(MCU外设的一种基本形式)作为示例,使用 into_...()
命名函数在不同类型之间转换。
1 | let pin = get_gpio(); |
svd2rust
是一个命令行工具,可以提取 SVD
文件(又名 CMSIS-SVD
,它们是定义寄存器名称、地址和用途的文件,你可以将它们视为微控制器数据表的计算机可读版本)并创建 Rust PAC
包在类型安全的 Rust API
4 中公开外围设备。目前它支持 Cortex-M
、MSP430
、RISCV
和 Xtensa LX6
微控制器4。
Rust
通过其 特征(trait) 支持 临时多态性(ad-hoc polymorphism)。一个常见的例子是浮点数和整数类型都实现了 Add
特征,因为它们可以相加。embedded-hal 项目利用特征来定义 GPIO
引脚(输入和输出)、UART
、I2C
、SPI
、ADC
等。这些通用接口可以被应用程序代码使用,而底层具体的驱动程序为每个特定的微控制器实现正确的功能。这与 C++
中如何使用虚拟接口类来创建可移植的 HAL
非常相似。在本篇的文章 cargo和包结构部分 会有更多相关内容介绍。
例如,serial::Read
和 Write
特征被定义为5。
1 | pub trait Read<Word> { |
在 Rust
中,如果你索引一个数组,它会自动进行边界检查(bounds checking)。这可以防止在 C/C++
中尝试相同的操作时出现大量微妙的“未定义行为错误”(以及安全问题!)。当然,边界检查确实会产生少量的运行时开销(在 99% 的用例中这可能可以忽略不计)。
1 | fn main() { |
如果将数组引用传递给函数,那么如果索引越界,Rust
将无法在编译时进行计算。在这种情况下,它将在运行时进行边界检查并出现恐慌:
1 | fn main() { |
如果你的应用程序需要考虑边界检查的运行时开销,那么你可以通过使用数组迭代器而不是索引(或使用 get_unchecked()
)来消除此开销。事实上,这是访问数组的推荐方法,除非你确实必须使用索引(某些情况仍然需要随机访问数组)。
你可能还注意到,数组不会像在 C/C++
中那样容易地退化为指针(即丢失维度信息 - sizeof
现在为你提供指针的大小)。在 Rust
中,你可以将对任何大小的数组的引用传递到函数中,同时仍然可以通过调用 .len()
找到它的长度,这是你在 C/C++
中无法做到的(你可以在 C/C++
中传递数组,而无需变量退化为一个指针,但你必须将函数硬编码为特定的数组大小,这是因为大小信息未保存在数组内存布局中)。
1 | fn no_decaying_to_pointer(arr: &[i8]) { |
当涉及到中断和多线程/多核心(例如运行 RTOS
)时,并发性是嵌入式固件中你必须关心的问题。你第一次遇到并发问题的时候之一是在中断内更新变量时。在 C/C++
中,使用易失性(volatile
)和临界区(critical sections
)通常是解决问题的办法。当使用多线程时,互斥体/队列/等 RTOS
原语可以用于防止数据遭到破坏。
在 Rust 中,你还可以使用临界区来防止中断中的数据竞争。nb 库采用了一种有趣的方法来解决决定 API
调用是否应该阻塞(或如何阻塞!)的问题。它允许编写 API 的人编写核心功能,然后让调用者决定阻塞行为。 API
返回 nb::Result<T, Error>
类型,其中 T
是函数的标准返回类型。如果调用者确实想要阻塞等待函数完成,他们可以将调用包装在块中 block!
。nb 库有一定的潜力与 HAL
外设一起使用,例如 UART
的 read/write()
函数(通常会阻塞,直到发送/接收数据)。
在大多数语言中,有两种常见的错误处理方式。
在嵌入式固件中,有时由于执行时间不可预测(尽管与普遍认识相反,异常实际上可以改进非异常情况下的运行时性能)或增加了每个开发者都必须注意的复杂性,需要禁止使用异常。返回错误代码是许多嵌入式项目的标准错误处理方式,但你必须记住检查错误并将它们适当地传播到调用堆栈。Rust
的 Result
类型,它可以极大地改善错误处理体验。
例如,让我们实现一个 uart_write_bytes()
函数,它通过 UART
写入一组字节。我们的 UART
有一些特殊的要求,一次不能写入超过 10 个字节。如果用户提供超过 10 个字节,我们希望返回错误条件。如果他们提供 10 个字节或更少,我们希望将它们写出 UART
,然后返回写入的字节数。让我们来写这个函数:
1 | fn uart_write_bytes(bytes: &[u8]) -> Result<usize, &'static str> { |
如果我们尝试使用这个函数并且忘记检查返回的结果,Rust
会产生警告,例如如果我们这样写:
1 | let data = [32, 38, 24, 34]; |
Rust
会抛出如下错误:
如何正确处理这个返回的 Result
对象呢?一种方法是调用 unwrap()
。如果没有错误,unwrap()
将返回该值;如果有错误,则会出现恐慌。在错误不可恢复的情况下,你可以使用 unwrap()
,并且在嵌入式情况下,你可以定义恐慌的作用(将其视为与 C/C++
断言相同)。
1 | // Using unwrap() we can unpack the returned `Result` type, we either get the number of bytes if write was successful or panic if `Err` was returned |
还有 Expect()
,它与 .unwrap()
类似,只不过它还允许你提供自定义错误消息:
1 | let num_bytes = uart_write_bytes(&data).expect("Writing bytes to UART failed."); |
如果错误是可恢复的和/或预期的,则可以在 Result
对象上使用 match
语句来适当地处理错误情况。
1 | fn main() { |
另一种选择是使用问号运算符 ?
。 这是执行匹配语句并在出现错误时提前返回的简写,它本质上是在堆栈中传播错误条件。这种设计风格是常见的做法(它与异常的工作方式非常相似),因此 Rust
为其引入简写是有道理的。以下示例显示了这一点,并添加了一个额外的函数来显示错误传播。
1 | let num_bytes = uart_write_bytes(&data)?; |
上面代码等效于:
1 | let num_bytes = match uart_write_bytes(&data) { |
注意: 阅读完所有内容后,你可能想知道 Rust
如何实现这些看似包含不同“类型”数据的返回类型。这背后的关键思想是 Rust
的枚举在幕后实现为所有事物的标记联合。还有空指针优化,这意味着当有两种可能的返回类型时,Rust
可以优化联合类型的空间:
None
)Rust
会将这两件事折叠成一个变量,并使用 0
来表示 None
。这就是 Option<&T>
的工作原理。Rust
拥有 第一层嵌入式支持(first-tier embedded support) 的原因之一是标准化的 #![no_std]
crate级的属性。此属性指示 crate 将链接到 core-crate
而不是 std-crate
。 core-crate
是 std-crate
的子集,它不包含任何假设/需要使用操作系统的 API
。此 no_std
非常适合裸机或自定义 RTOS
环境。它提供了基本功能,例如基本数据类型(浮点、字符串、切片等)和通用处理器功能,例如原子操作和 SIMD
指令。但是,它不提供任何 API
来创建线程、文件系统访问或进行系统调用的能力等功能。
no_std
无法实现的另一件事是初始化,它设置堆栈溢出保护并生成一个线程来调用 main()
。因此,在嵌入式 no_std
开发中,你可以定义要作为 “main” 的函数。
no_std
还意味着在默认情况下,你无法在堆上动态分配内存。乍一看这可能看起来很奇怪,因为在嵌入式 C
开发中通常有 malloc()/free()
等,而在 C++
中则有 new/delete
。没有动态内存分配意味着你不能使用任何依赖它的对象(如动态数组或字符串),Rust
这些被称为集合(Vec、Box、BTreeMap 等)。在某些情况下,固件中没有动态内存分配是可以的(事实上更可取或必需,例如 MISRA)。然而,动态内存分配有一些很好的用例(我对非危及生命的应用程序的一般规则是允许它,但仅在初始化期间)。幸运的是,只要它们是适合你的微控制器架构的分配器,你就可以启用它。例如,alloc-cortex-m 库 为 Cortex-M
架构提供了一个自定义分配器。然后,你还可以使用标准 Rust
集合(但要小心它们!)。
no_std
还意味着你必须定义恐慌的作用。在嵌入式开发中,不允许从恐慌函数返回,因此函数需要具有fn(&PanicInfo) -> !
签名 。你可以包含一些第三方包,它们对于嵌入式固件中的恐慌很有用:
ITM
记录到主机,ITM
是 Cortex-M 特定的调试外设(比半主机更快)。一旦你添加了这些包之一作为依赖项,你所需要做的就是告诉 Rust 编译器你想要链接到它,因为你不会直接从中调用任何内容。为此,请使用:
1 | use panic_halt as _; |
_
很重要,因为它告诉编译器你想要链接到它,但不从中调用任何内容。如果你没有这个,编译器会向你发出未使用的导入警告。
C/C++
非常缺乏的一个功能是用于管理依赖项和构建过程的标准化包管理器。幸运的是(像大多数常见语言一样)Rust
附带了 cargo
包管理器。 cargo
可以很好地转化为嵌入式开发,你可以使用它轻松包含第三方包(他们称之为 crate),或者创建你自己的库以使你的代码更加模块化和可重用。
我真正喜欢货物的一件事是它允许安装扩展,这可以为 cargo
命令添加功能。cargo-flash 包可为 cargo
添加微控制器烧录支持。你可以使用以下命令安装 cargo-flash
:
1 | $ cargo install cargo-flash |
它会将子命令 cargo flash
添加到 cargo
命令中。然后,你可以输入以下内容,使用 Rust
可执行文件对微控制器进行编程:
1 | cargo flash --chip STM32F042C4Tx |
Cargo 和嵌入式的另一个好处是,社区似乎已经采用了一种结构化的方式来组织各种与固件相关的库。这包括:
外设访问包 (Peripheral Access Crates,简写成PAC):包含控制微控制器外设的内存映射寄存器的最小命名。
架构支持包(Architecture Support Crate):包含用于控制 CPU
和跨 CPU
架构共享的外设的 API
(例如用于控制中断、系统滴答的 API
)。
硬件抽象层 (Hardware Abstraction Layers,简写成HAL):将 PAC
寄存器包装成易于使用的外设 API
,例如 uart.init()
、uart.write_byte()
、adc.read_value()
等。虽然这不是 Rust
所独有的,但我们可能期望 Rust
中的 HAL
能有更好的标准化,因为 embedded-hal
努力保持其在 MCU
系列之间的一致性。在 C/C++
中,HAL
的 API
通常对于 MCU
系列(STM32、SAMD 等)或框架(Arduino、mbed 等)是唯一的。
板级支持包(Board Support Crate): 该包是为包含微控制器的特定 PCB
项目而构建的。 板级支持包使用 HAL
并根据 MCU
与物理世界的连接方式创建适当命名的 HAL
对象实例。这是一个可选的额外包,如果你正在设计一个可供许多人用于许多不同目的的板子,那么创建板级支持包这是一个好主意。对于一次性项目,创建板级支持包的额外开销可能不值得,相反,你可以将此代码捆绑在应用程序中。
实时操作系统 (Real-time Operating System,简写成RTOS): 就像 C/C++
固件开发一样,你也可以获得 Rust
的 RTOS
。其中一些是 C/C++
RTOS
(如 FreeRTOS
)的端口/包装器,另一些是针对 Rust
从头开发的 RTOS
。使用 RTOS 是完全可选的,并且通常对于较大、复杂的固件应用程序有意义。
应用程序(Application): 作为任何固件项目的最后一层,包含高级业务逻辑。应用程序层通常向下调用 RTOS
(如果存在)和 HAL
层。
该结构如下图所示:
在嵌入式固件中,通常希望能够基于条件(例如 DEBUG
与 PRODUCTION
或 ENABLE_LARGE_LUT_ARRAY
)包含/排除代码块,比如通过删除生产版本中的调试字符串或包含特定于架构的字符串来释放内存使用量代码(因此你可以使用相同的代码库来定位多个微控制器)。在 C/C++
领域,这通常使用预处理器指令(#ifdef
等)来实现。然而,Rust 中没有预处理器。在 Rust 中解决这个问题的惯用方法是使用 Cargo 特性。
所有 cargo
特性都必须在 cargo.toml
的 [features]
下定义。例如:
1 | [features] |
然后,在 .rs
源代码文件中,你可以有条件地包含代码块:
1 |
|
默认情况下,所有特性均被禁用,除非在 cargo.toml
中定义了default
特性。
C/C++
预处理器的另一个用途是出于性能原因:可能需要通过创建执行直接文本替换的预处理器宏来避免函数调用。这在现代 C/C++
中不是什么问题,因为编译器已经非常擅长知道何时自动内联函数。但尽管如此,你仍然可以使用 Rust
的宏系统在 Rust
中执行类似的技巧。它在很多方面都比 C/C++
预处理器(执行基本文本替换)更强大、更智能。然而,你可以使用 C/C++
预处理器执行一些在 Rust
中无法执行的技巧,例如部分变量名称替换。
嵌入式 C/C++
固件中的一种常见模式是使用预处理器创建一个 assert()
宏,该宏不仅检查提供的表达式是否为真,而且还获取当前文件、行号和提供的表达式作为字符串。例如:
1 |
|
这是通过特殊的宏 __LINE__
、__FILE__
和 #exp
(其中 #
对 exp
进行字符串化)实现的,而且宏内容在 assert()
的任何地方都会被放入源代码中。幸运的是,你可以通过利用 line!()
、file!()
和 stringify!()
宏(它们是编译器内置宏)在 Rust 中执行相同的操作6。
大多数嵌入式开发人员都会熟悉 C/C++
中的 volatile
关键字。它告诉编译器该变量的值可能随时更改,这对于指向在硬件中 内存映射外设寄存器(memory-mapped peripheral registers) 的指针来说也是如此。这很重要,这样编译器就不会执行不正确的优化(有关 C/C++
易失性关键字的更多信息,请参阅 嵌入式系统和volatile关键字 )。
Rust
提供了两个方法 core::ptr::write_volatile()
和 core::ptr::read_volatile()
来告诉编译器同样的事情。 write_volatile()
接受 *mut T
类型的变量,read_volatile()
接受 *const T
类型的变量。
当考虑将 Rust
用于嵌入式项目时,你会想知道“Rust 支持我使用的微控制器吗?”。由于市场上有如此多的制造商和 MCU
系列(以及一些不同的架构),这完全取决于你所使用的产品。我们将在下面介绍一些流行架构和 MCU
系列的 Rust
支持级别。
通常通过运行 rustup
添加对特定架构的支持(默认情况下 rustup
仅安装适用于你的主机平台的标准库7):
1 | rustup target add <architecture> |
这将设置用于交叉编译到你选择的架构的构建环境。有关受支持平台的完整列表,请参阅 rustc手册:平台支持。
让我们更详细地了解当今嵌入式领域使用的主要架构的 Rust
支持。
Rust
很好地支持了 ARM Cortex-M CPU
架构,因此许多使用 Cortex-M
的 MCU
系列自然也得到了很好的支持。 rust-embedded/cortex-m 库为 Cortex-M
家族系列提供了最少的启动代码和运行时(包括半主机)。
Rust 支持的 ARM Cortex-Mx 编译目标列表表7 8 9:
ISA | rustup Target |
---|---|
ARMv6-M (Cortex-M0, M0+, M1) | thumbv6m-none-eabi |
Armv7-M (Cortex-M3) | thumbv7m-none-eabi |
Armv7E-M (Cortex-M4, M7 – no floating-point-support) | thumbv7em-none-eabi |
Armv7E-M (Cortex-M4F, M7F – floating-point-support) | thumbv7em-none-eabihf |
你可以使用以下命令快速启动 Cortex-M CPU 的新项目:
1 | cargo generate --git https://github.com/rust-embedded/cortex-m-quickstart |
alloc-cortex-m 为基于 Cortex-M
的微控制器提供堆分配器。下面的代码示例显示了如何将此分配器设置为全局分配器(以便 Vec 等标准集合工作)并在固件应用程序中使用11:
1 |
|
Rust
对 RISC-V
架构的支持相当好。以下是支持架构:
ISA | rustup Target |
---|---|
RV32I ISA | riscv32i-unknown-none-elf |
RV32IMAC ISA | riscv32imac-unknown-none-elf |
RV32IMC ISA | riscv32imc-unknown-none-elf |
RV64IMAFDC ISA | riscv64gc-unknown-none-elf |
RV64IMAC ISA | riscv64imac-unknown-none-elf |
riscv_rt 库 为 RISC-V
CPU
提供基本的启动/运行时。
Xtensa
架构仅在 ESP32
系列 MCU
中占主导地位,因此我们将在下面的 ESP32(Espressif Systems)部分中介绍该架构。
我们已经介绍了 CPU
架构(它定义了指令集),但是对围绕它并构成 MCU
的所有外设的支持又如何呢?让我们介绍一下一些流行制造商及其 MCU
系列对 Rust
的支持程度。
STM32
系列微控制器拥有所有微控制器中最丰富的 Rust
支持。 stm32-rs/stm32-rs 库包含适用于多种 STM32
微控制器的 Rust PAC 工具包。截至 2022 年 11 月,它得到积极维护,拥有 824 颗星。
https://github.com/Rahix/avr-hal 是适用于 ATmega AVR
(包括 Arduino
、ATmega
、ATtiny
)的流行第三方 Rust HAL
层。
ravedude
是一个有用的 cargo
应用程序,它增加了对 cargo
运行的支持以对 Arduino
板进行编程,然后连接串行以显示任何打印消息。
1 | cargo +stable install ravedude |
在 https://blog.logrocket.com/complete-guide-running-rust-arduino/ 上有一个关于让 Rust
在 Arduino Uno
(使用 ATmega328P
微控制器)上工作的很棒的教程。在 WSL
中开发时(使用 usbip
连接 Arduino USB
设备),使用该教程构建一个点亮LED的 Rust
项目大约需要 5 分钟。
atsamd-rs/atsamd 库提供了各种 crate
,用于使用 Rust12 处理基于 Atmel samd11
、samd21
、samd51
和 same5x
的设备。该库提供 PAC(外围访问包)
和更高级别的 HAL(硬件抽象层)
。 HAL
实现由 embedded-hal
项目指定的特征。此库中还包含许多开发板的 BSP(Board Support Packages,中文为板支持包)
。它们按照第 1 层(Tier 1)和第 2 层(Tier 2) 进行区分,第 1 层 BSP
是那些与最新版本的 atsamd-hal
保持同步的 BSP
,而第 2 层 BSP
则不然(它们可能被锁定到某个过去的版本) )。
截至 2022 年 11 月,该存储库看起来很活跃,有 705 次提交和 421 颗星。
japaric/msp430-rtfm 上有一个可用于 MSP430 MCU
的 RTFM
(Real-Time For the Masses,RTIC 的旧名称)版本,维护得不太好。
Rust
编译器有一个分支(esp-rs/rust),它添加了对 Xtensa
指令集(例如 ESP32S3
)的支持。如果 Xtensa
上游支持其架构进入 LLVM
,那么将来可能不需要这个分支。同一个 esp-rs
组织还在 GitHub
上的 esp-rs/esp-hal 提供了 no_std HAL
,并在 esp-rs/esp-idf-hal 提供了 std
支持。
esp-rs/esp-idf-svc 为各种 ESP-IDF
服务(例如 WiFi
、网络和日志记录)提供 Rust
包装器。这定义了 esp-rs/embedded-svc 中定义的特征的实现(将其视为基本 embedded-hal 特征的扩展)。
esp-rs
组织还提供了自己的安装程序 espup
,即“rustup for esp-rs”。它是一个工具(rustup
的替代品),用于安装和维护在 Espressif
设备上使用 Rust
进行开发所需的工具链。
nrf-rs/nrf-hal 库为 nRF51
、nRF52
和 nRF91
系列微控制器提供 Rust HAL
13。
默认的嵌入式 Rust 教程现在使用 micro:bit v2
(它曾经使用 STM32F303 Discovery Kit
),它恰好有一个板载 nRF52 MCU
。
rustup
目标 riscv32imac-unknown-none-elf
可用于 Freedom E310
(例如 HiFive1
)的交叉编译。我找不到对 HiFive1 Rev B
引导加载程序的任何支持,因此需要专门的程序员来对电路板进行编程。
RP2040
只是一个芯片而不是一个“系列”,但是你可以购买许多基于该 IC
的板。 rp-rs/rp-hal 库提供了高质量的 RP2040
。该仓库被组织为 Cargo Workspace,其中还包括许多用于使用该芯片的开发板的板支持crate,包括 Raspberry Pi Pico
、Adafruit Feather RP2040
、Adafruit ItsyBitsy RP2040
、Pimoroni Pico Explorer
、SolderParty RP2040 Stamp
、Sparkfun Pro Micro RP2040
、Sparkfun Thing Plus RP2040
和 Seeeduino XIAO RP204014
14。
PSoC 6
的 PAC
和 HAL
库,但它们看起来维护或使用得不好。PIC32
的 HAL
。看起来有些维护。嵌入式开发必须具备一个流畅的 编写代码
-> 构建
-> 编程
->调试
的工作流程。理想情况下,这不需要供应商锁定(即被迫使用供应商特定的 IDE),并且可以在代码编辑器中(而不仅仅是在命令行上)进行逐步调试。幸运的是 Rust
可以提供这一切!我专注于使用 VS Code
,因为它是当今最流行的非特定于供应商的 IDE
。 VS Code
对 Rust
和嵌入式开发有很好的支持。 Cortex-Debug
和 rust-analyzer
是你肯定想要安装的两个 VS Code
扩展。
我可以使用 STM32F303 Discovery Kit
(带有 STM32F303 MCU
的开发板),因此我进行了一些搜索并找到了 rubberduck203/stm32f3-discovery。其中包含预制的 VS Code
启动配置,因此我应该能够直接从 VS Code
中调试 Rust
代码。通过一些调整(包括将"cortex-debug.gdbPath":"gdb-multiarch"
添加到settings.json
),我能够启动并运行工作流程!
我所需要做的就是按 F5
– 这会构建代码,并将其烧录到 STM32F303
设备。下面是我单步执行 “Blinky” 示例时的图像。我使用 VS Code
通过 WSL
连接到 Ubuntu
(使用 usbip
连接 STM32F303 USB
设备)。
Knurling 是 Ferrous Systems
的项目集合(他们的两个流行工具包括 probe-run
和 defmt
)。
通过 cortex-m
crate 为 Cortex-M
MCU 提供半主机。半主机允许你通过附加的调试器将调试消息记录到主机,无需额外的电缆(例如 USB
到 UART
设备)。缺点是速度慢。一条消息可能需要很多毫秒,具体取决于你正在使用的附加调试器。 Panic-semihosting
crate 还可用于在主机上提供有用的恐慌消息。 ITM
是比半主机更快的选项,但仅适用于 Cortex-M3
及更高版本。 RTT
可能是一个更好的选择(在大多数目标/程序员上可用,如半主机,但速度像 ITM
一样快)15。它主要与平台无关,仅依赖于支持后台目标内存访问的调试探针。启用后,你可以使用 rprintln!()
宏。我还没有使用过这个,所以不能发表太多评论!
如果没有可供选择的 RTOS
,任何语言都不能声称适合嵌入式编程。幸运的是,Rust
有一些,从现有 C/C++ RTOS
(例如 FreeRTOS
和 RIOT
)的 Rust
包装器到从头开始构建的在 Rust
上运行的 RTOS
(例如 RTIC
、Embassy
和 Tock
)。让我们回顾一下 Rust
开发人员可用的一些流行 RTOS
。
hashmismatch/freertos.rs
并简化 Rust
中 FreeRTOS
的使用。RTIC
(Real-Time Interrupt-driven Concurrency)是一种有趣的 RTOS
方法,似乎拥有相当多的积极开发和社区支持。所有任务共享一个调用堆栈,并在编译时保证无死锁执行。下面是 RTIC
的一些特点:
项 | 值 |
---|---|
调度机制 | 基于中断的优先抢占(Interrupt-based preemptive with priority) |
仓库星星数 | 1k |
仓库提交数 | 1.1k |
Embassy
主要支持协作多任务处理而不是抢占式调度。但是,它确实允许你创建具有不同优先级的多个执行程序,因此你可以在需要时获得抢占。它利用了 Rust
的 async/await
。调度程序在单个堆栈上运行所有任务。它还提供了一套库,例如用于 IP
网络的 embassy-net
、用于 LoRa 网络的 embassy-lora
、用于 USB 设备的 embassy-usb
以及用于引导加载程序的 embassy-boot
。下面是 Embassy
是一些特点:
项 | 值 |
---|---|
调度机制 | 协作式(Co-operative) |
仓库星星数 | 1.2k |
仓库提交数 | 3.4k |
Tock
是一款嵌入式操作系统,设计用于在基于Cortex-M
和RISC-V
的嵌入式平台上运行多个并发、互不信任的应用程序。
下面是 Tock
的一些特点:
项 | 值 |
---|---|
调度机制 | 抢占式(Preemptive) |
仓库星星数 | 4k |
仓库提交数 | 11k |
Tock
的某些功能并未完全融入 Rust
,例如你必须突破 Rust
生态系统并调用 make
将内核编程到你的主板上。一旦内核被编程到你的主板上,你就可以使用他们自己的 tockloader
程序来刷新应用程序代码。
Drone 是一个基于中断的抢占式 RTOS
,采用 Rust
构建,适用于嵌入式设备。下面是 Drone
的一些特点:
项 | 值 |
---|---|
调度机制 | Interrupt-based pre-emptive with priority |
仓库星星数 | 361 |
仓库提交数 | 251 |
Rust
构建的应用程序的速度和内存使用情况与 C/C++
相比如何?首先值得一提的是,Rust
的大多数独特的所有权/借用检查纯粹是编译时构造,并且在速度和内存使用方面都产生零运行时开销。
正如 Rust
语言特性部分中提到的,Rust
在访问数组时会自动进行边界检查。最好在编译时执行此操作,但在某些情况下无法执行此操作(例如,将对数组的引用传递给函数),并且必须在运行时执行此操作。开销很小,并且在 99% 的用例中可能都是值得的。如果你确实想避免边界检查,你可以:
get_unchecked()
当编译时没有使用 --release
选项时,Rust
也会在进行加法和乘法等数学运算时执行溢出检查。如果我们使用 Godbolt Compiler Explorer
并比较 C++
和 Rust
中简单 square()
函数的汇编输出,我们可以看到这一点:
你可以在 Rust
窗格中看到它有一些额外的指令,包括 seto
读取溢出标志,然后进行 test
并跳转到 panic!
在发生溢出的情况下。这会减慢数学运算的速度,但在大多数情况下,为了捕获溢出错误,这是值得的权衡。请记住如果你使用 --release
选项构建,开销就会消失。
提示: 如果你希望在 --release
版本中进行溢出检查,则可以使用 checked_xxx
函数,例如 checked_add()
,如果值溢出,它会返回一个为 None
的 Option<T>
。
尽管溢出可能很糟糕,但在许多用例中(尤其是在嵌入式编程中)你需要(甚至依赖)溢出包装。一个典型的示例是获取当前系统刻度值并减去保存的先前系统刻度值来计算持续时间。你的系统记号可能存储在 32 位无符号整数中,并计算自启动以来的毫秒数。在连续运行 1193 小时多一点的时间里,这将回到 0。然而,由于整数数学实现方式的性质,当当前系统滴答回零时,依赖于减法的持续时间仍然可以正常工作,只要没有一个持续时间跨越总系统滴答周期的一半以上(大约 597小时)。在 Rust
中,你可以使用 wrapping_xxx
函数(例如 wrapping_add()
)安全地执行溢出方程。
gccrs 是一个将 Rust“前端(front-end)”合并到 GCC
中的项目。截至 2022 年 12 月,这仍然是 WIP(进行中)
。最终目标是使 GCC
能够编译 Rust
代码。这样做的主要好处是:
GCC
非常好的优化(这与 LLVM
不同)Rust
编译器可供选择(这通常是一件好事!)由于这是一个前端项目,编译器将获得对
GCC
的所有内部中端优化通道的完全访问权限,这与 LLVM 不同。 – GCC Front-End For Rust17
https://benchmarksgame-team.pages.debian.net/benchmarksgame/fastest/rust-gpp.html 有一些关于 Rust
与 C++
的有趣基准测试。 Rust
在 4 个基准测试中明显更快,C++
在其中 3 个基准测试中明显更快,而对于其余 3 个基准测试,它们基本相同。
如果不提及负面因素,任何评论都是不公平的。使用 Rust
进行嵌入式固件有哪些缺点?
不像C/C++那样得到很好的支持:C/C++
肯定受到许多微控制器供应商和 IDE
的更好支持,并且 C/C++
的嵌入式库比 Rust
多得多(库的成熟度)。但如上所示,Rust
对许多顶级微控制器系列的支持相当好,并且希望随着该语言的成熟,它会继续变得更好。
Rust的学习曲线很陡:如果你熟悉 C/C++
等编译语言以及 Javascript
和 Python
等一些解释型高级语言,你可能会发现学习新语言非常容易。然而,Rust 的工作方式有一些显着的核心差异(与大多数其他流行语言相比,它的借用检查器/所有权概念很新颖),因此仍然很难学习。有一句众所周知的说法,在学习 Rust
时,你将“与借用检查器搏斗”。
找到Rust开发人员将会更加困难:同样,由于 Rust
与其他语言相比相对不成熟,如果你运营着大型团队,通常会更难找到有能力的开发人员。
不如C/C++代码优化得好:尽管如此,编译后的 Rust
代码将会很快,并且在 99% 的用例中可能足够快。在某些特定用例中,C/C++
代码可能会击败 Rust
。随着时间的推移,Rust
的速度可能会变得更好,像 GCC Front-End For Rust 这样的项目将有助于这一过程。
请务必查看 Matrix’Rust Embedded 聊天室。
GitHub
存储库 rust-embedded/awesome-embedded-rust 是由 Rust
资源团队维护的大量嵌入式 Rust
资源列表。它包括工具、RTOS、外设访问包 (PAC)、硬件抽象层 (HAL)、板级支持包 (BSP)、博客、书籍和其他培训材料。
你可以使用在线编辑器/编译器(例如 Replit
)来尝试 Rust
。或者,如果你更喜欢在本地运行某些内容,请安装 cargo
,然后使用 cargo new hello_world --bin
初始化一个新项目(这将用于在你的计算机上运行,而不是在微控制器上运行)。
基于哈希表实现的Map中的取余运算转换成与运算的技巧,用数学语言来表达:
对于正整数x, y,如果x为2的n次方,n为正整数,那么 表达式是成立的。
对于等式的成立有很多理解角度,本文从数学角度出发,尝试以数学证明方式来证明它。证明过程如下:
y为正整数,将y转换成二进制后,其十进制的值可以用如下表达式表示,其中,,… 分别表示y二进制表示时其第1位,第2位,第n位上的值,依次类推:
举例说明,比如y为25时候,其对应二进制为11001,那么上面表达式表示如下:
其中,,,,,之后的都为0,这里面为了方便理解,把之后都写出来了。
那么 可以转换成:
由于x是2的n次方,所以,那么上面表达式可以转换为:
进一步转换成:
从上面可以看到我们把y分为从到和从到无穷这两部分,按照模运算规则:,上面表达式可以继续转换成如下:
由于可以整除,上面表达式可以进一步简化为:
而可能的最大值为(当且仅当时),也就是说, 那么存在:
那么等式进一步可以简化为:
上面等式中的含义是y二进制表示时候第1位到n位,其对应的十进制整数结果可以用与运算$ y;&;(20+21 + … + 2^{n-1})$得到,存在以下等式:
等式可以进一步简化处理得到我们要证明的等式:
综上所述,对于正整数x, y,如果x为2的n次方,n为正整数,那么 表达式是成立的。
]]>最近在部门中做了一次技术分享,现将分享内容总结成博文发布出来,内容有删改。
Golang以并发见长,支持成千上万个协程调度。Golang中协程称为Goroutine,它是Go runtime调度中的最小执行单元,Goroutine的创建、管理、调度运行的机制采用的GMP模型。本次分享介绍的就是Golang调度机制的GMP模型。
并行(Parallelism) 指的是一个CPU时间片内可以同时做多件事情。并行强调的是某一时间点内能够同时处理多件事情,并行需要多核CPU提供支持。并行是并发的子集。
并发(Concurrency) 指的是是一种同时处理许多事情的能力,并行强调是某一时间段内能够同时处理多件事情。
在仅支持进程的操作系统中,进程是拥有资源和独立调度的基本单位(这样的进程可以考虑是只有一个线程的进程)。在支持线程的操作系统中,线程是独立调度的基本单位,而进程是资源拥有的基本单位。
进程是应用程序运行时的抽象。一个进程包含两部分:
进程具有独立的虚拟地址空间。当应用程序运行起来时候,系统会将该应用加载到内存中,应用程序会独立的、完全的占用所有内存,这里的内存指的是虚拟内存,对于32位系统,该虚拟内存大小是2^32 = 4G,也就是说每个进程都具有“独占全部内存”的假象。
下面是进程的运行时的内存布局:
进程的创建是通过fork系统调用实现的,创建时候会将父进程的上面内存布局COPY 一份,所以说进程的创建是非常耗CPU资源操作(尽管fork系统调用支持了写时拷贝,建立映射关系也是耗时操作)。
1 |
|
运行上面程序,输出以下内容:
1 | Child process set x=2 |
线程是更加轻量级的运行时抽象。线程只包含运行时的状态:
一个进程可以包含多个线程。一个进程的多线程可以在不同处理器上同时执行,调度的基本单元由进程变为了线程,上下问的切换单位是线程。每个线程都拥有自己的栈,内核也有为线程准备的内核栈。
根据线程是否受内核直接管理,可以把线程分为两类:用户级线程和内核级线程。
1 |
|
运行上面程序,输出以下内容:
1 | Child process set x=2 |
协程是用户态下的轻量级线程,在不同的场景下中有不同的叫法,由Linux实现的叫做纤程(Fiber),由开发语言实现的一般叫协程(Coroutine)。协程的实现采用模型一般是用户级线程模型或者两级线程模型。Go语言的协程叫做Goroutine(是由Go 和 Coroutine拼接出来的词)。Goroutine具有以下特点:
线程创建、管理、调度等采用的方式称为线程模型。线程模型一般可分为以下三种:
内核级线程模型
用户级线程模型
两级线程模型,也称混合型线程模型
三大线程模型最大差异就在于用户级线程与内核调度实体KSE(KSE,Kernel Scheduling Entity)之间的对应关系。KSE是Kernel Scheduling Entity的缩写,其是可被操作系统内核调度器调度的对象实体,是操作系统内核的最小调度单元,可以简单理解为内核级线程。
内核级线程模型中用户线程与内核线程是一对一关系(1 : 1)。线程的创建、销毁、切换工作都是有内核完成的。应用程序不参与线程的管理工作,只能调用内核级线程编程接口。每个用户线程都会被绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。
操作系统调度器管理、调度并分派这些线程。运行时库为每个用户级线程请求一个内核级线程。内核级线程模型有如下优缺点:
优点:
在多处理器系统中,内核能够并行执行同一进程内的多个线程
如果进程中的一个线程被阻塞,不会阻塞其他线程,是能够切换同一进程内的其他线程继续执行
缺点:
用户级线程模型中的用户态线程与内核态线程KSE是多对一关系(N : 1)。线程的创建、销毁以及线程之间的协调、同步等工作都是在用户态完成,具体来说就是由应用程序的线程库来完成。从宏观上来看,任意时刻每个进程只能够有一个线程在运行,且只有一个处理器内核会被分配给该进程。
从上图中可以看出来:库调度器从进程的多个线程中选择一个线程,然后该线程和该进程允许的一个内核线程关联起来。内核线程将被操作系统调度器指派到处理器内核。用户级线程是一种”多对一”的线程映射。
用户级线程模型有如下优缺点:
优点:
创建和销毁线程、线程切换代价等线程管理的代价比内核线程少得多, 因为保存线程状态的过程和调用程序都只是本地过程
线程能够利用的表空间和堆栈空间比内核级线程多
缺点:
线程发生I/O或页面故障引起的阻塞时,如果调用阻塞系统调用则内核由于不知道有多线程的存在,而会阻塞整个进程从而阻塞所有线程, 因此同一进程中只能同时有一个线程在运行
资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用
两级线程模型中用户态线程与内核态线程是多对多关系(N : M)。两级线程模型充分吸收上面两种模型的优点,尽量规避缺点。其线程创建在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程被绑定到一些(小于或等于用户级线程的数目)内核级线程上。
Golang在底层实现了混合型线程模型。下图中M代表着系统线程,一个M关联一个KSE,即两级线程模型中的系统线程。G为Groutine,即两级线程模型的的应用级线程。M与G的关系是N:M。
G[1],M2,P3 分别是Go runtime调度的核心底层数据结构,所以Golang中调度模型也称为GMP模型。GMP分别代表的含义如下:
GMP模型概览图:
Golang最开始的调度模型只有G和M,G放在全局队列中,M都是从全局队列中获取可运行的G,这需要全局的锁保证并发安全,性能比较差4。后续追加P数据结构,对应每一个CPU核心,M执行G之前都需关联一个P,后续M获取G只需从其关联的P的本地队列获取,这个获取过程是无锁。
当M关联的P的LRQ没有可以执行的G时候,其可以从Gloable runable queue(GRQ)或者其他P上窃取(work stealing)可以执行的G。
1 | func main() { |
j:=i
这一行使用dlv或者gdb打上断点之后,为什么输出的内容跟Q1不一样?试着分析一下。每个P有个局部队列(LRQ),局部队列保存待执行的goroutine(流程2),当M绑定的P的的局部队列已经满了之后就会把goroutine放到全局队列(流程2-1)
每个P和一个M绑定,M是真正的执行P中goroutine的实体(流程3) ,M从绑定的P中的局部队列获取G来执行
当M绑定的P的局部队列为空时,M会从全局队列获取到本地队列来执行G(流程3.1),当从全局队列中没有获取到可执行的G时候,M会从其他P的局部队列中偷取G来执行(流程3.2),这种从其他P偷的方式称为work stealing
当G因系统调用阻塞(属于系统调用阻塞)时会阻塞M,此时P会和M解绑即hand off,并寻找新的idle的M,若没有idle的M就会新建一个M(流程5.1)
当G因channel(属于用户态阻塞)或者network I/O阻塞时,不会阻塞M,M会寻找其他runnable的G;当阻塞的G恢复后会重新进入runnable进入P队列等待执行(流程5.3)
GMP模型高效的保证策略有:
M是可以复用的,不需要反复创建与销毁,当没有可执行的Goroutine时候就处于自旋状态,等待唤醒
Work Stealing和Hand Off策略保证了M的高效利用
内存分配状态(mcache)位于P,G可以跨M调度,不再存在跨M调度局部性差的问题
M从关联的P中获取G,不需要使用锁,是lock free的
锁(lock)的目的是给临界区(Critical Section)加上一层保护,以保证临界区中代码能够像单条原子指令一样执行。临界区指的是一个访问共享资源的程序片段,比如对全局变量的访问、更新。在Linux系统中保护临界区的机制除了锁之外,还有信号量,屏障,RCU等手段。
锁本质是一个变量,我们通过lock()和unlock()这两个语义函数来操作锁变量。当线程准备进入临界区时候,会调用lock()尝试获取锁,当该锁状态是未上锁状态时候,线程会成功获取到锁,从而进入到临界区,如果此时其他线程尝试获取锁而进入临界区,会阻塞或者自旋。获取锁并进入临界区的线程称为锁的持有者,当锁持有者退出临界区时候,调用unlock()来释放锁,那么阻塞等待的其他线程继续开始竞争这个锁。下面是获取锁和释放锁的代码示例:
1 | lock_t mutex; |
忙等待(Busy waiting),也称为自旋(Spin)或忙循环(busy looping),是一种同步技术,指的是线程在继续执行之前等待并不断检查要满足的条件。在忙等待中,线程执行指令以测试进入条件是否为真。
通过忙等待技术可以实现锁。当线程尝试将锁状态设置为上锁状态,如果成功,则该线程成为锁的持有者,其他线程不停自旋检查锁的状态,等待锁状态变成未上锁状态。通过忙等待实现的锁一般称为自旋锁(spin lock) 。自旋锁不会像POSIX库中的mutex锁陷入内核状态带来的性能损耗,但它不是银弹。比如对应单核操作系统,我们应该避免自旋处理,应该尽管让出CPU资源,比如单核操作系统自旋是没有意义的。在Go的 sync.Mutex 和 runtime.mutex 代码中可以看到这一点。
当临界区或者资源已被其他线程持有,除了自旋锁的忙等待外,还可以进行阻塞休眠,其他线程都休眠在一个队列上,等待资源被释放出来,休眠往往需要内核支持,这意味会发生上下文切换和线程切换。POSIX库中的mutex锁就是这样,当锁被持有了,其他线程就会休眠等待唤醒。
如何评价锁的好与坏,可以从下面三个指标考虑:
POSIX库中将锁称为互斥量(英文单词是mutex,是互斥MUTual EXclusion一词的缩写),用来提供线程之间的互斥,即当一个线程在临界区时,它能够阻止其他线程进入直到本线程离开临界区。
1 | pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // pthread = posix + thread |
我们可以通过下面代码验证下使用mutex与未使用mutex的情况:
1 |
|
我们在编译时候添加-fsanitize=thread
选项,开启Data race检测功能。接着运行编译后的程序,会看到下面的信息。我们可以看到sum1的赋值操作存在竞态,而使用了mutex的sum2没有:
1 | ubuntu@VM-0-3-ubuntu:/tmp/lock$ gcc -fsanitize=thread -g main.c -o sum.out |
gcc编译时候-fsanitize=thread
选项使用的是ThreadSanitizer,Go语言中竞态检查的-race选项也是基于此实现的。
在最早期,那时候还是单核处理器系统时候,锁的实现比较简单:在进入临界区之前使用硬件指令关闭中断,保证临界区的代码执行时不会被中断,从而原子地执行。当临界区结束之后,使用打开中断的硬件指令开启中断。伪代码如下:
1 | void lock() { |
通过中断指令实现锁的效率很差,因为中断指令是非常耗时的,更糟糕的是恶意程序可以通过不停调用lock()来阻止系统获取控制权。随着处理器的发展,提供了更多了硬件原子指令,聪明的开发者基于各种各样的硬件原语,在软硬件协同之上实现了多种锁。下面将介绍测试并设置、测试测试并设置、获取并增加、比较并交换等原语等实现的锁。
测试并设置(test-and-set) 原理是用一个变量来标志锁是否被某些线程占用。第一个线程进入临界区,调用 lock(),检查标志是否为1,如果不是1,将标志设置为1,表明线程持有该锁。结束临界区时,线程调用 unlock(),清除标志,表示锁未被持有。使用C语言实现的测试并设置锁的伪代码如下:
1 | typedef struct lock_t { int flag; } lock_t; |
测试并设置可以通过 xchg 指令实现,下面是Plan 9汇编代码实现lock()的代码:
1 | TEXT ·TestAndSetLock(SB), NOSPLIT, $0-8 |
Plan 9汇编是Go语言中使用的汇编,语法规则可以参考官方指南:A Quick Guide to Go’s Assembler。Go中atomic包提供了类似SwapXX的原子操作,我们可以使用其实现测试并设置锁:
1 | func (l *testAndSetLock) Lock() { |
在使用测试并设置机制实现锁的时候,使用到了 xchg
指令,该指令会隐式发送lock前缀信号来强制CPU会锁住缓存行或者总线(当数据不在缓存里面会锁住总线)从而使实现原子操作。当锁状态是1时候,一定能保证其他线程读取的状态值也是1,我们可以在xchg
指令值前读取该状态是不是1,来决定下一步操作,以避免锁缓存,这种机制称为测试测试并设置(test-test-and-set) 。Plan 9汇编代码实现lock的代码如下所示:
1 | TEXT ·TestAndTestAndSetLock(SB), NOSPLIT, $0-8 |
Go实现的核心代码如下,感兴趣的可以对比下test-and-set和test-test-and-set性能基准测试的结果:
1 | func (l *testAndTestAndSetLock) Lock() { |
获取并增加(fetch-and-add) 指的是原子地把值加一,并返回该值之前的值。在x86架构CPU中,可以使用的 xadd 指令实现,需要注意的是xadd
指令需要加上lock前缀指令,保证原子性:
1 | TEXT ·FetchAndAdd(SB), NOSPLIT, $0-24 |
基于该硬件原语我们可以实现一款排队形式的排号自旋锁(ticket lock),下面是该锁C语言实现的伪代码:
1 | int FetchAndAdd(int *ptr) { |
上面代码中使用ticket字段记录全局票号,turn字段记录当前可以进入临界区的票号,当线程尝试进入临界区时候,会通过fetch-and-add原语获取自己的票号mytrun,并将全局票号ticket增一,通过比较线程自己的票号mytrun和临界区允许的进入票号trun,相同则允许进入,否则自旋或者阻塞。当ticket锁持有者退出临界区时候,会将trun加一,这类似点餐时候的叫号,叫下一位取餐者。
从上面解释中可以看到ticket锁是完全公平的,每一个尝试进入临界区的线程都有拥有自己票号,最终一定也会排到自己进入临界区。绝对的公平,往往却是不是最高效的。因为ticket锁中每一个线程都拥有了自己的票号,然后不停去询问自己的票号是不是当前允许进入临界区的票号,而系统中只能有一个线程才能进入临界区,也就是说只有持有临界区票号的线程的询问才是有效询问,其他都是无效的(不持有临界区票号的线程去询问,问了也是白问)。
ticket性能相对比较差,从底层分析来看,是因为每个CPU自旋在相同ticket上,根据cache一致性协议,如果ticket值更改之后,其他CPU上的cache line会变成invalidate状态,它们必须重新从内存中读取ticket值,这个过程效率比较低效。如果每个尝试获取线程都有自己的局部变量上面自旋就可以避免ticket这个问题,这个也是下面将要介绍的mcs锁的实现思路。
下面我们来看看Go语言中如何实现ticket锁。Go语言中atomic.AddXX使用了xadd指令,可惜的是返回最新的值,而不是旧值。我们可以atomic.CompareAndSwapXX来实现fetch-and-add原语,核心代码如下:
1 | func (l *fetchAndAddLock) Lock() { |
Mcs锁是根据发明人John Mellor-Crummey和Michael Scott的名字命名的。linux内核中实现了Mcs锁,它的结构如下所示, 其中next指向下一个msc_spinlock,locked标志是否获取到锁:
1 | struct mcs_spinlock { |
Mcs锁实现的思路是每一个线程都有一个自己的mcs_spinlock,当尝试获取锁时候,会将其原子交换操作链接到next字段构成的链表中,如果返回的前一个mcs_spinlock为空,则说明此时锁是free的,则该线程获取到锁。若前一个msc_spinlock不为空,则将当前线程的mcs_spinlock挂载到前一个msc_spinlock的next上,该线程会自旋在自己的mcs_spinlock的locked字段,等待locked变为1。 当锁持有者释放锁时候,会将其msc_spinlock指向的next中locked置为1。
Mcs锁可以利用到缓存局部性,每个线程自旋在自己的状态变量(locked字段)上,但其使用上不太友好,上锁和释放锁时都需要传递自己的锁结构。下面是Go实现的Mcs锁的核心代码:
1 | type mscLock struct { |
同ticket锁一样,Mcs锁也是排号自旋锁,遵循了 FCFS 原则(先来先服务原则),是公平锁。Mcs锁避免了ticket锁缓存失效重新读取内存的问题,理论上性能会比ticket锁好。需要注意的是本文描述的锁是在线程模型(内核级线程模型)下,而Go调度最小单元是协程,属于混合型线程模型,锁的实际性能可能跟预期不一致。
比较并交换(compare-and-swap,简称cas) 是另外一个硬件原语,x86架构CPU对应的指令是 cmpxchg,下面是该指令实现的功能,用C语言表达的伪代码:
1 | int CompareAndSwap(int *ptr, int expected, int new) { |
基于比较并交换实现的锁的C语言伪代码如下:
1 | void lock(lock_t *lock) { |
在实际项目中基于比较交换原语实现自旋锁是较多的方案。Go里面atomic提供了CAS的原子操作,我们可以基于此实现一款自旋锁。
1 | func (l *casLock) Lock() { |
两阶段锁指在获取锁的时候,分为两阶段处理:第一阶段会先自旋一段时间,希望它可以获取锁。如果第一个自旋阶段没有获得锁,则会睡眠,直到锁可用。两阶段锁避免了自旋锁一直自旋带来的CPU的无效浪费,另外第一阶段先自旋一段时间,而不是在未成功获取锁时直接休眠,这样可以减少上下文切换的性能损耗。在Go语言中 sync.Mutex 设计上也采用了该思路:
1 | func (m *Mutex) lockSlow() { |
读者-写者问题(Readers–writers problem)描述的是多个线程(进程)之间共享资源的问题,其中一些线程是读者,即他们想读取共享资源,而一些线程是写者,即他们想写入数据到共享资源。解决读者-写者问题可以使用上面介绍的自旋锁,它对读者,写者一视同仁,当其中一方获取到锁之后,其他方只能等待锁释放。
并发编程系列:死锁
并发编程系列:无锁编程之栈
并发编程系列:无锁编程之队列
并发编程系列:无锁编程之ring buffer
并发编程系列:无锁编程之优先级队列
并发编程系列:缓存一致性协议、内存屏障
C
的伪包(pseudo-package)来与C 语言交互,这种Go语言与C语言交互的机制叫做CGO。当 Go 代码中加入import C
语句来导入C
这个不存在的包时候,会启动CGO特性。此后在Go 代码中我们可以使用C.
前缀来引用C语言中的变量、类型,函数等。我们可以给import C
语句添加注释,在注释中可以引入C的头文件,以及定义和声明函数和变量,此后我们可以在 Go 代码中引用这些函数和变量。这种注释称为 序言(preamble)。需要注意的是 序言和import C
语句之间不能有换行,序言中的静态变量是不能被Go代码引用的,而静态函数是可以的。
1 | package main |
执行go build
命令时候,我们可以使用-n
选项查看所有执行的命令:
1 | vagrant@vagrant:/tmp/cgo$ go build -n |
从上面可以看到在CGO模式下,gcc参加了编译工作。需要注意同Go包一样,C语言模块编译后也会缓存起来,下次编译时候直接使用,如果我们之前构建过应用,在代码没有变动情况下,使用go build -n
就看不见gcc命令了。这时候我们可以使用-a
选项强制重新构建包。
如果Go 代码中存在import C
,那么编译时候Go 编译器会查找代码目录中其他非Go文件,对于后缀为.c
、.s
、.S
的文件,会使用C编译器进行编译。后缀为.cc
、.cpp
或 .cxx
文件使用C编译器编译。对于.h
、.hh
、.hpp
或 .hxx
后缀的文件,它们是C/C的头文件,不会单独编译,如果更改了这些头文件,包括非Go代码都会重新编译。
在序言中我们可以使用#cgo
指令来设置在构建C语言模块时候的编译器参数CFLAGS和链接器参数LDFLAGS。
1 | // #cgo CFLAGS: -g -Wall -I./include |
上面CFLAGS中-g选项用于开启debug symbols
, -Wall用于开启all warning
,-I用于设置头文件目录,-DPNG_DEBUG=1用于设置宏PNG_DEBUG值为1。
在设置LDFLAGS时候,可以使用${SRCDIR}
来替换源码的路径,比如:
1 | // #cgo LDFLAGS: -L${SRCDIR}/libs -lfoo |
将会拓展为
1 | // #cgo LDFLAGS: -L/go/src/foo/libs -lfoo |
CGO编译时候,总是隐式包含-I${SRCDIR}
这个链接选项,并且优先级是高于系统include目录,或者-I指定的目录。这意味着如果foo/bar.h
这个文件既存在于代码目录中,也存在系统目录中,#include <foo/bar.h>
始终优先使用本地代码目录的版本。
此外#cgo
指令还支持条件选择,只有满足特定系统或者CPU架构时候编译或者连接选项才会生效:
1 | // #cgo amd64 386 CFLAGS: -DX86=1 // amd64 368 平台才设置该编译选项 |
当然我们也可以使用#cgo pkg-config
指令来获取设置CFLAGS和LDFLAGS参数。#cgo pkg-config
指令依赖pkg-config
命令,pkg-config
可以帮助我们编译时候插入正确的编译器参数,而不必硬编码,例如我们可以使用**gcc -o test test.c $(pkg-config --libs --cflags glib-2.0)**来找到glib库。pkg-config
在CGO中使用方法如下:
1 | // #cgo pkg-config: png cairo |
1 | type C.char |
1 | type C.short |
1 | type C.int |
1 | type C.long |
1 | type C.longlong (long long) |
1 | type C.float |
1 | type C.double |
1 | type C.struct_<name_of_C_Struct> |
1 | C.union_<name_of_C_Union> |
1 | C.enum_<name_of_C_Enum> |
C语言中的void *
对应Go语言中的unsafe.Pointer
。
1 | cs := C.CString("Hello from stdio") |
由于C语言和Go语言中字符串底层内存模型不一样,且Go 是gc型语言。Go类型字符串需要转换成C类型字符串,才能作为参数传递给C函数,反过来也是一样。
Go 类型转换成C 类型:
1 | // The C string is allocated in the C heap using malloc. |
C 类型字符串转换成Go 类型:
1 | // C string to Go string |
需要注意得是Go 类型字符串转换成C 类型字符串之后,需要手动进行回收:
1 | cs := C.CString("hello, world") |
只要我们在序言中设置好#include
之后,我们就可以在Go 语言可以直接调用标准库里面的函数,或者其他库里面的函数:
1 | package main |
我们也可以调用序言中定义的函数:
1 | package main |
当然我们也可以调用外部C文件中定义的函数:
greeter.h
文件内容如下:
1 |
|
greeter.c
文件内容如下:
1 |
|
go文件内容如下:
1 | package main |
Go语言也只调用C对象文件中的函数,但是相应头文件一定要指定。上面例子中我们可以使用gcc -c greeter.c
生成对象文件greeter.o
,然后删除调用greeter.c
文件,接着在main.go文件中添加以下LDFLAGS参数,也是OK的:
1 | // #cgo LDFLAGS: ./greeter.o |
如果调用C函数时,如果有两个返回值,那么第二个返回值对应的是<errno.h>标准库errno宏,它用于记录错误状态:
1 | /* |
上面代码将会输出:
1 | 2 <nil> |
Go语言中函数要能被C语言调用,需要使用 export
指令进行导出。
greet.go
文件内容如下:
1 | package main |
我们使用go build -buildmode=c-archive greet.go
,将greet.go构建成C存档文件,此时会生成greet.a
和greet.h
文件。需要注意的是go文件中一定要导入C
伪包,以及存在main包。
对应的C文件是main.c,内容如下:
1 |
|
最后使用gcc -pthread main.c greet.a -o main
构建C二进制应用,-pthread
选项是必须的,因为Go runtime使用到线程。我们也可以使用go build -buildmode=c-shared
将Go代码编译成C共享文件,然后在C语言中调用。
代码模式使你的程序更可靠、更高效,并使你的工作和生活更轻松
我已经为开发EDR解决方案工作了7年。这意味着我必须编写具有弹性和高效性的长时间运行的系统软件。我在这项工作中大量使用 Go,我想分享一些最重要的代码模式,你可以依靠这些模式你的程序更加可靠(reliable)和高效(efficient)。
我们经常需要检查某些对象是否存在。例如,我们可能想检查之前是否访问过某个文件或者URL。在这些情况下,我们可以使用map[string]struct{}
。如下所示:
使用空结构 struct{}
意味着我们不希望Map的值占用任何空间。有些人会使用 map[string]bool
,但基准测试表明 map[string]struct{}
在内存和时间上都表现得更好。相关基准测试可以查看这里。
我们需要特别注意的 map 操作通常被认为具有 O(1) 的时间复杂度([StackOverflow](https://stackoverflow.com/questions/29677670/what-is-the-big-o-performance-of-maps-in-golang),但是 go runtime 没有提供这样的保证。
通道可以用来存放数据,但有时候我们使用它们只用于同步目的。在下面的例子中, 通道携带struct{}类型的数据,它是一个不占空间的空结构体。这与上面的 map 示例中的技巧相同:
继续上面例子,如果我们运行多个go hello(quit)
,那么我们可以通过关闭quit
通道来广播信号,而不是发送多个 struct{}{}
退出:
需要注意的是通过关闭通道进行广播通知,适用于任意数量的 goroutine,因此 close(quit)
也适用于之前的那个示例。
有时我们需要在 select 语句中禁用某些case语句,例如在下面函数中,它从事件源读取事件并将事件发送到调度通道:
上面代码中,我们需要改进的地方有:
当len(pending) == 0
时, 禁用case s.dispatchC
分支防止代码发生恐慌
当len(pending) >= maxPending
时禁用 case s.eventSource
分支以避免分配太多内存
改进后的代码如下所示:
这里的技巧是使用一个额外的变量来打开/关闭原始通道,然后将该变量用于select的case语句中。
**警告:**注意不要同时禁用所有case语句,否则for-select 循环将停止工作。
有时我们想提供“尽力而为”(best-effort)的服务。也就是说我们允许通道是“有损”(lossy)的。例如,当我们有过多的事件要分派(dispatch)给接收者,而其中一些可能没有响应时。这情况是存在的,我们可以忽略那些无响应的接收者,因为这样可以:
有时我们只是想让一个容器来存储一组相关的值,而这个容器不会出现在其他任何地方。在这些情况下,我们不关心它的类型。在 Python 中,我们可能会创建一个字典或元组。在 Go 中,我们可以创建一个匿名结构体(Anonymous Struct)。我会用2个例子来说明:
如果你想把你的配置值存储到一个变量中。但如下所示,为它专门创建一个类型似乎有点矫枉过正:
相反你应该这么做:
注意: struct {...}
是变量 Config
的类型——现在你可以通过 Config.Timeout
访问你的配置值。
假设你想测试你 Add()
函数,而不是像这样编写大量的 if-else
语句:
相反,你可以像下面那样将测试用例和测试逻辑分开(译者注:这种测试称为表驱动测试):
当你有许多测试用例时,或者有时需要更改测试逻辑时,这会更便捷。肯定有更多的场景,你可能会发现匿名结构体很方便。例如,当你想解析以下 JSON 时,可以定义一个带有嵌套匿名结构体的匿名结构体,以便可以使用 encoding/json 库对其进行解析。
有时我们有一个包含许多可选字段的复杂结构,这时你会羡慕在 Python 中使用可选参数的功能:
在 Go 中实现的方法是使用函数包装这些选项。也就是说,我们可以构造函数来应用我们的选项值,这些值存储在函数的闭包中。使用上面的示例,我们有2个可选字段,用户可以在创建 Client 实例时指定它们:
包装选项(Wrapping options)这种方式使代码易于使用,更重要的是易于阅读:
map[string]struct{}
实现Setchan struct{}
高效同步 goroutine,并使用 close()
向任意数量的 goroutine 广播信号select-default
模式创建有损通道如果你是一位经验丰富的 Go 程序员,那么你之前可能已经看过这些代码模式。然而,当我第一次开始用 Go 编程时,这对我来说并不明显。
Go 是一种非常强大的语言,它的结构与我们熟悉的大多数语言(即 C/C++、Python、PHP、Java 等)完全不同。因此,正确使用其优美的语法非常重要,否则你最终可能会遇到非常讨厌的错误,这些错误要么难以触发,要么你可能不知道它的来源。
我试图用上面的代码模式来描绘 Go 的本质,但它们还远远不够完整。要了解更多信息,我建议你查看 Google 的精彩演讲。
Go语言中反转字符串很好处理。我们只需要将使用[]byte(string)
强制将字符串转换成字节切片,然后将该字节切片中第一个字节和最后一个字节对调,第二个字节和倒数第二个字节对调,依次类推,完成整个字节切片反转后,再将字节切片转换成字符串就行了。整个反转操作的时间复杂度是O(n)。需要注意的是对于包含中文等多字节文本的字符串需要转换成[]rune
类型。为了减少处理起来的复杂性,本博文就只考虑英文字符串的反转了。相关代码如下:
1 | func main() { |
上面处理是完成了反转字符串的目标,但是string和[]byte或[]rune类型互转时候,会进行内存分配的。至于为啥进行了内存分配可以参见本人写的电子书《深入Go语言之旅》中[]byte(string) 和 string([]byte)为什么需要进行内存拷贝?这一小节。本篇博文不再详述。
既然上面处理使用的[]byte(string)和 string([]byte)方法进行字符串和字节切片互转时候需要进行内存分配,那么有没有不进行内存分配的转换方法呢?
答案是有的。因为string和[]byte底层类型大致一样,我们可以通过非类型安全指针unsafe.Pointer
进行指针类型转换,该方法是优化字符串和字节切片互转的常见手段。具体实现可以参考下面:
1 | func bytes2string(b []byte) string{ |
再接着上面的反转字符串处理,我们使用无内存分配的方式试一下,点击在线运行:
1 | func main() { |
运行上面代码我们可以看到类似下面的SEGV内存错误:
1 | unexpected fault address 0x461f48 |
这是因为我们直接操作的是字符底层内容,而字符串底层内容存储在的进程内存布局的.rodata
段(准确说应该是data
段中.rodata
节)中,该段是只读的。我们反转字符时候,会进行写入操作,故运行时会报出上面的段错误提示,这也是Go中字符串只读的原因。字符串的底层结构如下:
一路下来貌似无法做到在零内存分配情况下反转字符串。其实只需要改变上面字符底层内容所在内存的权限,让它可写就行了。Linux中提供了mprotect系统调用,可以用来更改进程内存页的读写权限。需要注意的mprotect
操作的最小单位是内存页,传入的地址参数需要以页边界对齐。最后代码如下,点击在线运行。
1 | func main() { |
至此任务完成。需要注意的是上面代码中使用到固定大小的数组,不是非常完美的解决方案。
]]>容器是微服务的基石,可以做到每个服务快速autoscale,但随之带来的是服务的消亡是任意不定的,服务如何能够被调用方找到的难题。为了解决这个问题,就需要系统支持服务的注册和服务的发现。对于grpc来说,就是服务提供者grpc server会部署到多个k8s的Pod上,Pod的创建和消亡是任意时刻,不可预测,那就需要有一套机制能够发现grpc server所有Pod的端点信息,保证调用方(grpc client)能够及时准确获取服务提供方信息。所以grpc部署在k8s的方案也必要解决服务的注册和服务的发现。
此外调用方(grpc client)会维持grpc长连接,以及grpc底层使用HTTP/2协议,负载均衡不同与http和tcp,这一点在设计方案时候,也需要特别关注。
K8s service是一个命名负载均衡器,它可以将流量代理到一个或多个Pod(这里面的service指的是ClusterIP
类型的service)。grpc-go可以通过拨号直连到service,让service进行服务发现和负载均衡处理。
k8s service直连方案部署和开发简单,Pod扩容和缩容都可以及时感知。但是由于service负载均衡工作在4层,无法识别7层的HTTP/2协议,会导致负载均衡不均匀的问题。
为什么常规的4层负载均衡器无法对7层的HTTP/2协议进行负载均衡?
gRPC uses the performance boosted HTTP/2 protocol. One of the many ways HTTP/2 achieves lower latency than its predecessor is by leveraging a single long-lived TCP connection and to multiplex request/responses across it. This causes a problem for layer 4 (L4) load balancers as they operate at too low a level to be able to
make routing decisions based on the type of traffic received. As such,
an L4 load balancer, attempting to load balance HTTP/2 traffic, will
open a single TCP connection and route all successive traffic to that
same long-lived connection, in effect cancelling out the load balancing.
上面架构图中说明:svc A是grpc client应用,svc B是grpc server应用,svc A作为服务调用方会调用svc B的服务,图中只画出svc A的一个Pod调用svc B的服务的流程,其他Pod略去。
k8s service支持headless模式,创建service时,设置clusterip=none时,k8s将不再为servcie分配clusterip,即开启headless模式。headless service会将对应的每个 Pod IP 以 A 记录的形式存储。通过dsn lookup访问headless service时候,可以获取到所有Pod的IP信息。
如上图所示,基于k8s headless service方案,需要做下面两个步骤:
grpc client需要通过dns查询headless service,获取所有Pod的IP
获取所有Pod IP后,grpc client需要实现客户端负载均衡,将请求均衡到所有Pod
对于步骤1,grpc-go原生支持dns解析,只需在服务名称前面加上dns://
,grpc-go内置dsn resover会解析该headless service的A记录,得到所有pod地址。
1 | conn, err := grpc.DialContext(ctx, "dns:///"+headlessSvc+":8080", |
对于步骤2,go-grpc原生支持多种负载均衡策略,通过设置rr策略,可以保证后端grpc负载的均衡:
1 | grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`) |
由于grpc具有探活机制,Pod消亡之后,会自动把其摘除。Pod扩容之后,由DNS查询缓存的原因,新加入Pod会等待一定时候才能加入grpc长连接池中。
需要注意的grpc-go内置dns resolver默认解析缓存时间30分钟,这意味着新加入的节点需要在30分钟后才会生效
etcd/consul等支持服务注册和发现的组件,可以运用在grpc-go部署在k8s环境方案中。架构图如下图所示:
从上图可以看到分为三个阶段:
注册阶段
当svc B的grpc server的Pod启动时候,将其信息注册到etcd中
监听阶段
这阶段是发现阶段。当svc A的grpc client的Pod启动后,第一次会去etcd中查询获取svc B的所有Pod端点信息,获取到svc B的节点信息后缓存起来,一方面避免每次都去查询etcd,能够提升性能,另一方面防止etcd发生故障,导致查询不到任何节点信息。此外为了能够及时获取svc A需要支持监听etcd的功能,及时获取到svc B节点的变动信息,比如新加入的Pod节点或者消亡的Pod节点,并更新缓存
负载均衡阶段
当svc A的grpc client获取到svc B中所有的Pod地址信息之后,可以采用内置的round_robin
负载均衡策略进行负载均衡
服务注册时候需要获取自身所在Pod的IP信息,我们可以把Pod相关信息设置成环境变量:
1 | env: |
如果使用etcd作为服务注册中心,其官方提供了grpc服务发现支持,具体可以查看官方文档gRPC naming and discovery。
k8s提供了Endpoints API可以查询service下面的所有Pod信息。k8s Endpoints底层使用etcd存储的,当Pod创建时候,会将信息写入到Endpoints中,当Pod消亡时候会将其摘掉。该方案同etcd外部方案类似,只不过其服务端点信息是从k8s集群中查询,避免维护etcd集群。我们可以使用sercand/kuberesolver这个包,或者使用go-zero框架,其里面内置k8s endpoints解析处理。
该方案需要我们在部署k8s时候,创建具有读取特定命名空间endpoints的service Account,并在Deployment配置文件指定该server Account。k8s部署可以参考k8s endpoints API 模式。
上面几种方案都属于客户端负载均衡,需要代码实现负载均衡功能,尽管grpc客户端原生支持负载均衡功能,但还是不建议使用客户端负载均衡,因为胖客户端缺乏弹性,需要大量自定义代码支持metric,日志记录等功能,且需要针对不同语言重复开发。
Envoy 是一款由 Lyft 开源的 L7 代理和通信总线,是 CNCF 旗下的开源项目,由 C++ 语言实现,通过Filter机制实现强大的定制化能力。我们可以使用Envoy(当然也可以使用其他支持HTTP/2协议的负载均衡组件)来进行集中式代理,服务调用方不必关心后端服务的部署情况,只需要和集中式代理器打交道即可。如下图所示就是k8s环境下使用envoy proxy的架构图:
上面架构图中需要将envoy的服务发现类型设置为STRICT_DNS
,并指向grpc server的headless service,具体k8s部署可以参考:envoy proxy 模式。服务调用方svc A通过clusterIP service转发连接到envoy,envoy做负载均衡,将流量最终流向svc B中Pod中。
此外我们还可以将envoy作为服务调用方的svc A的Pod的sidecar,每一个svv A的Pod内部部署两个容器,一个是grpc-client容器,一个是envoy容器,grpc-client直接与其同Pod内的envoy连接,这就是Envoy proxy as sidecar方案。
从上面架构图可以看到每一个调用方Pod中都一个envoy作为边车,流出流量会经过envoy代理。envoy的配置同上面Envoy proxy方案一样。具体k8s部署可以参考envoy proxy as sidecar 模式。
服务网格(Service Mesh)跟上面的Envoy proxy as sidecar
有点类似,在Service Mesh下,svc A和svc B 中所有Pod中都会有一个流量代理组件作为sidecar,它们构成了data plane,所有流入(ingress)/流出(egress)的流量都会经过sidecar。市场上常见的实现了Service Mesh的工具是istio和linkered。本方案采用istio实现service mesh。具体k8s部署可以参考service mesh 模式。
方案 | 负载均衡类型 | 优点 | 缺点 |
---|---|---|---|
k8s service直连 | Proxy Model | 部署和使用最简单 | 未实现HTTP/2协议的负载均衡 |
基于Etcd/consul 等外部服务注册中心 | Balancing-aware Client | 使用相对简单,服务信息方便查看 | 1. 需要维护Etcd等服务注册中心 2.服务提供者需要实现注册机制,服务调用方需要实现发现机制 |
k8s endpoints | Balancing-aware Client | 部署和使用相对简单 | 需要配置Pod支持serviceAccount,在权限要求很严系统中,比较麻烦 |
Envoy proxy | Proxy Model | 部署和使用相对简单 | 由于需要走k8s service代理和envoy代理,性能相比有一定损失 |
Envoy proxy as sidecar | External Load Balancing Service | 部署和使用相对简单,可以通过度量envoy指标,获取服务质量 | 部署相对复杂 |
Service Mesh | External Load Balancing Service | 功能强大,支持熔断器,灰度发布等功能 | 部署相对复杂,链路长,有性能损耗,内部机制复杂,出问题定位难 |
上面表格中负载均衡类型的介绍,可以查看gRPC服务发现&负载均衡,其中介绍到Proxy Model指的是集中式LB,Balancing-aware Client指的是进程内LB,External Load Balancing Service 是独立LB进程,一般就是sidecar代理。
在这篇文章中,我将探索下Prometheus Go 客户端指标,这些指标由client_go
通过promhttp.Handler()
暴露出来的。通过这些指标能帮助你更好的理解 Go 是如何工作的。
想对Prometheus了解更多吗?你可以去学习下Monitoring Systems and Services with Prometheus,这是一门很棒的课程,可以让你快速上手。
让我们从一个简单的程序开始,它注册prom handler
并且监听8080端口:
1 |
|
当你请求metric
端点时候,你将看到类似下面内容:
1 | # HELP go_gc_duration_seconds A summary of the GC invocation durations. |
在初始化时,client_golang
注册了 2 个 Prometheu
收集器:
进程收集器 —— 用于收集基本的 Linux 进程信息,比如 CPU、内存、文件描述符使用情况,以及启动时间等。
Go 收集器 —— 用于收集有关 Go 运行时的信息,比如 GC、gouroutine 和 OS 线程的数量的信息。
这个收集器的作用是读取proc
文件系统。proc
文件系统暴露内核内部数据结构,用于获取系统信息。
比如Prometheus
客户端读取 /proc/PID/stat
文件,得到如下所示内容:
1 | 1 (sh) S 0 1 1 34816 8 4194560 674 43 9 1 5 0 0 0 20 0 1 0 89724 1581056 209 18446744073709551615 94672542621696 94672543427732 140730737801568 0 0 0 0 2637828 65538 1 0 0 17 3 0 0 0 0 0 94672545527192 94672545542787 94672557428736 140730737807231 140730737807234 140730737807234 140730737807344 0 |
你可以通过cat /proc/PID/status
获取上面信息的可读版本。
process_cpu_seconds_total – 该指标计算使用到utime
(Go 进程执行在用户态模式下的滴答数)和stime
(Go 进程执行在内核态时候的滴答数,比如系统调用时),它们的单位jiffies
,jiffy 描述了两次系统定时器中断之间的滴答时间。process_cpu_seconds_total 等于 utime
和 stime
之和除以USER_HZ
。这样计算是有道理的,因为将程序滴答总数除以 Hz(每秒滴答数)得到就是操作系统运行该进程的总时间(以秒为单位)
process_virtual_memory_bytes - 即vss(Virtual Set Size),vss指的虚拟内存集,它是全部分配的内存,包括分配但未使用的内存、共享内存、换出的内存。
process_resident_memory_bytes - 即rss(Resident Set Size),rss指的是常驻内存集,是进程实际使用的内存,它不包括分配但未使用的内存,也不包括换出的内存页面,但包含共享内存。
process_start_time_seconds – 它使用到start_time
,start_time
描述了进程启动时的时间,单位是jiffies,数据来自/proc/stat
。最后将start_time
除以 USER_HZ
得到以秒为单位的值。
process_open_fds - 通过计算/proc/PID/fd
目录下的文件总数得来。它显示了 Go 进程当前打开的常规文件、套接字、伪终端总数。
process_max_fds - 读取 /proc/{PID}/limits
文件中,Max Open Files
所在行的值获得,该值是软限制(soft limit)。软限制(soft limit)是内核为相应资源强制执行的值,而硬限制(hard limit)充当软限制的上限。
在 Go 中你可以通过err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &syscall.Rlimit{Cur: 9, Max: 10})
来设置最大文件打开数限制。
Go Collector 的大部分指标来自runtime
、runtime/debug
这两个包。
go_goroutines – 通过runtime.NumGoroutine()
调用获取,它基于调度器结构sched
和全局allglen
变量计算得来。由于sched
结构体的所有字段可能并发的更改,因此最后会检查计算的值是否小于1,如果小于1,那么返回1。
go_threads – 通过runtime.CreateThreadProfile()
调用获取,它读取的是全局 allm
变量。如果你还不知道什么是 M 或 G,你可以阅读我的博文。
go_gc_duration_seconds – 数据来自调用 debug.ReadGCStats()
,调用该函数时候,会将传入参数GCStats结构体的PauseQuantile字段设置为5,这样函数将会返回最小、25%、50%、75% 和最大这5个GC暂停时间百分位数。然后prometheus go客户端根据返回的GC暂停时间百分位数、以及NumGC
和PauseTotal
变量创建摘要类型指标。
go_info – 该指标为我们提供了 Go 版本信息。该指标数据来自runtime.Version()
。
Go 收集器提供一系列关于内存和GC的指标。所有内存指标都来自runtime.ReadMemStats()
,它为我们提供了 MemStats 结构体的指标信息。
让我担忧的是runtime.ReadMemStats()
会STW
(stop-the-world)。所以我想知道该暂停会带来多少实际成本?在 stop-the-world 暂停期间,所有 goroutine
都会暂停,以便 GC 可以运行。我可能会在以后的文章中对有没有使用Prometheus Go客户端的应用程序进行对比。
从上面我们已经看到 Linux 为我们提供了内存统计的 rss/vss
指标,所以很自然地好奇,我们究竟该使用MemStats
中提供的指标还是 rss/vss
提供的指标?
使用rss和vss的好处在于它基于 Linux 原语并且与编程语言无关。理论上你可以检测任何程序获知它消耗了多少内存,你可以保证指标命名的一致性,比如Prometheus Go客户端中process_virtual_memory_bytes
和 process_resident_memory_bytes
指标。
但是在实际中,Go 进程启动时会预先占用大量虚拟内存,就像上面那样的简单程序在我的机器(x86_64 Ubuntu)上占用了 544MiB 的 vss,这有点令人困惑,而rss在7Mib左右,这是更接近实际使用情况。
使用基于 Go 运行时的指标可以提供正在运行的应用程序中所发生事情的更细粒度的信息。这样你能够更轻松地找出你的程序是否存在内存泄漏、GC花费了多长时间、内存回收了多少。此外当你优化程序的内存分配时,它为你指明了正确的方向。
我没有详细研究 Go的GC 和内存模型是如何工作的,它们是并发模型的一部分。这部分对我来说还是新知。接下来让我们来看看这些指标:
go_memstats_alloc_bytes – 该指标展示了在 堆 上为对象分配了多少字节的内存。该值与 go_memstats_heap_alloc_bytes 相同。该指标包括所有可达(reachable)堆对象和不可达(unreachable)对象(GC尚未释放的)占用的内存大小。
go_memstats_alloc_bytes_total - 该指标随着对象在堆中分配而增加,但在释放对象时并不会减少。我认为它非常有用,因为它的只会增加,类似Prometheus的计数器类型,对该指标我们可以使用rate()
来获取内存消耗速度。
go_memstats_sys_bytes – 该指标用于衡量 Go 从系统中总共获取了多少字节的内存。
go_memstats_lookups_total – 它是一个计数器值,用于计算有多少指针解引用。我们可以使用rate()
函数来计算指针解引用速率。
go_memstats_mallocs_total – 它是一个计数器值,用于显示有多少堆对象进行分配了。我们可以使用rate()
函数来计算堆对象分配速率。
go_memstats_frees_total – 它是一个计数器值,用于显示有多个堆对象被释放。我们可以使用rate()
函数计算堆对象释放速率。我们可以通过go_memstats_mallocs_total – go_memstats_frees_total
得到存活的堆对象数量。
Go 以span
形式管理内存,span
是8K大小或更大的连续内存空间。有 3 种类型的span
:
空闲span – 该span没有存放任何对象可以释放回操作系统,也可重用于堆分配,或重用于栈内存。
正在使用span - 该span上最少有一个堆对象。
栈span – 该span用于goroutine
栈。这类型span,既可以用于堆,也可以栈,但不会同时用于堆和栈分配。
go_memstats_heap_alloc_bytes – 类似go_memstats_alloc_bytes指标.
go_memstats_heap_sys_bytes – 该指标显示从操作系统中为堆分配的内存字节数。它包括已保留但尚未使用的 虚拟地址空间 。
go_memstats_heap_idle_bytes – 显示空闲span占用的内存字节数。
通过go_memstats_heap_idle_bytes
减去 go_memstats_heap_released_bytes
可以估计出可以是否释放出的内存大小,但这部分内存由Go runtime维持,并不一定会归还OS,以便可以快速用于在堆上分配对象。
go_memstats_heap_inuse_bytes – 显示正在使用的span占用字节数。
通过 go_memstats_heap_alloc_bytes 减去 go_memstats_heap_inuse_bytes可以估算出已分配的堆内存中有多少未被使用
go_memstats_heap_released_bytes – 显示有多少空闲span已归还OS.
go_memstats_heap_objects – 显示有多少对象是堆上在分配的,它会随着 GC和新对象的分配而改变。
go_memstats_stack_inuse_bytes – 显示栈内存span上已使用的内存大小,该span上面至少分配了一个栈对象。
go_memstats_stack_sys_bytes – 显示从 OS 中获得多少字节的栈内存。它是 go_memstats_stack_inuse_bytes 加上OS线程栈得到。
Prometheus Go客户端没有提供go_memstats_stack_idle_bytes,因为未使用的栈span计入到 go_memstats_heap_idle_bytes。
堆外内存指标是为Go 运行时内部结构分配的内存大小的指标,这些内部结构没有在堆上分配,因为它们实现了堆。
go_memstats_mspan_inuse_bytes - 显示mspan结构体使用的内存大小。
go_memstats_mspan_sys_bytes – 显示从操作系统中分配的,用于mspan结构体的内存大小。
go_memstats_mcache_inuse_bytes – 显示mcache结构体使用的内存大小。
go_memstats_mcache_sys_bytes – 显示从操作系统分配的,用于mcache结构体的内存大小。
go_memstats_buck_hash_sys_bytes – 显示用于profiling的哈希表占用的内存大小。
go_memstats_gc_sys_bytes – 显示垃圾收集元数据占用内存大小。
go_memstats_other_sys_bytes – 显示用于其他运行时分配占用内存大小。
go_memstats_next_gc_bytes – 显示下个GC循环时候,堆占用内存大小。GC的目标是保证go_memstats_heap_alloc_bytes小于此值。
go_memstats_last_gc_time_seconds – 上一次GC完成时的时间戳。
go_memstats_last_gc_cpu_fraction – 显示自程序启动以来,GC 所占用CPU时间的比例。该指标也可在设置环境变量GODEBUG=gctrace=1
时查看到。
Prometheus Go客户端提供了很多指标,我认为学习这些指标的最好方法就是使用它,所以我将使用文章开头相同的程序,并获取/metrics
端点数据,部分数据如下所示:
1 | process_resident_memory_bytes 1.09568e+07 |
根据上面指标,我们转换得到可读性更好的数据:
1 | rss = 1.09568e+07 = 10956800 bytes = 10700 KiB = 10.4 MiB |
有趣的是heap_inuse_bytes 比 heap_alloc_bytes
多。我个人认为 heap_alloc_bytes
显示是对象的字节数, heap_inuse_bytes
显示是span的内存字节数。将heap_inuse_bytes
除以span
的大小得出:3039232 / 8192 = 371 个span。
heap_inuse_bytes
减去heap_alloc_bytes
,显示的是在使用中的span的可用内存空间大小,即2.9 MiB – 2.1 MiB = 0.8 MiB。这意味着我们可以在不使用新span的情况下,可以在堆上分配 0.8 MiB 的对象。需要注意的是内存碎片的存在。想象一下,如果要创建10K字节的切片时,内存中可能没有10K字节的连续内存块,那么它需要创建新的span,而不是复用。
将heap_idle_bytes
减去heap_released_byte
表明我们有大约 60.6 MiB 的未使用span,它们是从操作系统中保留的,可以返回给操作系统。它有 63643648/8192 = 7769 个span。
heap_sys_bytes
大小是63.6MiB,它是堆的最大大小,拥有66682880/8192 = 8140 个span。
mallocs_total
显示我们分配了18707 个对象并释放了 12209 个(go_memstats_frees_total
)。所以目前我们有 18707-12209 = 6498 个对象。我们可以将 heap_alloc_bytes
除以6498,可以得到对象的平均内存大小是2243440 / 6498 = 345.3 个字节。
sys_bytes大小应该是所有*sys指标的总和,即
sys_bytes == mspan_sys_bytes + mcache_sys_bytes + buck_hash_sys_bytes + gc_sys_bytes + other_sys_bytes + stack_sys_bytes + heap_sys_bytes
使用上面数字验证:
72284408 == 32768 + 16384 + 1443899 + 2371584 + 1310909 + 425984 + 66682880, which is 72284408 == 72284408,我们发现完全匹配。
关于sys_bytes
的一个有趣的细节是它的大小是68.9 MiB,而操作系统的vss
是616.7MiB, rss
是10.4 MiB。这说明这些数字并不是匹配的。按照我的理解,我们的内存的一部分可能位于 OS 的内存页面中,这些页面位于交换或文件系统中(不在 RAM 中),这也就解释了为什么rss
小于 sys_bytes
了。并且vss
包含很多东西,例如映射的 libc、pthreads 库等。你可以从/proc/PID/maps
和 /proc/PID/smaps
文件中,查看到当前正在映射的内容。
gc_cpu_fraction
运行得非常低,只有0.000001 的 CPU 时间用于 GC。这真的很酷。
next_gc_bytes
显示 GC 的目标是将 heap_alloc_bytes
保持在 4 MiB 以下,因为heap_alloc_bytes
目前为 2.1 MiB,说明GC 目标已达成。
系统调用(system call)指的是运行在用户空间的程序向操作系统内核请求具有更高权限的服务。究竟是哪些服务呢?这些服务指的是由操作系统内核进行管理的服务,比如进程管理,存储,内存,网络等。以打开文件为例子,用户程序需要调用open
和read
这两个系统调用,在c语言中要么使用libc库实现(底层也是系统调用),要么直接使用系统调用实现。
Linux系统中为什么一定要经过系统调用才能访问特资源呢,难道就不能在用户空间完成调用访问功能吗?之所以这么设计是考虑到系统隔离性,提高系统安全性和容错性,避免恶意攻击。操作系统把CPU访问资源的安全级别分为4个级别,这些级别称为特权级别(privilege level),也称为CPU环(CPU Rings)。在任一时刻,CPU都是在一个特定的特权级下运行的,从而决定了什么可以做,什么不可以做。这些级别可以形象的考虑成一个个圆环,里面是最高特权的Ring0,向外依次是Ring1,Ring2,最后是最低特权的Ring3。当发生系统调用时候,应用程序将会从应用空间进入内核空间,此时特权级别会由Ring3提升到Ring0,应用程序代码也会跳到相关系统调用代码处执行。
早期时候,系统调用是通过软中断int 0x80
实现的。由于软中断实现方式需要扫描中断描述表找到系统调用对应入口地址,性能较差,为此Linux系统引入了专有的系统调用指令来完成系统调用,在64位系统下相关指令是SYSCALL/SYSRET指令。我们需要知道的是系统调用时候需要由用户态切换内核态,这会造成一定的性能损失。
复习完系统调用的概念,我们接下来使用strace命令来看下下面代码中time.Now()
有没有使用到系统调用。
1 | package main |
执行下面命令,先构建出二进制可执行文件test,然后使用strace查看test执行过程中所有的系统调用,看看有没有使用到任何与时间相关的系统调用:
1 | go build -gcflags="-N -l" -v -o test |
结果我们发现在调用time.Now()时候,并没有使用到任何与时间相关的系统调用。我们可以初步得出调用time.Now()时候没有发生系统调用。但这个结论与上面介绍的系统调用概念相冲突,因为获取时间需要读取系统时钟信息,它属于Ring0特权,需要使用系统调用的。
接下来我们来分析time.Now()的实现,查看调用它时候发生了什么?
分析源码有两个途径,第一种是直接去查看源码,在查看源码过程中由于源码内容繁多,且存在汇编代码以及多系统支持,代码编辑器并不能支持很好支持提示和跳转,有时候就需要我们使用全局搜索相关关键字才能找到函数或变量位置。第二种是使用gdb或者dlv等调试工具,通过打断点形式来追踪查看执行过程的源码。这两种方式一般都是混合使用的。这次我们将使用gdb来分析。笔者系统环境如下:
1 | vagrant@vagrant:~$ go version |
首先我们启动gdb,接着在main函数处设置断点并运行程序:
接下来我们在time.Now()处(即行6处)设置断点,并执行continue和step命令来查看time.Now()内部实现:
从上图我们可以看到time.Now()源码位于在time/time.go文件中第1121行。time.Now()函数会调用now()函数获取当前秒数和纳秒数。接下来我们看下now()函数的实现:
从上图可以看到当我们查看now()函数时候,它跳到time_now()处。这是因为编译指令go:linkname
的缘故,go:linkname
指令用于将当前源文件中私有函数或者变量在编译时链接到指定的方法或变量。比如//go:linkname time_now time.now
意思是将time_now链接到time.now中,所以time包的now函数实现是由time_now完成的,它的位置是runtime/timestub.go的第15行处。
接下来我们查看time_now中walltime的实现。从下图中可以看到walltime位于runtime/time_nofake.go
文件中第23行,它调用walltime1函数。walltime1是由汇编程序实现的,源码位于runtime/sys_linux_amd64.s
第209处。
接下里我们来看看汇编代码,我们只关心其中runtime·walltime1
函数部分,具体就是sys_linux_amd64.s文件中的209到210之间的汇编代码,核心部分已用箭头标示出来了:
上图中汇编代码主要完成两个功能,首先完成将goroutine栈切换到g0栈。
1 | get_tls(CX) // 将tls加载到CX寄存器上 |
根据GMP模型,M执行的栈可能是系统栈(即g0栈)或者signal栈上,也有可能用户线程栈(即goroutine栈)上。通过getg()
可以返回正在执行的g,这个g可能是M的g0,或者gsignal,也可能是和M关联的goroutine,而getg().m.curg
返回的永远是M关联的goroutine,那么我们可以通过两者比较getg() == getg().m.curg
判断当前M执行的栈是不是系统栈。上面汇编代码切换到系统栈之前进行栈类型判断就是基于此实现的。
第二功能就是调用runtime·vdsoClockgettimeSym
变量指向的函数,来获取当前秒数和毫秒数。这个也是time.Now()实现的核心。
1 | noswitch: |
从上面可以看到time.Now()最终调用的是runtime·vdsoClockgettimeSym
这个变量指向的函数,函数入口地址是0x7ffff7ffe8e0
。为什么要用一个变量来指向函数地址,而不是正常情况下通过函数符号来获取地址?我们先推测是该函数地址不是固定的,它会随着应用不同而变化的,它需要在运行时动态的获取地址。
接下来我们看下入口地址为0x7ffff7ffe8e0
函数的汇编代码:
我们可以看到地址0x7ffff7ffe8e0
对应的函数名称clock_gettime
。
一路gdb调试过来,最后我们发现必须去了解runtime·vdsoClockgettimeSym
这个变量是怎么赋值成clock_gettime函数入口地址的。
我们知道在Go应用启动时候,Go运行时会完成ncpu,g0,schet等全局变量的初始化的,这里面的runtime·vdsoClockgettimeSym
也不例外,他们在执行main函数之前已经完成初始化了。所以我们使用watch命令观察vdsoClockgettimeSym
变量变化时候,必须在应用启动时候。
观察变量vdsoClockgettimeSym
变化时候,我们可以看到是函数vdsoParseSymbols
更改了其值,它将0x7ffff7ffe8e0
赋值给vdsoClockgettimeSym
这个变量,0x7ffff7ffe8e0
是函数clock_gettime的入口地址。
需要注意的是在gdb中访问vdsoClockgettimeSym
这个变量是runtime.vdsoClockgettimeSym
,runtime和vdsoClockgettimeSym之间的点号(.)和汇编里面的点号(·)是不一样的。
接下来我们使用bt命令,我们可以看到整个函数栈帧,后面我们可以打开代码编辑器依图索骥:
至此我们使用gdb分析追踪time.Now()结束了。我们用代码编辑器查看vdsoParseSymbols这个函数,它位于runtime/vdso_linux.go
文件中。在这个文件开头注释有这么一句话:Look up symbols in the Linux vDSO.
。结合函数名称,可以知道vdsoParseSymbols用来完成vDSO的符号解析。这就引入了vDSO概念。
vDSO是Virtual Dynamic Shared Object的缩写,Dynamic Shared Object是我们非常熟悉的Linux下面的动态库的全称。vDSO中文名称是虚拟动态共享对象,是Linux内核对用户空间暴露内核函数的一种机制。vDSO实现方式是将内核中某些不涉及安全的系统调用代码直接映射到用户空间里面,那么用户代码不再使用系统调用,也能完成相关功能。由于避免了系统调用时候需要用户空间到内核空间的切换,vDSO机制可以减少性能上面的消耗。vDSO支持的系统调用有clock_gettime
,time
,getcpu
等。
我们可以通过查看进程的内存映射,可以找到vDSO模块:
从上面可以发现vDSO地址是从0x7ffff7ffe000
到0x7ffff7fff000
。
为了安全性,防止被恶意程序替换,vDSO的起始地址不是固定的,每个二进制应用的vDSO都是不一样的。我们可以使用下面命令测试验证,可以看到每次执行的vdso起始地址都不一样:
1 | vagrant@vagrant:~$ LD_SHOW_AUXV=1 cat /proc/self/maps | egrep '\[vdso|AT_SYSINFO' |
接下我们尝试把内存中vDSO的信息保存到文件中,查看它具体是什么格式?这里面介绍两种方法。
第一种使用gdb的dump命令,把进程的内存中vdso部分保存下来。首先我们使用info proc mappings
找到应用进程内存中vdso的起始地址,然后使用dump memory命令把对应起始地址的内存数据保存到vdso.so文件中。
第二种方式是自己编写代码实现,点击查看完整源码。
1 | outputFile, err := os.Create(*output) |
通过上面介绍的方法得到vdso文件之后,我们可以使用file
命令查看文件类型,以及objdump -T
命令查看其Dynamic symbols
信息。
从上图中我们再次看到了clock_gettime
。
从上面介绍中,我们知道了Go语言中调用time.Now()时候,没有发生系统调用,是因为它使用vDSO技术,将系统调用clock_gettime映射到应用空间,Go语言调用应用空间相应代码,避免了系统调用。
上面介绍中也提到了vDSO的入口地址不是固定的,那么Go语言是如何找到这个入口地址的,并找到clock_gettime
函数地址的?
Go语言是通过读取辅助向量(Auxiliary Vectors)信息来获取vDSO开始地址的,然后读取vDSO信息,解析出clock_gettime
地址。Auxiliary Vectors是内核ELF二进制加载器提供给用户空间的一些信息的集合,包括了可执行的入口地址、线程的gid、线程uid、vdso入口地址等信息。
Auxiliary Vectors包含一系列的键值对,每一个键对应一个值。vDSO入口地址对应的键是AT_SYSINFO_EHDR。具体信息可以查看系统调用getauxval的手册。Go runtime中相关源码如下,具体细节就不在赘述了:
1 | func vdsoauxv(tag, val uintptr) { |
文末留一个思考题:Go语言中调用time.Sleep()时候会不会发生系统调用?
atomic是Go内置原子操作包。下面是官方说明:
Package atomic provides low-level atomic memory primitives useful for implementing synchronization algorithms. atomic包提供了用于实现同步机制的底层原子内存原语。
These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package. Share memory by communicating; don’t communicate by sharing memory. 使用这些功能需要非常小心。除了特殊的底层应用程序外,最好使用通道或sync包来进行同步。通过通信来共享内存;不要通过共享内存来通信。
atomic包提供的操作可以分为三类:
T类型是int32
、int64
、uint32
、uint64
、uintptr
其中一种。
1 | func AddT(addr *T, delta T) (new T) |
unsafe.Pointer
类型的操作1 | func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool) |
atomic.Value
类型提供Load/Store操作atomic提供了atomic.Value
类型,用来原子性加载和存储类型一致的值(consistently typed value)。atomic.Value
提供了对任何类型的原则性操作。
1 | func (v *Value) Load() (x interface{}) // 原子性返回刚刚存储的值,若没有值返回nil |
1 | package main |
1 | package main |
对于Uint32和Uint64类型Add方法第二个参数只能接受相应的无符号整数,atomic
包没有提供减法SubstractT
操作:
1 | func AddUint32(addr *uint32, delta uint32) (new uint32) |
对于无符号整数V
,我们可以传递-V
给AddT方法第二个参数就可以实现减法操作。
1 | package main |
atomic
包提供的三类操作的前两种都是直接通过汇编源码实现的(sync/atomic/asm.s):
1 | #include "textflag.h" |
从上面汇编代码可以看出来atomic操作通过JMP操作跳到runtime/internal/atomic
目录下面的汇编实现。我们把目标转移到runtime/internal/atomic
目录下面。
该目录包含针对不同平台的atomic汇编实现asm_xxx.s
。这里面我们只关注amd64
平台asm_amd64.s
(runtime/internal/atomic/asm_amd64.s)和atomic_amd64.go
(runtime/internal/atomic/atomic_amd64.go)。
函数 | 底层实现 |
---|---|
SwapInt32 / SwapUint32 | runtime∕internal∕atomic·Xchg |
SwapInt64 / SwapUint64 / SwapUintptr | runtime∕internal∕atomic·Xchg64 |
CompareAndSwapInt32 / CompareAndSwapUint32 | runtime∕internal∕atomic·Cas |
CompareAndSwapUintptr / CompareAndSwapInt64 / CompareAndSwapUint64 | runtime∕internal∕atomic·Cas64 |
AddInt32 / AddUint32 | runtime∕internal∕atomic·Xadd |
AddUintptr / AddInt64 / AddUint64 | runtime∕internal∕atomic·Xadd64 |
LoadInt32 / LoadUint32 | runtime∕internal∕atomic·Load |
LoadInt64 / LoadUint64 / LoadUint64/ LoadUintptr | runtime∕internal∕atomic·Load64 |
LoadPointer | runtime∕internal∕atomic·Loadp |
StoreInt32 / StoreUint32 | runtime∕internal∕atomic·Store |
StoreInt64 / StoreUint64 / StoreUintptr | runtime∕internal∕atomic·Store64 |
AddUintptr
、 AddInt64
以及 AddUint64
都是由方法runtime∕internal∕atomic·Xadd64
实现:
1 | TEXT runtime∕internal∕atomic·Xadd64(SB), NOSPLIT, $0-24 |
LOCK指令是一个指令前缀,其后是读-写性质的指令,在多处理器环境中,LOCK指令能够确保在执行LOCK随后的指令时,处理器拥有对数据的独占使用。若对应数据已经在cache line里,也就不用锁定总线,仅锁住缓存行即可,否则需要锁住总线来保证独占性。
XADDQ指令用于交换加操作,会将源操作数与目的操作数互换,并将两者的和保存到源操作数中。
AddInt32
、 AddUint32
都是由方法runtime∕internal∕atomic·Xadd
实现,实现逻辑和runtime∕internal∕atomic·Xadd64
一样,只是Xadd中相关数据操作指令后缀是L
:
1 | TEXT runtime∕internal∕atomic·Xadd(SB), NOSPLIT, $0-20 |
StoreInt64
、StoreUint64
、StoreUintptr
三个是runtime∕internal∕atomic·Store64
方法实现:
1 | TEXT runtime∕internal∕atomic·Store64(SB), NOSPLIT, $0-16 |
XCHGQ指令是交换指令,用于交换源操作数和目的操作数。
StoreInt32
、StoreUint32
是由runtime∕internal∕atomic·Store
方法实现,与runtime∕internal∕atomic·Store64
逻辑一样,这里不在赘述。
CompareAndSwapUintptr
、CompareAndSwapInt64
和CompareAndSwapUint64
都是由runtime∕internal∕atomic·Cas64
实现:
1 | TEXT runtime∕internal∕atomic·Cas64(SB), NOSPLIT, $0-25 |
CMPXCHGQ指令是比较并交换指令,它的用法是将目的操作数和累加寄存器AX进行比较,若相等,则将源操作数复制到目的操作数中,否则将目的操作复制到累加寄存器中。
SwapInt64
、SwapUint64
、SwapUintptr
实现的方法是runtime∕internal∕atomic·Xchg64
,SwapInt32
和SwapUint32
底层实现是runtime∕internal∕atomic·Xchg
,这里面只分析64的操作:
1 | TEXT runtime∕internal∕atomic·Xchg64(SB), NOSPLIT, $0-24 |
LoadInt32
、LoadUint32
、LoadInt64
、 LoadUint64
、 LoadUint64
、 LoadUintptr
、LoadPointer
实现都是Go实现的:
1 | //go:linkname Load |
最后我们来分析atomic.Value类型提供Load/Store操作。
atomic.Value类型定义如下:
1 | type Value struct { |
atomic.Value底层存储的是空接口类型,空接口底层结构如下:
1 | type eface struct { |
atomic.Value内存布局如下所示:
从上图可以看出来atomic.Value内部分为两部分,第一个部分是_type类型指针,第二个部分是unsafe.Pointer类型,两个部分大小都是8字节(64系统下)。我们可以通过以下代码进行测试:
1 | type Value struct { |
接下来我们看下Store方法:
1 | func (v *Value) Store(x interface{}) { |
总结上面Store流程:
从流程2可以看出来,每次调用Store方法时传入参数都必须是同一类型的变量。当Store完成之后,实现了“鸠占鹊巢”,atomic.Value底层存储的实际上是(interface{})x。
最后我们看看atomic.Value的Load操作:
1 | func (v *Value) Load() (x interface{}) { |
GDB(GNU symbolic Debugger)是Linux系统下的强大的调试工具,可以用来调试ada, c, c++, asm, minimal, d, fortran, objective-c, go, java,pascal 等多种语言。
我们以调试go
代码为示例来介绍GDB的使用。源码内容如下:
1 | package main |
构建二进制应用:
1 | go build -gcflags="-N -l" -o test main.go |
1 | gdb ./test |
进入gdb调试界面之后,执行run
命令运行程序。若程序已经运行,我们可以attach
该程序的进程id进行调试:
1 | $ gdb |
当执行attach
命令的时候,GDB首先会在当前工作目录下查找进程的可执行程序,如果没有找到,接着会用源代码文件搜索路径。我们也可以用file命令来加载可执行文件。
或者通过命令设置进程id:
1 | gdb test 1785 |
若已运行的进程不含调试信息,我们可以使用同样代码编译出一个带调试信息的版本,然后使用file和attach命令进行运行调试。
1 | $ gdb |
GDB也支持多窗口图形启动运行,一个窗口显示源码信息,一个窗口显示调试信息:
1 | gdb test -tui |
GDB支持在运行过程中使用Crtl+X+A
组合键进入多窗口图形界面, GDB支持的快捷操作有:
1 | Crtl+X+A // 多窗口与单窗口界面切换 |
通过run
命令运行程序:
1 | (gdb) run |
指定命令行参数运行:
1 | (gdb) run arg1 arg2 |
或者通过set
命令设置命令行参数:
1 | (gdb) set args arg1 arg2 |
除了run
命令外,我们也可以使用start
命令运行程序。start
命令会在在main函数的第一条语句前面停下来。
1 | (gdb) start |
GDB中是通过break
命令来设置断点(BreakPoint),break
可以简写成b
。
break function
在指定函数出设置断点,设置断点后程序会在进入指定函数时停住
break linenum
在指定行号处设置断点
break +offset/-offset
在当前行号的前面或后面的offset行处设置断点。offset为自然数
break filename:linenum
在源文件filename的linenum行处设置断点
break filename:function
在源文件filename的function函数的入口处设置断点
break *address
在程序运行的内存地址处设置断点
break
break命令没有参数时,表示在下一条指令处停住。
break … if
…可以是上述的参数,condition表示条件,在条件成立时停住。比如在循环境体中,可以设置break if i=100,表示当i为100时停住程序
我们可以通过info
命令查看断点:
1 | (gdb) info breakpoint # 查看所有断点 |
删除断点是通过delete
命令删除的,delete
命令可以简写成d
:
1 | (gdb) delete 3 # 删除3号断点 |
1 | (gdb) disable 3 # 禁用3号断点 |
next
用于单步执行,会一行行执行代码,运到函数时候,不会进入到函数内部,跳过该函数,但会执行该函数,即step over
。可以简写成n
。
1 | (gdb) next |
step
用于单步进入执行,跟next
命令类似,但是遇到函数时候,会进入到函数内部一步步执行,即step into
。可以简写成s
。
1 | (gdb) step |
与step
相关的命令stepi
,用于每次执行每次执行一条机器指令。可以简写成si
。
continue
命令会继续执行程序,直到再次遇到断点处。可以简写成c
:
1 | (gdb) continue |
until
命令可以帮助我们实现运行到某一行停住,可以简写成u
:
1 | (gdb) until 5 |
skip
命令可以在step时跳过一些不想关注的函数或者某个文件的代码:
1 | (gdb) skip function add # step时跳过add函数 |
其他相关命令:
注意: 当不带skip号时候,是针对所有skip进行设置。
finish
命令用来将当前函数执行完成,并打印函数返回时的堆栈地址、返回值、参数值等信息,即step out
。
1 | (gdb) finish |
GDB中的list
命令用来显示源码信息。list
命令可以简写成l
。
list
从第一行开始显示源码,继续输入list,可列出后面的源码
list linenum
列出linenum行附近的源码
list function
列出函数function的代码
list filename:linenum
列出文件filename文件中,linenum行出的代码
list filename:function
列出文件filename中,函数function的代码
list +offset/-offset
列出在当前行号的前面或后面的offset行附近的代码。offset为自然数。
list +/-
列出当前行后面或者前面的代码
list linenum1, linenum2
列出行linenum1和linenum2之间的代码
info
命令用来显示信息,可以简写成i
。
info files
显示当前的debug文件,包含程序入口地址,内存分段布局位置信息等
info breakpoints
显示当前设置的断点列表
info registers
显示当前寄存器的值,可以简写成i r
。指定寄存器名称,可以查看具体寄存器信息:i r rsp
info all-registers
显示所有寄存器的值。GDB提供四个标准寄存器:pc是程序计数器寄存器,sp是堆栈指针。fp用于记录当前堆栈帧的指针,ps用于记录处理器状态的寄存器,GDB会处理好不同架构系统寄存器不一致问题,比如对于amd64架构,pc对应就是rip寄存器。
引用寄存器内容是将寄存器名前置pc就是程序计数器寄存器值。
info args
显示当前函数参数
info locals
显示当前局部变量
info frame
查看当前栈帧的详细信息,包括rip信息,正在运行的指令所在文件位置
info variables
查看程序中的变量符号
info functions
查看程序中的函数符号
info functions regexp
通过正则匹配来查看程序中的函数符号
info goroutines
显示当前执行的goroutine列表,带*的表示当前执行的。注意需要加载go runtime支持。
info stack
查看栈信息
info proc mappings
可以简写成i proc m
。用来查看应用内存映射
info proc [procid]
显示进程信息
info proc status
显示进程相关信息,包括user id和group id;进程内有多少线程;虚拟内存的使用;挂起的信号,阻塞的信号,忽略的信号;TTY;消耗的系统和用户时间;堆栈大小;nice值
info display
info watchpoints
列出当前所设置了的所有观察点
info line [linenum]
查看第linenum的代码指令地址信息,不带linenum时,显示的是当前位置的指令地址信息
info source
显示此源代码的源代码语言
info sources
显示程序中所有有调试信息的源文件名,一共显示两个列表:一个是其符号信息已经读过的,一个是还未读取过的
info types
显示程序中所有类型符号
info types regexp
通过正则匹配来查看程序中的类型符号
其他类似命令有:
show args
查看命令行参数
show environment [envname]
查看环境变量信息
show paths
查看程序的运行路径
whatis var1
显示变量var1类型
ptype var1
显示变量var1类型,若是var1结构体类型,会显示该结构体定义信息。
通过where
可以查看调用栈信息:
1 | (gdb) where |
通过watch
命令,可以设置观察点。当观察点的变量发生变化时,程序会停下来。可以简写成wa
1 | (gdb) watch sum |
我们可以通过开启disassemble-next-line
自动显示汇编代码。
1 | (gdb) set disassemble-next-line on |
当面我们可以查看指定函数的汇编代码:
1 | (gdb) disassemble main.main |
disassemble
可以简写成disas
。我们也可以将源代码和汇编代码一一映射起来后,查看
1 | (gdb) disas /m main.main |
GDB默认显示汇编指令格式是AT&T格式,我们可以改成intel格式:
1 | (gdb) set disassembly-flavor intel |
display
命令支持自动显示变量值功能。当进行next或者step等调试操作时候,GDB会自动显示display
所设置的变量或者地址的值信息。
display
命令格式:
1 | display <expr> |
其他相关命令:
注意: 当不带display号时候,是针对所有display进行设置。
我们可以通过display
命令,实现当程序停止时,查看将要执行的汇编指令:
1 | (gdb) display /i $pc |
取消显示可以用undisplay命令进行操作。
backtrace
命令用来查看栈帧信息。可以简写成bt
。
1 | (gdb) backtrace # 显示当前函数的栈帧以及局部变量信息 |
frame
命令可以切换栈帧信息:
1 | (gdb) frame n # 其中n是层数,最内层的函数帧为第0帧 |
其他相关命令:
GDB中有一组命令能够辅助多线程的调试:
info threads
显示当前可调式的所有线程,线程 ID 前有 “*” 表示当前被调试的线程。
thread threadid
切换线程到线程threadid
set scheduler-locking [on|off|step]
多线程环境下,会存在多个线程运行,这会影响调试某个线程的结果,这个命令可以设置调试的时候多个线程的运行情况,on 表示只有当前调试的线程会继续执行,off 表示不屏蔽任何线程,所有线程都可以执行,step 表示在单步执行时,只有当前线程会执行。
thread apply [threadid] [all] args
对线程列表执行命令。比如通过thread apply all bt full
可以查看所有线程的局部变量信息。
print
命令可以用来查看变量的值。print
命令可以简写成p
。print
命令格式如下:
1 | print [</format>] <expr> |
format用来设置显示变量的格式,可选的。可用值有如下:
expr可以是一个变量,也可以是表达式,也可以是寄存器:
1 | (gdb) p var1 # 打印变量var1 |
print
也支持查看连续内存,@
操作符用于查看连续内存,@
的左边是第一个内存的地址的值,@
的右边则想查看内存的长度。
例如对于如下代码:int arr[] = {2, 4, 6, 8, 10};
,可以通过如下命令查看arr前三个单元的数据:
1 | (gdb) p *arr@3 |
examine
命令用来查看内存地址中的值,可以简写成x
。examine
命令的语法如下所示:
examine /<n/f/u>
n 表示显示字段的长度,也就是说从当前地址向后显示几个地址的内容。
f 表示显示的格式
u 表示从当前地址往后请求的字节数,默认是4个bytes
示例:
1 | (gdb) x/10c 0x4005d4 # 打印前10个字符 |
set
命令支持修改变量以及寄存器的值:
1 | (gdb) set var var1=123 # 设置变量var1值为123 |
help
命令支持查看GDB命令帮助信息。
1 | (gdb) help status # 查看所有命令使用示例 |
search
命令支持在当前文件中使用正则表达式搜索内容。search
等效于forward-search
命令,是从当前位置向前搜索,可以简写成fo
。reverse-search
命令功能跟forward-search
恰好相反,其可以简写成rev
。
1 | (gdb) search func add # 从当前位置向前搜索add方法 |
我们可以通过shell
指令来执行shell命令。
1 | (gdb) shell cat /proc/27889/maps # 查看进程27889的内存映射。若想查看当前进程id,可以使用info proc命令获取 |
如果你对微服务感兴趣,那么你可能多次听说过这两个术语。人们常常把这两者混为一谈。在本文中,我将详细讨论服务网格和API网关,并讨论何时使用什么。
在深入研究服务网格和API网关之前,让我们先回顾复习一下网络层。下面是OSI的网络层模型:
之所以进行网络协议复习的原因是我们将在下一节中讨论中使用到这些OSI网络层。
服务网格(Service Mesh)是一种在分布式软件系统中管理服务对服务(service-to-service)通信的技术。服务网格管理东西向类型的网络通信(East-west traffic)。东西向流量表示数据中心、Kubernetes集群或分布式系统内部的流量。
服务网格由两个重要组件组成:
驻留在应用程序旁边的代理称为数据层,而协调代理行为的管理组件称为控制层。
服务网格允许你将应用程序的业务逻辑从网络、可靠性、安全性和可观察性中分离出来。
服务网格可以进行服务动态发现(service discovery)。边车代理(Sidecar proxy)可以支持负载均衡和限流,它也可以帮助你进行流量拆分,执行A/B测试,这对于金丝雀发布很有帮助。
服务网格支持分布式跟踪,这可以帮助你进行高级监控(比如请求数量、成功率和响应延迟)和调试。它甚至能够利用服务对服务的通信来更好地理解通信。
由于服务网格提供了健康检查,重试,超时和熔断功能,因此它可以提高应用程序的基线可靠性(baseline reliability)。
服务网格允许服务之间相互使用TLS进行通信,这有助于提高服务对服务通信的安全性。还可以实现acl(access-control list)作为安全策略。
一个真正的服务网格以及边车代理能够支持广泛的服务,并且能够实现对L4和L7层的流量控制。
市场上有许多可用的服务网格。以下是其中的一些:
API网关一般是群集,数据中心或一组分布式服务的单个入口点。在网络拓扑中,通常被称为南北向流量。移动客户端属于这种类型的网络流量。
API网关充当进入集群、数据中心或一组分布式服务的单一入口点。在网络拓扑结构中,它通常被称为南北通信。通常,移动客户端属于这种类型的网络流量。
人们很有可能最终使用API网关在部署在同一数据中心的两个产品之间进行通信。在这种情况下,流量类型可以是东西向。
API网关接收来自客户端的调用请求,并将其路由到适当的服务。与此同时它也可以进行协议转换。
使用API网关有多种好处:
抽象化
API网关可以抽象出底层微服务的复杂性,并为客户端创建统一的体验
身份认证
API网关可以负责身份验证,并将令牌信息传递给服务
流量控制
API网关可以限制入站和出站流量
监控与赢利
如果你计划将API货币化,API网关可以通过提供监控客户端API请求/响应的功能来帮助你做到这一点
协议转换
API网关可以帮助你转换API请求/响应。它还可以进行协议转换。
API网关通常只关注L7策略。
API网关的类型有以下两种:
从部署的角度来看,API网关有两种使用方式:
内部API网关(Internal API gateway)
充当一组服务的网关或产品范围的网关
边缘API网关(Edge API gateway)
充当外部组织的消费者或移动客户端的网关
市场上有许多可用的API网关。以下是其中的一些:
既然你已经知道了什么是服务网格和API网关,那么让我们尝试理解什么时候使用什么。
服务网格和API网关很有可能共存。下图展示了服务网格和API网关共存的场景:
通过上面图表我们可以知道,在一个产品范围内,你可以实现一个服务网格(东西流量east-west traffic)。当需要跨产品通信时,可以使用内部API网关(东西流量east-west traffic)。当处于边缘的客户端需要与服务通信时,可以使用边缘API网关(南北流量east-west traffic)。
sync/map
提供了并发读写map功能。这里面分析的源码基于go1.14.13版本。1 | type Map struct { |
sync.Map结构体中read字段是atomic.Value
类型,底层是readOnly结构体:
1 | type readOnly struct { |
read map和dirty map的value类型是*entry, entry结构体定义如下:
1 | // expunged用来标记从dirty map删除掉了 |
对Map的操作可以分为四类:
我们来看看新增和更新操作:
1 | // Store用来新增和更新操作 |
接下来看看Map的Get操作:
1 | // Load方法用来获取key对应的value值,返回的ok表名key是否存在sync.Map中 |
在接着看看Map的删除操作:
1 | // Delete用于删除key |
sync.Map还提供遍历key-value功能:
1 | // Range方法接受一个迭代回调函数,用来处理遍历的key和value |
当Tcp连接建立时,会随机生成一个32位的ISN号(Initial Sequence Number)对应报文段第一个字节数据,以后每传输一个字节该ISN都会加1。Wireshark默认显示报文的序号和确认序号都是相对值。如果我们希望显示绝对值序号,可以按照以下步骤操作。
Wireshark菜单栏 => Edit => Preferences => protocols => TCP
然后去掉Relative sequence number
的选中状态。
Wireshark默认不会解析Http2协议的,我们需要手动指定端口按照Http2协议解析。
我们首选需要启动Http2协议,操作位置如下:
Wireshark菜单栏 => 分析 => 启动的协议
搜索Http2,找到之后将http2_tcp和http2_tls都勾选中
接下来设置端口与协议映射:
Wireshark菜单栏 => 分析 => 解码为
将Http2协议的端口映射为http2协议解码。下图显示的是将8080端口映射为http2协议解码:
gRPC协议同理操作。
Wireshark主界面由5部分组成。
对于常用的过滤的条件,可以保存到过滤器栏最右侧,方便下次使用:
有时候我们希望能够使用wireshark分析远程服务器上面的封包信息。尽管tcpdump抓包命令能够抓取分析服务器上面的信息,但是其分析起来不够直观。解决这个问题有两个办法,第一个办法是使用tcpdump命令的-w
选项将抓包的原始信息保存到文件中,然后将文件拉取下来使用wireshark分析。另一个办法是使用ssh连接,实时的将tcpdump抓包数据传递给wireshark进行分析。
1 | // window系统,注意需要切换Wireshark.exe所在目录 |
注意:
window系统里面没有ssh命令的,可以通过安装Cygwin来安装ssh等相关命令。
tcpdump命令的-U
选项需指定,否则tcpdump抓取的包数据并不会实时的显示到wireshark里面。
Protocol Buffers简称Protobuf,是google公司推出的一种数据描述语言。Protocol buffers具有平台无关、语言无关、二进制格式编码、编码后体积小,序列化和反序列化快、类型安全、向后兼容等特点。
Protocol buffers有专门的语法结构来定义数据结构。消息和RPC服务接口是Protocol buffers中两大基本组成。消息类似一个Json object,RPC服务接口定义了服务所具有的接口和所依赖的消息类型。
Protocol buffers定义的数据结构应该保存在.proto后缀名的文件中。目前最新版本的语法协议是proto3。
message(消息)是protobuf中最基本数据单元。protobuf中使用message关键字来定义消息。假设想要定义一个搜索请求消息格式,其中包含搜索的查询字符串,分页参数。下面是用于定义消息类型的.proto文件内容:
1 | syntax = "proto3"; |
.proto
文件的第一行使用syntax = "proto3"
表明使用proto3
语法。
上面代码中定义了一个名字为SearchRequest
的消息,它包含了四个字段,每个字段都有名字(Field Name),类型(Field Type),唯一编号(Field Number),**字段规则(Field Rule) **。其中字段规则不是必须。
消息中定义的每个字段都必须有唯一的编号。字段编号是反序列化时候重要依据。当编号范围在1到15之间时候只需要一个字节进行编码,当范围16到2047的字段时候占用两个字节。所以应该为频繁出现的消息元素保留1到15的字段编号。
字段编号不一定从1开始。最小的字段编号是1,最大可到2^29。其中19000到19999位proto保留编号,不可以使用。
proto3
语法与proto2
语法不同之处,其中一项就是去掉了proto2中required
,optional
规则,只保留了repeated
规则,并且对于由于repeated
规则的标量类型的字段默认采用了packed
编码,而proto2中需要额外指定选项才能采用packed
编码。
proto3消息中定义的字段需要满足以下规则之一:
singular
proto3的默认规则,字段前面不需要加任何关键字。表明该字段可以出现0次或者1次。相当于proto2中的optional规则
repeated
消息中该字段可以重复出现0次或多次
当反序列化消息时,如果消息中不包含特定的字段时候,则解析对象中的对应字段将被设置为该字段的默认值。默认值规则如下:
repeated
规则的字段默认值是空Protocol Buffer中字段的类型既可以是标量类型( scalar type),也可以是复合类型(composite type)。
我们可以通过enum
关键字定义枚举类型。
1 | message SearchRequest { |
上面结构体中Corpus是一个枚举类型,它的值可以是UNIVERSAL
,WEB
,IMAGES
,LOCAL
,NEWS
,PRODUCTS
,VIDEO
。
注意:
我们可以使用其他消息类型作为某个字段的类型:
1 | message SearchResponse { |
上面proto定义中可以看出来,在SearchResponse
消息中,我们使用Result
类型来定义字段result的类型。
我们可以在一个消息类型中,嵌套其他类型的消息。比如下面的SearchResponse
消息中嵌套了Result
类型
1 | message SearchResponse { |
我们一个通过_Parent_._Type_
语法来复用父级消息的类型。SomeOtherMessage
消息中的result字段的类型SearchResponse
中的Result
类型
1 | message SomeOtherMessage { |
通过任意类型,可以将消息作为嵌入类型使用,任意类型的字段以字节的形式进行序列化。使用任意类型,需要导入google/protobuf/any.proto
类型
1 | import "google/protobuf/any.proto"; |
当一个消息中包含多个字段,并且最多同时设置一个字段。我们就可以使用Oneof类型节省内存。
1 | message SampleMessage { |
Oneof字段特性:
map
类型字段和repeated
规则字段外,Oneof字段支持其他任意类型我们可以通过下面语法声明map类型字段:
1 | map<key_type, value_type> map_field = N; |
其中key_type可以除了float
和bytes
之外的任意标量类型。value_type可以是除了map类型的任意类型。map类型字段不能是repeated
规则。
未知字段指的是反序列化时候,无法识别的字段。当旧代码解析带有新字段的消息生成的序列化数据时候,该字段对就代码来说就是未知字段。对于未知字段处理规则:
当需要更新消息格式时候,比如增加一个额外的字段,为了不影响已有功能。需要注意以下几个规则:
int32
, uint32
, int64
, uint64
以及bool
类型都是兼容的。从其中一种类型更改为另一种类型,不会破坏向前或向后兼容性,但要注意截断问题(比如:int64向int32转换时候,会被截断)sint32
和sint64
彼此兼容,但与其他整数类型不兼容bytes
类型包含一个消息体,嵌套类型的消息类型是与其兼容的fixed32
和sfixed32
, fixed64
, sfixed64
是兼容的string
, bytes
以及消息类型字段,optional
和repeated
规则是兼容的enum
类型与int32
, uint32
, int64
, uint64
是兼容的。同规则4一样,需注意截断问题oneof
成员是安全的和二进制兼容的。如果确信没有代码会一次设置多个字段,那么将多个字段移到一个新的字段中可能是安全的。将任何字段移动到现有字段中是不安全的通过在.proto
文件中定义RPC服务接口,接着我们就可以使用protocol buffer编译器生成特定语言的服务接口代码和stub。
比如定义一个RPC服务,该服务具有Search接口,该接口接收SearchRequest参数并返回一个SearchResponse,你可以在你的.proto文件中定义它如下:
1 | service SearchService { |