2024年9月

论文阅读翻译之Deep reinforcement learning from human preferences

关于

  • 首次发表日期:2024-09-11
  • 论文原文链接:
    https://arxiv.org/abs/1706.03741
  • 论文arxiv首次提交日期:12 Jun 2017
  • 使用KIMI,豆包和ChatGPT等机翻,然后人工润色
  • 如有错误,请不吝指出

Deep reinforcement learning from human preferences(基于人类偏好的深度强化学习)

Abstract (摘要)

对于复杂的强化学习(RL)系统来说,要与现实世界环境有效互动,我们需要向这些系统传达复杂目标。在这项工作中,我们探索了以(非专家)人类对轨迹段对的偏好来定义目标。我们展示了这种方法可以在没有奖励函数的情况下有效解决复杂的RL任务,包括Atari游戏和模拟机器人运动,同时仅需对不到1%的代理与环境交互提供反馈。这大大降低了人类监督的成本,使其能够实际应用于最先进的强化学习系统。为了展示我们方法的灵活性,我们表明可以在大约一小时的人类参与时间内成功训练出复杂的新行为。这些行为和环境比以往任何从人类反馈中学到的都要复杂得多。

1 Introduction (引言)

最近在将强化学习 (RL) 扩展到大规模问题上取得的成功,主要得益于那些具有明确奖励函数的领域(Mnih等, 2015, 2016; Silver等, 2016)。不幸的是,许多任务的目标是复杂的、定义不清的或难以明确说明的。克服这一限制将大大扩展深度强化学习的潜在影响,并可能进一步扩大机器学习的应用范围。

例如,假设我们想使用强化学习训练一个机器人来清洁桌子或炒鸡蛋。如何构建一个合适的奖励函数并不明确,而这个奖励函数需要依赖机器人的传感器数据。我们可以尝试设计一个简单的奖励函数,大致捕捉预期的行为(intended behavior),但这通常会导致机器人行为优化我们的奖励函数,但机器人行为并实际上却不符合我们的偏好。这种困难是构成近期关于我们价值观(values)与强化学习系统目标不一致的基础(Bostrom, 2014; Russell, 2016; Amodei等, 2016)。如果我们能够成功地向智能体(agent)传达我们的实际目标,将是解决这些问题的关键一步。

如果我们拥有所需任务的示范,就可以通过逆向强化学习 (Ng 和 Russell, 2000) 提取一个奖励函数,然后使用该奖励函数来训练通过强化学习训练一个智能体。更直接的方式是使用模仿学习(imitation learning)来复制示范的行为。然而,这些方法并不适用于人类难以演示的行为(例如控制一个具有多自由度且形态与人类差异很大的机器人)。

另一种方法是允许人类对系统当前的行为提供反馈,并利用这些反馈来定义任务。原则上,这符合强化学习的范式,但直接将人类反馈作为奖励函数对于需要数百或数千小时经验的强化学习系统来说成本过高。为了能够在实际上基于人类反馈训练深度强化学习系统,我们需要将所需反馈的量减少几个数量级。

我们的方法是从人类反馈中学习奖励函数,然后优化这个奖励函数。这种基本方法之前已经被考虑过,但我们面对的是如何将其扩展到现代深度强化学习中的挑战,并展示了迄今为止从人类反馈中学到的最复杂的行为。

总之,我们希望找到一个解决方案来处理没有明确指定奖励函数的顺序决策问题,这个解决方案应该满足以下条件:

  1. 能够解决我们只能
    识别
    期望行为但不一定能够示范的任务,
  2. 允许非专家用户教导智能体,
  3. 能够扩展到大型问题,且
  4. 在用户反馈方面经济高效。

我们的算法在训练策略优化当前预测的奖励函数的同时,根据人类的偏好拟合一个奖励函数(见图1)。我们要求人类比较智能体行为的短视频片段,而不是提供绝对的数值评分。我们发现,在某些领域,进行比较对人类来说更容易,同时在学习人类偏好时同样有效。比较短视频片段的速度几乎与比较单个状态一样快,但我们证明,这种比较方式显著更有帮助。此外,我们还表明,在线收集反馈能够提高系统性能,并防止它利用所学奖励函数的漏洞。

我们的实验在两个领域进行:Arcade Learning Environment(Bellemare等, 2013)中的Atari游戏,以及物理模拟器MuJoCo(Todorov等, 2012)中的机器人任务。我们展示了即使是非专家人类提供的少量反馈,从十五分钟到五小时不等,也足以学习大多数原始的强化学习任务,即使奖励函数不可观察。随后我们在每个领域中考虑了一些新行为,例如完成后空翻或按照交通流向驾驶。我们证明了我们的算法能够通过大约一小时的反馈学习这些行为——即使很难通过手工设计奖励函数来激励这些行为。

大量研究探索了基于人类评分或排序的强化学习,包括 Akrour 等 (2011)、Pilarski 等 (2011)、Akrour 等 (2012)、Wilson 等 (2012)、Sugiyama 等 (2012)、Wirth 和 Fürnkranz (2013)、Daniel 等 (2015)、El Asri 等 (2016)、Wang 等 (2016) 和 Wirth 等 (2016)。另一些研究则关注从偏好而非绝对奖励值出发的强化学习问题 (Fürnkranz 等, 2012; Akrour 等, 2014),以及在非强化学习环境中通过人类偏好进行优化的研究 (Machwe 和 Parmee, 2006; Secretan 等, 2008; Brochu 等, 2010; Sørensen 等, 2016)。

我们的算法遵循与Akrour等人(2012)和Akrour等人(2014)相同的基本方法。他们研究了四个自由度的连续域和小的离散域,在这些域中,他们可以假设奖励在手编码特征的期望中是线性的。我们则研究具有几十个自由度的物理任务和没有手工设计特征的 Atari 任务;我们环境的复杂性迫使我们使用不同的强化学习算法和奖励模型,并应对不同的算法权衡。一个显著的区别在于,Akrour等人(2012)和Akrour等人(2014)是从整个轨迹中获取偏好,而不是短片段。因此,虽然我们收集了多两个数量级的比较,但我们的实验所需的人类时间少于一个数量级。其他区别主要在于调整我们的训练程序,以应对非线性奖励模型和现代深度强化学习,例如使用异步训练和集成方法。

我们对反馈引导的方法与 Wilson 等人 (2012) 的研究非常接近。然而,Wilson 等人 (2012) 假设奖励函数是到某个未知“目标”策略的距离(该策略本身是手工编码特征的线性函数)。他们通过贝叶斯推理拟合这个奖励函数,而不是执行强化学习,他们根据目标策略的最大后验估计 (MAP) 生成轨迹。他们的实验涉及的是从其贝叶斯模型中抽取的“合成”人类反馈,而我们进行了从非专家用户收集反馈的实验。目前尚不清楚 Wilson 等人 (2012) 的方法是否可以扩展到复杂任务,或是否能够处理真实的人类反馈。

MacGlashan 等 (2017)、Pilarski 等 (2011)、Knox 和 Stone (2009)、以及 Knox (2012) 进行了一些涉及基于真实人类反馈的强化学习实验,尽管他们的算法方法并不十分相似。在 MacGlashan 等 (2017) 和 Pilarski 等 (2011) 的研究中,学习仅在人工训练者提供反馈的回合(episodes)中进行。这在像 Atari 游戏这样的领域似乎是不可行的,因为学习高质量策略需要数千小时的经验,即使对于我们考虑的最简单任务,这种方法的成本也过于昂贵。TAMER(Knox, 2012; Knox 和 Stone, 2013)也学习奖励函数,但他们考虑的是更简单的设置(settings),在这些设置中,期望的策略可以相对快速地学习。

我们的工作也可以看作是合作逆向强化学习框架( cooperative inverse reinforcement learning framework)(Hadfield-Menell 等, 2016)的一个特定实例。这个框架考虑了一个人类和机器人在环境中互动的两人游戏,目的是最大化人类的奖励函数。在我们的设置中,人类只能通过表达他们的偏好来与这个游戏进行互动。

与之前的所有工作相比,我们的关键贡献是将人类反馈扩展到深度强化学习,并学习更复杂的行为。这符合将奖励学习方法扩展到大型深度学习系统的最新趋势,例如逆强化学习(Finn等人,2016年)、模仿学习(Ho和Ermon,2016年;Stadie等人,2017年)、半监督技能泛化(Finn等人,2017年)以及从示范中引导强化学习(Silver等人,2016年;Hester等人,2017年)。

2 Preliminaries and Method(预备知识与方法)

2.1 Setting and Goal(配置与目标)

我们考虑一个智能体在一系列步骤中与环境进行交互;在每个时刻
\(t\)
,智能体从环境中接收观察
\(o_t \in \mathcal{O}\)
,然后向环境发送动作
\(a_t \in \mathcal{A}\)

在传统的强化学习中,环境还会提供奖励
\(r_t \in \mathbb{R}\)
,智能体的目标是最大化奖励的折扣和(discounted sum of rewards)。与假设环境生成奖励信号不同,我们假设有一位人类监督者可以在
轨迹片段
(trajectory segments)之间表达偏好。轨迹片段是观察和动作的序列,
\(\sigma=\left(\left(o_0, a_0\right),\left(o_1, a_1\right), \ldots,\left(o_{k-1}, a_{k-1}\right)\right) \in(\mathcal{O} \times \mathcal{A})^k\)
。我们用
\(\sigma^1 \succ \sigma^2\)
表示人类更偏好轨迹片段
\(\sigma^1\)
而非轨迹片段
\(\sigma^2\)
。非正式地说,智能体的目标是生成人类偏好的轨迹,同时尽量减少向人类询问的次数。

更确切地说,我们将通过两种方式评估我们算法的行为:

定量:
我们说偏好
\(\succ\)
是由一个奖励函数
[1]
\(r: \mathcal{O} \times \mathcal{A} \rightarrow \mathbb{R}\)
生成的,如果

\[\left(\left(o_0^1, a_0^1\right), \ldots,\left(o_{k-1}^1, a_{k-1}^1\right)\right) \succ\left(\left(o_0^2, a_0^2\right), \ldots,\left(o_{k-1}^2, a_{k-1}^2\right)\right)
\]

每当

\[r\left(o_0^1, a_0^1\right)+\cdots+r\left(o_{k-1}^1, a_{k-1}^1\right)>r\left(o_0^2, a_0^2\right)+\cdots+r\left(o_{k-1}^2, a_{k-1}^2\right)
\]

如果人类的偏好是由奖励函数
\(r\)
生成的,那么我们的智能体应当根据
\(r\)
获得高的总奖励。因此,如果我们知道奖励函数
\(r\)
,我们就能对代理进行量化评估。理想情况下,代理应达到的奖励几乎与其使用强化学习来优化
\(r\)
时一样高。

定性
:有时我们没有奖励函数来对行为进行定量评估(这正是我们的方法在实际中有用的情况)。在这些情况下,我们只能定性地评估智能体满足人类偏好的程度。在本文中,我们将从一个用自然语言表达的目标开始,要求人类根据智能体实现该目标的情况来评估智能体的行为,然后展示智能体尝试实现该目标的视频。

我们的基于轨迹片段比较的模型与 Wilson 等人 (2012) 中使用的轨迹偏好查询非常相似,不同之处在于我们不假设可以将系统重置为任意状态
[2]
,并且我们的片段通常从不同的状态开始。这使得人类比较的解释(interpretation of human comparisons)变得更加复杂,但我们展示了即使人类评分者对我们的算法不了解,我们的算法也能够克服这一难题。

2.2 Our Method(我们的方法)

在每个时刻,我们的方法维持一个策略
\(\pi: \mathcal{O} \rightarrow \mathcal{A}\)
和一个奖励函数估计
\(\hat{r}: \mathcal{O} \times \mathcal{A} \rightarrow \mathbb{R}\)
,它们均由深度神经网络参数化。

这些网络通过三个过程进行更新:

  1. 策略
    \(\pi\)
    与环境交互,生成一组轨迹
    \(\left\{\tau^1, \ldots, \tau^i\right\}\)
    。使用传统的强化学习算法更新
    \(\pi\)
    的参数,以最大化预测奖励的总和
    \(r_t=\hat{r}\left(o_t, a_t\right)\)
  2. 从步骤1生成的轨迹
    \(\left\{\tau^1, \ldots, \tau^i\right\}\)
    中选择片段对
    \(\left(\sigma^1, \sigma^2\right)\)
    ,并将它们发送给人类进行比较。
  3. 通过监督学习优化映射
    \(\hat{r}\)
    的参数,以拟合迄今为止从人类收集的比较结果。

2.2.1 Optimizing the Policy (对策略进行优化)

在使用
\(\hat{r}\)
计算奖励后,我们面临的是一个传统的强化学习问题。我们可以使用任何适合该领域的强化学习算法来解决这个问题。一个细微之处在于,奖励函数
\(\hat{r}\)
可能是非平稳的(non-stationary),这使我们倾向于选择对奖励函数变化具有鲁棒性的算法。这导致我们专注于策略梯度方法(policy gradient methods),这些方法已经成功应用于这类问题(Ho 和 Ermon, 2016)。

在本文中,我们使用
优势演员-评论员(advantage actor-critic)
(A2C;Mnih 等, 2016)来玩 Atari 游戏,并使用
信赖域策略优化(trust region policy optimization)
(TRPO;Schulman 等, 2015)来执行模拟机器人任务。在每种情况下,我们都使用了被发现对传统强化学习任务有效的参数设置。我们唯一调整的超参数是 TRPO 的熵奖励(entropy bonus),因为 TRPO 依赖信赖域来确保足够的探索,如果奖励函数不断变化,这可能导致探索不足。

我们将
\(\hat{r}\)
生成的奖励归一化(normalized)为均值为零、标准差恒定。这是一个典型的预处理步骤,尤其适合于我们的学习问题,因为奖励的位置(position of the rewards)在我们的学习过程中是未定的。

2.2.2 Preference Elicitation(偏好获取)

人类监督者会看到两个可视化的轨迹片段,以短视频片段的形式呈现。在我们所有的实验中,这些视频片段的时长在 1 到 2 秒之间。

然后,人类指示他们更喜欢哪个片段,或者表示两个片段同样优秀,或者表示他们无法比较这两个片段。

人类的判断记录在数据库
\(\mathcal{D}\)
中,形式为三元组
\(\left(\sigma^1, \sigma^2, \mu\right)\)
,其中
\(\sigma^1\)

\(\sigma^2\)
是两个片段,
\(\mu\)
是一个在
\(\{1,2\}\)
上的分布,表示用户更喜欢哪个片段。如果人类选择一个片段为更优,则
\(\mu\)
将所有权重放在该选择上。如果人类标记这两个片段为同样可取,则
\(\mu\)
是均匀分布。最后,如果人类标记这两个片段不可比较,则该比较将不包含在数据库中。

2.2.3 Fitting the Reward Function (拟合奖励函数)

我们可以将奖励函数估计
\(\hat{r}\)
视为一个偏好预测器,如果我们将
\(\hat{r}\)
看作解释人类判断的潜在因素,并假设人类选择偏好片段
\(\sigma^i\)
的概率呈指数地取决于在片段长度上潜在奖励的合计值:
[3]

\[\hat{P}\left[\sigma^1 \succ \sigma^2\right]=\frac{\exp \sum \hat{r}\left(o_t^1, a_t^1\right)}{\exp \sum \hat{r}\left(o_t^1, a_t^1\right)+\exp \sum \hat{r}\left(o_t^2, a_t^2\right)}
\tag{1}
\]

我们选择
\(\hat{r}\)
以最小化这些预测与实际人类标签之间的交叉熵损失:

\[\operatorname{loss}(\hat{r})=-\sum_{\left(\sigma^1, \sigma^2, \mu\right) \in \mathcal{D}} \mu(1) \log \hat{P}\left[\sigma^1 \succ \sigma^2\right]+\mu(2) \log \hat{P}\left[\sigma^2 \succ \sigma^1\right]
\]

这遵循了从成对偏好估计评分函数Bradley-Terry模型(Bradley和Terry,1952),并且是Luce-Shephard选择规则(Luce,2005;Shepard,1957)在轨迹片段上的偏好的特化。它可以理解为将奖励等同于一个偏好排序尺度(preference ranking scale),类似于为国际象棋开发的著名的 Elo 排名系统(Elo,1978)。就像两个国际象棋棋手的 Elo 分数之差估计了一个棋手在一盘国际象棋比赛中击败另一个棋手的概率一样,两个轨迹片段的预测奖励之差估计了人类选择一个而不是另一个的概率。

我们实际的算法对这个基本方法进行了一些修改,早期实验发现这些修改很有帮助,并在第3.3节中进行了分析:

  • 我们拟合一个预测器的集合(ensemble),每个预测器都是在从
    \(\mathcal{D}\)
    中抽样的
    \(|\mathcal{D}|\)
    个三元组上训练的(允许重复抽样)。估计值
    \(\hat{r}\)
    通过独立地对每个预测器进行归一化,然后对结果取平均来定义。
  • 数据中有
    \(1/e\)
    的部分被保留,作为每个预测器的验证集。我们使用
    \(\ell_2\)
    正则化,并调整正则化系数,以保持验证损失在训练损失的1.1到1.5倍之间。在某些领域,我们还应用 dropout 进行正则化。
  • 我们不是像公式 1 中描述的那样直接应用 softmax,而是假设人类有 10%的概率随机均匀地(uniformly)做出响应。概念上,这种调整是必要的,因为人类评估者有一个固定的犯错误概率,这个概率不会随着奖励差异变得极端而衰减至0。

2.2.4 Selecting Queries (选择查询)

我们根据奖励函数估计器的不确定性近似来决定如何查询偏好,这类似于Daniel等人(2014)的方法:我们采样大量的长度为
\(k\)
的轨迹片段对,使用我们集合中的每个奖励预测器来预测每一对中哪个片段会被偏好,然后选择那些在集合成员之间预测方差最高的轨迹。这是一种粗糙的近似,第三节中的消融实验表明,在某些任务中它实际上损害了性能。理想情况下,我们希望基于查询的信息价值来查询(Akrour等人, 2012; Krueger等人, 2016),但我们留待未来的工作进一步探索这一方向。


  1. 在这里,我们假设奖励是观察和动作的函数。而在我们的 Atari 环境实验中,我们假设奖励是前四次观察的函数。在一般的部分可观测环境中,我们可以考虑依赖于整个观察序列的奖励函数,并使用递归神经网络来建模此奖励函数。
    ↩︎

  2. Wilson 等人 (2012) 还假设可以采样合理的初始状态。然而,我们处理的是高维状态空间,在这种情况下随机状态可能无法达到,而预期的策略(intended policy)位于一个低维流形上。
    ↩︎

  3. 公式1没有使用折扣因(discounting),这可以被解释为建模人类对于轨迹片段中事件发生的时间是无所谓的。使用显式的折扣因子或推断人类的折扣函数也是合理的选择。
    ↩︎

前言

在前面两篇实战文章中:

覆盖了可观测中的指标追踪和
metrics
监控,下面理应开始第三部分:
日志

但在开始日志之前还是要先将链路追踪和日志结合起来看看应用实际使用的实践。

通常我们排查问题的方式是先查询异常日志,判断是否是当前系统的问题。

如果不是,则在日志中捞出
trace_id
再到链路查询系统中查询链路,看看具体是哪个系统的问题,然后再做具体的排查。

类似于这样:

日志中会打印
trace_id

span_id

如果日志系统做的比较完善的话,还可以直接点击
trace_id
跳转到链路系统里直接查询链路信息。

MDC

这里的日志里关联 trace 信息的做法有个专有名词:MDC:(Mapped Diagnostic Context)。

简单来说就是用于排查问题的上下文信息,通常是由键值对组成,类似于这样的数据:

{  
  "timestamp" : "2024-08-05 17:27:31.097",  
  "level" : "INFO",  
  "thread" : "http-nio-9191-exec-1",  
  "mdc" : {  
    "trace_id" : "26242f945af80b044a60226af00211fb",  
    "trace_flags" : "01",  
    "span_id" : "3a7842b3e28ed5c8"  
  },  
  "logger" : "com.example.demo.DemoApplication",  
  "message" : "request: name: \"1232\"\n",  
  "context" : "default"  
}

在 Java 中的 Log4j 和 Logback 都有提供对应的实现。

如果我们使用了 OpenTelemetry 提供的
javaagent
再配合
logback
或者
Log4j
时就会自动具备打印
MDC
的能力:

java -javaagent:/Users/chenjie/Downloads/blog-img/demo/opentelemetry-javaagent-2.4.0-SNAPSHOT.jar xx.jar

比如我们只需要这样配置这样一个JSON 输出的 logback 即可:

<appender name="PROJECT_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">  
    <file>${PATH}/demo.log</file>  
  
    <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">  
        <fileNamePattern>${PATH}/demo_%i.log</fileNamePattern>  
        <maxIndex>1</maxIndex>  
    </rollingPolicy>  
  
    <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">  
        <maxFileSize>100MB</maxFileSize>  
    </triggeringPolicy>  
  
    <layout class="ch.qos.logback.contrib.json.classic.JsonLayout">  
        <jsonFormatter  
                class="ch.qos.logback.contrib.jackson.JacksonJsonFormatter">  
            <prettyPrint>true</prettyPrint>  
        </jsonFormatter>  
        <timestampFormat>yyyy-MM-dd' 'HH:mm:ss.SSS</timestampFormat>  
    </layout>  
  
</appender>  
  
<root level="INFO">  
    <appender-ref ref="STDOUT"/>  
    <appender-ref ref="PROJECT_LOG"/>  
</root>

就会在日志文件中输出
JSON
格式的日志,并且带上
MDC
的信息。

自动 MDC 的原理

我也比较好奇 OpenTelemetry 是如何自动写入 MDC 信息的,这里以
logback
为例。

@Override  
public ElementMatcher<TypeDescription> typeMatcher() {  
  return implementsInterface(named("ch.qos.logback.classic.spi.ILoggingEvent"));  
}  
  
@Override  
public void transform(TypeTransformer transformer) {  
  transformer.applyAdviceToMethod(  
      isMethod()  
          .and(isPublic())  
          .and(namedOneOf("getMDCPropertyMap", "getMdc"))  
          .and(takesArguments(0)),  
      LoggingEventInstrumentation.class.getName() + "$GetMdcAdvice");  
}

会在调用
ch.qos.logback.classic.spi.ILoggingEvent.getMDCPropertyMap()/getMdc()
这两个函数中进行埋点。

这些逻辑都是写在 javaagent 中的。

public Map<String, String> getMDCPropertyMap() {  
    // populate mdcPropertyMap if null  
    if (mdcPropertyMap == null) {  
        MDCAdapter mdc = MDC.getMDCAdapter();  
        if (mdc instanceof LogbackMDCAdapter)  
            mdcPropertyMap = ((LogbackMDCAdapter) mdc).getPropertyMap();  
        else  
            mdcPropertyMap = mdc.getCopyOfContextMap();  
    }    
    // mdcPropertyMap still null, use emptyMap()  
    if (mdcPropertyMap == null)  
        mdcPropertyMap = Collections.emptyMap();  
  
    return mdcPropertyMap;  
}

这个函数其实默认情况下会返回一个 logback 内置 MDC 的 map 数据(这里的数据我们可以自定义配置)。

而这里要做的就是将 trace 的上下文信息写入这个 mdcPropertyMap 中。

以下是 OpenTelemetry agent 中的源码:

Map<String, String> spanContextData = new HashMap<>();  
  
SpanContext spanContext = Java8BytecodeBridge.spanFromContext(context).getSpanContext();  
  
if (spanContext.isValid()) {  
  spanContextData.put(traceIdKey(), spanContext.getTraceId());  
  spanContextData.put(spanIdKey(), spanContext.getSpanId());  
  spanContextData.put(traceFlagsKey(), spanContext.getTraceFlags().asHex());  
}  
spanContextData.putAll(ConfiguredResourceAttributesHolder.getResourceAttributes());  
  
if (LogbackSingletons.addBaggage()) {  
  Baggage baggage = Java8BytecodeBridge.baggageFromContext(context);  
  
  // using a lambda here does not play nicely with instrumentation bytecode process  
  // (Java 6 related errors are observed) so relying on for loop instead  for (Map.Entry<String, BaggageEntry> entry : baggage.asMap().entrySet()) {  
    spanContextData.put(  
        // prefix all baggage values to avoid clashes with existing context  
        "baggage." + entry.getKey(), entry.getValue().getValue());  
  }}  
  
if (contextData == null) {  
  contextData = spanContextData;  
} else {  
  contextData = new UnionMap<>(contextData, spanContextData);  
}

这就是核心的写入逻辑,从这个代码中也可以看出直接从上线文中获取的 span 的 context,而我们所需要的
trace_id/span_id
都是存放在 context 中的,只需要 get 出来然后写入进 map 中即可。

从源码里还得知,只要我们开启
-Dotel.instrumentation.logback-mdc.add-baggage=true
配置还可以将 baggage 中的数据也写入到 MDC 中。

而得易于 OpenTelemetry 中的 trace 是可以跨线程传输的,所以即便是我们在多线程里打印日志时 MDC 数据依然可以准确无误的传递。

MDC 的原理

public static final String MDC_ATTR_NAME = "mdc";


logback
的实现中是会调用刚才的
getMDCPropertyMap()
然后写入到一个 key 为
mdc

map
里,最终可以写入到文件或者控制台。

这样整个原理就可以串起来了。

自定义日志 数据

提到可以自定义 MDC 数据其实也是有使用场景的,比如我们的业务系统经常有类似的需求,需要在日志中打印一些常用业务数据:

  • userId、userName
  • 客户端 IP等信息时

此时我们就可以创建一个
Layout
类来继承
ch.qos.logback.contrib.json.classic.JsonLayout
:

public class CustomJsonLayout extends JsonLayout {
    public CustomJsonLayout() {
    }

    protected void addCustomDataToJsonMap(Map<String, Object> map, ILoggingEvent event) {
        map.put("user_name", context.getProperty("userName"));
        map.put("user_id", context.getProperty("userId"));
        map.put("trace_id", TraceContext.traceId());
    }
}


public class CustomJsonLayoutEncoder extends LayoutWrappingEncoder<ILoggingEvent> {  
    public CustomJsonLayoutEncoder() {  
    }  
    public void start() {  
        CustomJsonLayout jsonLayout = new CustomJsonLayout();  
        jsonLayout.setContext(this.context);  
        jsonLayout.setIncludeContextName(false);  
        jsonLayout.setAppendLineSeparator(true);  
        jsonLayout.setJsonFormatter(new JacksonJsonFormatter());  
        jsonLayout.start();  
        super.setCharset(StandardCharsets.UTF_8);  
        super.setLayout(jsonLayout);  
        super.start();  
    }}

这里的 trace_id 是之前使用 skywalking 的时候由 skywalking 提供的函数:org.apache.skywalking.apm.toolkit.trace.TraceContext#traceId

接着只需要在
logback.xml
中配置这个
CustomJsonLayoutEncoder
就可以按照我们自定义的数据输出日志了:

<appender name="PROJECT_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">  
    <file>${PATH}/app.log</file>  
  
    <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">  
        <fileNamePattern>${PATH}/app_%i.log</fileNamePattern>  
        <maxIndex>1</maxIndex>  
    </rollingPolicy>  
  
    <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">  
        <maxFileSize>100MB</maxFileSize>  
    </triggeringPolicy>  
  
    <encoder class="xx.CustomJsonLayoutEncoder"/>  
</appender>

<root level="INFO">  
    <appender-ref ref="STDOUT"/>  
    <appender-ref ref="PROJECT_LOG"/>  
</root>

虽然这个功能也可以使用日志切面来打印,但还是没有直接在日志中输出更加方便,它可以直接和我们的日志关联在一起,只是多加了这几个字段而已。

Spring Boot 使用

OpenTelemetry
有给 springboot 应用提供一个
spring-boot-starter
包,用于在不使用
javaagent
的情况下也可以自动埋点。

<dependencies>
  <dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-spring-boot-starter</artifactId>
    <version>OPENTELEMETRY_VERSION</version>
  </dependency>
</dependencies>

但在早
期的版本
中还不支持直接打印 MDC 日志:
image.png

最新的版本已经支持

即便已经支持默认输出 MDC 后,我们依然可以自定义的内容,比如我们想修改一下 key 的名称,由
trace_id
修改为
otel_trace_id
等。

<appender name="OTEL" class="io.opentelemetry.instrumentation.logback.mdc.v1_0.OpenTelemetryAppender">
  <traceIdKey>otel_trace_id</traceIdKey>
  <spanIdKey>otel_span_id</spanIdKey>
  <traceFlagsKey>otel_trace_flags</traceFlagsKey>
</appender>

还是和之前类似,修改下 logback.xml 即可。

image.png
他的实现逻辑其实和之前的 auto instrument 中的类似,只不过使用的 API 不同而已。

auto instrument 是直接拦截代码逻辑修改 map 的返回值,而
OpenTelemetryAppender
是继承了
ch.qos.logback.core.UnsynchronizedAppenderBase
接口,从而获得了重写
MDC
的能力,但本质上都是一样的,没有太大区别。

不过使用它的前提是我们需要引入以下一个依赖:

<dependencies>
  <dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-logback-mdc-1.0</artifactId>
    <version>OPENTELEMETRY_VERSION</version>
  </dependency>
</dependencies>

如果不想修改 logback.yaml ,对于
springboot
来说还有更简单的方案,我们只需要使用以下配置即可自定义 MDC 数据:

logging.pattern.level = trace_id=%mdc{trace_id} span_id=%mdc{span_id} trace_flags=%mdc{trace_flags} %5p

这里的 key 也可以自定义,只要占位符没有取错即可。

使用这个的前提是需要加载 javaagent,因为这里的数据是 javaagent 里写进去的。

总结

以上就是关于
MDC

OpenTelemetry
中的使用,从使用和源码逻辑上都分析了一遍,希望对
MDC

OpenTelemetry
的理解更加深刻一些。

关于 MDC 相关的概念与使用还是很有用的,是日常排查问题必不可少的一个工具。

【写在前面】

在很多工作中,我们需要计算数据或者文件的散列值,例如登录或下载文件。

而在 Qt 中,负责这项工作的类为
QCryptographicHash

关于
QCryptographicHash

QCryptographicHash 是 Qt 框架中提供的一个用于生成加密散列(哈希值)的类。该类可以将任意长度的输入(二进制或文本数据)转换成固定长度的输出(哈希值),这一过程是不可逆的。
QCryptographicHash
支持多种哈希算法,包括
MD4、MD5、SHA-1、SHA-224、SHA-256、SHA-384 和 SHA-512
等,这些算法在数据完整性校验、密码存储、数字签名等应用场景中非常有用。

主要特点:

  1. 支持多种哈希算法:
    QCryptographicHash
    提供了多种哈希算法的支持,允许开发者根据具体需求选择合适的算法。
  2. 简单易用的接口:
    QCryptographicHash
    提供了简单易用的接口来计算哈希值。开发者可以通过调用
    QCryptographicHash::hash()
    静态方法或创建
    QCryptographicHash
    对象并使用
    addData()

    result()
    方法来计算哈希值。
  3. 逐块计算:`QCryptographicHash 还可以逐块地计算哈希值,这对于处理大文件或流式数据非常有用。
  4. 可重复使用:
    QCryptographicHash
    对象可以多次使用。当计算完一个哈希值后,可以通过调用
    reset()
    方法重置对象,然后继续计算新的哈希值。

然鹅, 虽然
QCryptographicHash
很优秀,但它最大的问题在于其散列值的计算是同步的( 即阻塞 ),对小数据来说并没什么影响,但对大数据来说则意味明显卡顿。

因此,我将
QCryptographicHash
进行简单封装,扩展了实用性的同时并将计算改为异步,还增加了进度通知和结束通知。


【正文开始】

先来看看
AsyncHasher
的使用效果图:

image

AsyncHasher
的使用方法非常简单:

  1. 包含头文件:在使用
    AsyncHasher
    之前,需要包含相应的头文件
    #include "asynchasher.h"

  2. 通过
    setSource / setSourceText / setSourceData/ setSourceObject
    设置源目标。

  3. 通过
    void hashProgress(qint64 processed, qint64 total)
    来获取进度,
    void finished()
    通知计算结束。

  4. 通过
    QString hashValue() const
    获取最终结果。

例如 C++ 使用:

    AsyncHasher *hasher = new AsyncHasher;
    hasher->setSourceText("Test Text");
    QObject::connect(hasher, &AsyncHasher::finished, [hasher]{
        qDebug() << hasher->hashValue();
    });

并且我还做了 Qml 适配,使用方法:

    AsyncHasher {
        id: textHasher
        algorithm: AsyncHasher.Md5
        onStarted: {
            startTime = Date.now();
        }
        onFinished: {
            totalTime = Date.now() - startTime;
            console.log("HashValue:", hashValue, "time:", totalTime);
        }
        property real startTime: 0
        property real totalTime: 0
    }

完整头文件如下:

#ifndef ASYNCHASHER_H
#define ASYNCHASHER_H

#include <QCryptographicHash>
#include <QFuture>
#include <QObject>
#include <QUrl>

QT_FORWARD_DECLARE_CLASS(QNetworkAccessManager);

QT_FORWARD_DECLARE_CLASS(AsyncHasherPrivate);

class AsyncHasher : public QObject
{
    Q_OBJECT

    Q_PROPERTY(QCryptographicHash::Algorithm algorithm READ algorithm WRITE setAlgorithm NOTIFY algorithmChanged)
    Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged)
    Q_PROPERTY(QString hashValue READ hashValue NOTIFY hashValueChanged)
    Q_PROPERTY(int hashLength READ hashLength NOTIFY hashLengthChanged)
    Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged)
    Q_PROPERTY(QString sourceText READ sourceText WRITE setSourceText NOTIFY sourceTextChanged)
    Q_PROPERTY(QByteArray sourceData READ sourceData WRITE setSourceData NOTIFY sourceDataChanged)
    Q_PROPERTY(QObject* sourceObject READ sourceObject WRITE setSourceObject NOTIFY sourceObjectChanged)

public:
    Q_ENUMS(QCryptographicHash::Algorithm);

    explicit AsyncHasher(QObject *parent = nullptr);
    ~AsyncHasher();

    QNetworkAccessManager *networkManager() const;

    QCryptographicHash::Algorithm algorithm();
    void setAlgorithm(QCryptographicHash::Algorithm algorithm);

    bool asynchronous() const;
    void setAsynchronous(bool async);

    QString hashValue() const;

    int hashLength() const;

    QUrl source() const;
    void setSource(const QUrl &source);

    QString sourceText() const;
    void setSourceText(const QString &sourceText);

    QByteArray sourceData() const;
    void setSourceData(const QByteArray &sourceData);

    QObject *sourceObject() const;
    void setSourceObject(QObject *sourceObject);

    bool operator==(const AsyncHasher &hasher);
    bool operator!=(const AsyncHasher &hasher);

    QFuture<QByteArray> static hash(const QByteArray &data, QCryptographicHash::Algorithm algorithm);

signals:
    void algorithmChanged();
    void asynchronousChanged();
    void hashValueChanged();
    void hashLengthChanged();
    void sourceChanged();
    void sourceTextChanged();
    void sourceDataChanged();
    void sourceObjectChanged();
    void hashProgress(qint64 processed, qint64 total);
    void started();
    void finished();

private slots:
    void setHashValue(const QString &value);

private:
    Q_DECLARE_PRIVATE(AsyncHasher);
    QScopedPointer<AsyncHasherPrivate> d_ptr;
};

#endif // ASYNCHASHER_H


【结语】

最后:项目链接(多多star呀..⭐_⭐):

Github 地址:
https://github.com/mengps/QmlControls/tree/master/AsyncHasher

前言

Prism 一个开源的框架,专门用于开发可扩展、模块化和可测试的企业级 XAML 应用程序,适用于 WPF(Windows Presentation Foundation)和 Xamarin Forms 等平台。

Prism 基于 MVVM(Model-View-ViewModel)设计模式,提供一套丰富的工具和库,能够实现模块化、依赖注入、导航和事件聚合等功能。

本文将介绍 Prism 框架的基本概念、安装步骤以及使用。

什么是Prism?

Prism 是一个用于开发灵活、可维护的 WPF、Windows 10 UWP 和 Xamarin.Forms 应用程序的框架。它是由微软的模式与实践团队开发的,,构建模块化、可测试的应用程序。Prism 包含了几个核心组件,以支持应用程序的架构和设计模式:

1、
依赖注入(Dependency Injection)

Prism 提供了一个依赖注入容器,可以将应用程序的组件和服务进行解耦,从而提高代码的可测试性和可维护性。

2、
模块化(Modularity)

Prism 支持模块化设计,将应用程序分解成独立的模块,每个模块负责特定的功能。这样助于减少应用程序的复杂性,并能够使开发和维护更加容易。

3、
导航(Navigation)

Prism 提供了一个灵活的导航系统,可以定义视图之间的导航路径,并管理视图的生命周期。

4、
事件聚合器(Event Aggregator)

这是一个松散耦合的事件发布/订阅机制,应用程序的不同部分之间进行通信,而不需要直接引用对方。

5、
命令(Commands)

Prism 提供了一种简化的方式来处理用户界面中的命令,如按钮点击事件。

6、
数据绑定(Data Binding)

虽然 Prism 本身不提供数据绑定机制,但它与 WPF 和 Xamarin.Forms 的数据绑定框架紧密集成,可以轻松地将视图模型与视图进行绑定。

7、
视图模型(ViewModel)

Prism 鼓励使用视图模型模式,是一种将业务逻辑与用户界面分离的设计模式,有助于程序更加清晰和可维护。

安装 Prism

Prism 可通过NuGet方案包管理器进行安装,主要安装三个Prism.Core、Prism.Unity、Prism.Wpf。

首先创建一个新的 WPF、Xamarin Forms、Uno 或 WinUI 项目,然后打开 NuGet 包管理器,右键点击项目 -> 选择"管理 NuGet 包"。

1、安装 Prism 核心包

在NuGet包管理器中,搜索并安装
Prism.Core

2、安装容器包

在NuGet包管理器中,搜索
Prism.Unity

Prism.DryIoc
(根据你的需求选择),然后点击安装。

Unity是Prism官方推荐的容器之一,但DryIoc在某些情况下可能提供更高的性能。

3、安装平台包

  • WPF 安装
    Prism.Wpf

  • Xamarin Forms 安装
    Prism.Forms

  • Uno Platform 安装
    Prism.Uno

  • WinUI 安装
    Prism.WinUI

具体操作步骤,可以参考下图:

使用 Prism

通过一个手动敲代码示例实现 WPF MVVM框架 Prism 导航,具体可以参考以下代码。

1、新建WPF项目

首先新建一个
WPF
项目,根据上面图示完成Prism的安装,具体项目结构如下图所示:

1、框架使用 .NET 6.0、Visual Studio 2022;

2、新建
Views

ViewModels
文件夹

2、重写 App.xaml

添加命名空间
xmlns:prism="http://prismlibrary.com/"

记得删除
StartupUri="MainWindow.xaml

继承由Application->PrismApplication,代码如下所示:

<prism:PrismApplicationx:Class="ManageCore.WpfApp.App"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:local="clr-namespace:ManageCore.WpfApp"xmlns:prism="http://prismlibrary.com/">
    <Application.Resources>
    </Application.Resources>
</prism:PrismApplication>

3、修改 App.xaml.cs

继承由Application->PrismApplication, 代码如下所示:

  public partial classApp : PrismApplication
{
protected overrideWindow CreateShell()
{
return Container.Resolve<MainWindow>();
}
protected override voidRegisterTypes(IContainerRegistry containerRegistry)
{
containerRegistry.RegisterForNavigation
<Home, HomeViewModel>();
containerRegistry.RegisterForNavigation
<Edge, EdgeViewModel>();
}
protected override voidOnStartup(StartupEventArgs e)
{
base.OnStartup(e);
}
}

在这里实现了两个抽象方法:

CreateShell

该方法返回了一个Window类型的窗口, 其实就是返回应用程序的主窗口。

RegisterTypes

该方法用于在Prism初始化过程中, 我们定义自身需要的一些
注册
类型, 以便于在Prism中可以使用。

注意:

Views
文件夹下新建了两个 UserControl
Home、Edge
并在
RegisterTypes
进行注册。

ViewModels
文件夹下新建了两个VM
HomeViewModel、EdgeViewModel
进行注册。

4、修改 MainWindow.xaml

  • 添加命名空间
    xmlns:prism="http://prismlibrary.com/"

  • 设置
    prism:ViewModelLocator.AutoWireViewModel="True" Prism
    框架会根据规则自动查找该视图相对应ViewModel。

  • 使用了
    WPFDevelopers
    中的
    DrawerMenu
    进行切换菜单。

<wd:Windowx:Class="ManageCore.WpfApp.Views.MainWindow"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:d="http://schemas.microsoft.com/expression/blend/2008"xmlns:wd="https://github.com/WPFDevelopersOrg/WPFDevelopers"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"xmlns:local="clr-namespace:ManageCore.WpfApp"xmlns:vm="clr-namespace:ManageCore.WpfApp.ViewModels"xmlns:prism="http://prismlibrary.com/"xmlns:i="http://schemas.microsoft.com/xaml/behaviors"Title="Prism - 导航栏"Width="800"Height="450"prism:ViewModelLocator.AutoWireViewModel="True">
    <Grid>
       
    </Grid>
</wd:Window>

5、MainWindowViewModel

选中
ViewModels
文件右键创建
MainWindowViewModel
继承
BindableBase

  • 使用
    RegionManager
    上调用
    RequestNavigate
    方法,该方法允许您指定要导航的区域。

  • 使用
    RegionManager
    上的
    RegisterViewWithRegion
    加载
    View

  • 使用
    RegionManager
    上的
    RequestNavigate
    导航菜单。

MainWindow.xaml
通过
prism:ViewModelLocator.AutoWireViewModel="True"
属性自动绑定了
MainWindowViewModel

这样,当
MainWindow
被加载时,Prism会自动创建并关联
MainWindowViewModel
实例。

public classMainWindowViewModel : BindableBase
{
privateDrawerMenuItem _selectedItem;publicDrawerMenuItem SelectedItem
{
get { return_selectedItem; }set { SetProperty(ref_selectedItem, value); }
}
public DelegateCommand SelectionChangedCommand { get; }private readonlyIRegionManager _regionManager;
/// <summary> /// /// </summary> /// <param name="regionManager"></param> publicMainWindowViewModel(IRegionManager regionManager)
{

}
voidUpdateRegionViews()
{

}
}

示例中创建了一个简单的Prism应用程序,其中
App.xaml

App.xaml.cs
配置了Prism的启动和依赖注入。

4、启动程序

通过上面代码的编写,完成WPF框架应用,具体运行效果如下所示:

总结

Prism 是一个专为 WPF 应用程序设计的 MVVM 模式框架,它通过依赖注入和控制反转容器来促进团队协作中的松耦合设计。

凭借其强大的功能和灵活性,Prism 成为了开发企业级应用程序的首选框架。不仅简化了代码结构,提高了应用程序的可维护性和可扩展性。

希望这篇文章能帮助你了解Prism框架的基本概念、安装步骤以及如何使用。

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!

原创文章,欢迎转载,转载请注明出处,谢谢。


0. 前言

本系列将介绍 Go runtime 调度器。要学好 Go 语言,runtime 运行时是绕不过去的,它相当于一层“操作系统”对我们的程序做“各种类型”的处理。其中,调度器作为运行时的核心,是必须要了解的内容。本系列会结合 Go plan9 汇编,深入到 runtime 调度器的源码层面去看程序运行时,goroutine 协程创建等各种场景下 runtime 调度器是如何工作的。

本系列会运用到 Go plan9 汇编相关的知识,不熟悉的同学可先看看
这里
了解下。

1. Go 程序初始化

首先,从一个经典的
Hello World
程序入手,查看程序的启动,以及启动该程序时调度器做了什么。

package main

func main() {
	println("Hello World")
}

1.1 准备

程序启动经过编译和链接两个阶段,我们可以通过
go build -x hello.go
查看构建程序的过程:

# go build -x hello.go 
...
// compile 编译 hello.go
/usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath "$WORK/b001=>" -p main -complete -buildid uHBjeIlqt1oQO9TLC5SE/uHBjeIlqt1oQO9TLC5SE -goversion go1.21.0 -c=3 -nolocalimports -importcfg $WORK/b001/importcfg -pack ./hello.go
...
// link 链接库文件生成可执行文件
/usr/local/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=27kmwBgRtsWy6cL5ofDV/uHBjeIlqt1oQO9TLC5SE/Ye3W7EEwzML-FanTsWbe/27kmwBgRtsWy6cL5ofDV -extld=gcc $WORK/b001/_pkg_.a

这里省略了不相关的输出,经过编译,链接过程之后得到可执行文件
hello

# ls
go.mod  hello  hello.go
# ./hello 
Hello World

1.2 进入程序

上一节生成了可执行程序
hello
。接下来进入本文的主题,通过
dlv
进入
hello
程序,查看在执行 Go 程序时,运行时做了什么。

我们可以通过
readelf
查看可执行程序的入口:

# readelf -h ./hello
ELF Header:
  ...
  Entry point address:               0x455e40

省略了不相关的信息,重点看
Entry point address
,它是进入
hello
程序的入口点。通过
dlv
进入该入口点:

# dlv exec ./hello
Type 'help' for list of commands.
(dlv) b *0x455e40
Breakpoint 1 set at 0x455e40 for _rt0_amd64_linux() /usr/local/go/src/runtime/rt0_linux_amd64.s:8

可以看到入口点指向的是
/go/src/runtime/rt0_linux_amd64.s
中的
_rt0_amd64_linux()
函数。

接下来,进入该函数查看启动 Go 程序时,运行时做了什么。

// c 命令执行到入口点位置
(dlv) c
> _rt0_amd64_linux() /usr/local/go/src/runtime/rt0_linux_amd64.s:8 (hits total:1) (PC: 0x455e40)
Warning: debugging optimized function
     3: // license that can be found in the LICENSE file.
     4:
     5: #include "textflag.h"
     6:
     7: TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
=>   8:         JMP     _rt0_amd64(SB)      // 跳转到 _rt0_amd64

// si 单步执行指令
(dlv) si
> _rt0_amd64() /usr/local/go/src/runtime/asm_amd64.s:16 (PC: 0x454200)
Warning: debugging optimized function
TEXT _rt0_amd64(SB) /usr/local/go/src/runtime/asm_amd64.s
=>      asm_amd64.s:16  0x454200        488b3c24        mov rdi, qword ptr [rsp]
        asm_amd64.s:17  0x454204        488d742408      lea rsi, ptr [rsp+0x8]
        asm_amd64.s:18  0x454209        e912000000      jmp $runtime.rt0_go     // 这里跳转到 runtime 的 rt0_go

// 进入 rt0_go
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:161 (PC: 0x454220)
Warning: debugging optimized function
TEXT runtime.rt0_go(SB) /usr/local/go/src/runtime/asm_amd64.s
=>      asm_amd64.s:161 0x454220        4889f8          mov rax, rdi
        asm_amd64.s:162 0x454223        4889f3          mov rbx, rsi
        asm_amd64.s:163 0x454226        4883ec28        sub rsp, 0x28
        asm_amd64.s:164 0x45422a        4883e4f0        and rsp, -0x10
        asm_amd64.s:165 0x45422e        4889442418      mov qword ptr [rsp+0x18], rax
        asm_amd64.s:166 0x454233        48895c2420      mov qword ptr [rsp+0x20], rbx

rt0_go
是 runtime 执行 Go 程序的入口。

需要补充说明的是:我们使用的 si 显示的是 CPU 单步执行的指令,是 CPU 真正执行的指令。而 Go plan9 汇编是“优化”了的汇编指令,所以会发现 si 显示的输出和 asm_amd64.s 中定义的不一样。在实际分析的时候可以结合两者一起分析。

结合着
asm_amd64.s/rt0_go
分析 si 输出的 CPU 指令:

=>      asm_amd64.s:161 0x454220        4889f8          mov rax, rdi                    // 将 rdi 寄存器中的 argc 移到 rax 寄存器:rax = argc
        asm_amd64.s:162 0x454223        4889f3          mov rbx, rsi                    // 将 rsi 寄存器中的 argv 移到 rbx 寄存器:rbx = argv
        asm_amd64.s:163 0x454226        4883ec28        sub rsp, 0x28                   // 开辟栈空间
        asm_amd64.s:164 0x45422a        4883e4f0        and rsp, -0x10                  // 对齐栈空间为 16 字节的整数倍(因为 CPU 的一组 SSE 指令需要内存地址必须是 16 字节的倍数)
        asm_amd64.s:165 0x45422e        4889442418      mov qword ptr [rsp+0x18], rax   // 将 argc 移到栈空间 [rsp+0x18]
        asm_amd64.s:166 0x454233        48895c2420      mov qword ptr [rsp+0x20], rbx   // 将 argv 移到栈空间 [rsp+0x20]

画出栈空间如下图:

image

继续分析:

(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:170 (PC: 0x454238)
Warning: debugging optimized function
        asm_amd64.s:166 0x454233        48895c2420              mov qword ptr [rsp+0x20], rbx
=>      asm_amd64.s:170 0x454238        488d3d815b0700          lea rdi, ptr [runtime.g0]           // 将 runtime.g0 的地址移到 rdi 寄存器中,rdi = &g0
        asm_amd64.s:171 0x45423f        488d9c240000ffff        lea rbx, ptr [rsp+0xffff0000]       // 将 [rsp+0xffff0000] 地址的值移到 rbx 中,后面会讲
        asm_amd64.s:172 0x454247        48895f10                mov qword ptr [rdi+0x10], rbx       // 将 rbx 中的地址,移到 [rdi+0x10],实际是移到 g0.stackguard0
        asm_amd64.s:173 0x45424b        48895f18                mov qword ptr [rdi+0x18], rbx       // 将 rbx 中的地址,移到 [rdi+0x18],实际是移到 g0.stackguard1
        asm_amd64.s:174 0x45424f        48891f                  mov qword ptr [rdi], rbx            // 将 rbx 中的地址,移到 [rdi],实际是移到 g0.stack.lo
        asm_amd64.s:175 0x454252        48896708                mov qword ptr [rdi+0x8], rsp        // 将 rsp 中的地址,移到 [rdi+0x8],实际是移到 g0.stack.hi

指令中
runtime.g0
为运行时主线程提供运行的执行环境,它并不是执行用户代码的 goroutine。

使用
regs
查看寄存器
rbx
存储的是什么:

(dlv) regs
    Rip = 0x000000000045423f
    Rsp = 0x00007ffec8d155f0
    Rbx = 0x00007ffec8d15628

(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:172 (PC: 0x454247)
Warning: debugging optimized function
        asm_amd64.s:171 0x45423f        488d9c240000ffff        lea rbx, ptr [rsp+0xffff0000]
=>      asm_amd64.s:172 0x454247        48895f10                mov qword ptr [rdi+0x10], rbx

(dlv) regs
    Rip = 0x0000000000454247
    Rsp = 0x00007ffec8d155f0
    Rbx = 0x00007ffec8d055f0

可以看到,这段指令实际指向的是一段栈空间,
rsp:0x00007ffec8d155f0
指向的是栈底,
rbx:0x00007ffec8d055f0
指向的是栈顶,它们的内存空间是 64KB。

根据上述分析,画出栈空间布局如下图:

image

继续往下分析,省略一些不相关的汇编代码。直接从
asm_amd64.s/runtime·rt0_go:258
开始看:

258	    LEAQ	runtime·m0+m_tls(SB), DI
259	    CALL	runtime·settls(SB)
260
261	    // store through it, to make sure it works
262	    get_tls(BX)
263	    MOVQ	$0x123, g(BX)
264	    MOVQ	runtime·m0+m_tls(SB), AX
265	    CMPQ	AX, $0x123
266	    JEQ 2(PC)
267	    CALL	runtime·abort(SB)

dlv
打断点,进入 258 行汇编指令处:

(dlv) b /usr/local/go/src/runtime/asm_amd64.s:258
Breakpoint 2 set at 0x4542cb for runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:258
(dlv) c
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:259 (PC: 0x4542d2)
Warning: debugging optimized function
        // 将 [runtime.m0+136] 地址移到 rdi,rdi = &runtime.m0.tls
        asm_amd64.s:258 0x4542cb*       488d3d565f0700                  lea rdi, ptr [runtime.m0+136]
        // 调用 runtime.settls 设置线程本地存储
=>      asm_amd64.s:259 0x4542d2        e809240000                      call $runtime.settls
        // 将 0x123 移到 fs:[0xfffffff8]
        asm_amd64.s:263 0x4542d7        6448c70425f8ffffff23010000      mov qword ptr fs:[0xfffffff8], 0x123
        // 将 [runtime.m0+136] 的值移到 rax 寄存器中
        asm_amd64.s:264 0x4542e4        488b053d5f0700                  mov rax, qword ptr [runtime.m0+136]
        // 比较 rax 寄存器的值是否等于 0x123,如果不等于则执行 call $runtime.abort
        asm_amd64.s:265 0x4542eb        483d23010000                    cmp rax, 0x123
        asm_amd64.s:266 0x4542f1        7405                            jz 0x4542f8
        asm_amd64.s:267 0x4542f3        e808040000                      call $runtime.abort

这段指令涉及到线程本地存储的知识。线程本地存储(TLS)是一种机制,允许每个线程有自己独立的一组变量,即使这些变量在多个线程之间共享相同的代码。在 Go runtime 中,每个操作系统线程(M)都需要知道自己当前正在执行哪个 goroutine(G)。为了高效地访问这些信息,Go runtime 使用 TLS 来存储 G 的指针。这样每个线程都可以通过 TLS 快速找到自己当前运行的 G。m0 是 Go 程序启动时的第一个操作系统线程,并且负责初始化整个 Go runtime。在其他线程通过 Go runtime 的调度器创建时,调度器会自动为它们设置 TLS,并将 G 的指针写入 TLS。但 m0 是一个特殊的线程,它直接由操作系统创建,而没有经过 Go 调度器,因此需要通过汇编指令设置 TLS。

这段指令的逻辑是将
runtime.m0.tls
的地址送到
rdi
寄存器中,接着调用
runtime.settls
设置 fs 段基址寄存器的值,使得通过段基址和偏移量就能访问到
m0.tls
。最后验证设置的
[段基址:偏移量]
能否正确的访问到
m0.tls
,将
0x123
传到
[段基址:偏移量]
,这时如果访问正确,应该传给的是
m0.tls[0] = 0x123
,然后将
[runtime.m0+136]
的内容,即
m0.tls[0]
拿出来移到
rax
寄存器做比较,如果一样,则说明通过
[段基址:偏移量]
可以正确访问到
m0.tls
,否则调用
runtime.abort
退出
runtime

每个线程都有自己的一组 CPU 寄存器值,不同的线程通过不同的段 fs 基址寄存器私有的存储全局变量。更详细的信息请参考
Go语言调度器源代码情景分析之十:线程本地存储

为加深这块理解,我们从汇编角度看具体是怎么设置的。

asm_amd64.s:258 0x4542cb*       488d3d565f0700                  lea rdi, ptr [runtime.m0+136]
=> rdi = &runtime.m0.tls = 0x00000000004ca228

asm_amd64.s:259 0x4542d2        e809240000                      call $runtime.settls
=> 设置的是 Fs_base 段基址寄存器的值,regs 查看 Fs_base=0x00000000004ca230

asm_amd64.s:263 0x4542d7        6448c70425f8ffffff23010000      mov qword ptr fs:[0xfffffff8], 0x123
=> fs:[0xfffffff8],fs 是段基址,实际是 Fs_base 段基址寄存器的值,[0xfffffff8] 是偏移量。fs:[0xfffffff8] = 0x00000000004ca230:[0xfffffff8] = 0x00000000004ca228
=> 实际通过段基址寄存器 fs:[0xfffffff8] 访问的内存地址就是 m0.tls 的地址 0x00000000004ca228

继续往下执行:

=>      asm_amd64.s:271 0x4542f8        488d0dc15a0700                  lea rcx, ptr [runtime.g0]               // 将 runtime.g0 的地址移到 rcx,rcx = &runtime.g0
        asm_amd64.s:272 0x4542ff        6448890c25f8ffffff              mov qword ptr fs:[0xfffffff8], rcx      // 将 rcx 移到 m0.tls,实际是 m0.tls[0] = &runtime.g0
        asm_amd64.s:273 0x454308        488d05915e0700                  lea rax, ptr [runtime.m0]               // 将 runtime.m0 的地址移到 rax,rax = &runtime.m0
        asm_amd64.s:276 0x45430f        488908                          mov qword ptr [rax], rcx                // 将 runtime.g0 的地址移到 runtime.m0,实际是 runtime.m0.g0 = &runtime.g0
        asm_amd64.s:278 0x454312        48894130                        mov qword ptr [rcx+0x30], rax           // 将 runtime.m0 的地址移到 runtime.g0.m,实际是 runtime.g0.m = &runtime.m0

上述指令做的是关联主线程
m0

g0
,这样
m0
就有了运行时执行环境。画出内存布局如下图:

image

2. 小结

至此,我们的程序初始化部分就告一段落了,下一篇将正式进入调度器的部分。