分类 其它 下的文章

前置芝士

$\LARGE {关于二叉搜索树及平衡树无聊的一大串子定义}$

二叉搜索树(BST树)

定义

二叉搜索树是一种二叉树的树形数据结构,其定义如下:

  • 空树是二叉搜索树。

  • 若二叉搜索树的左子树不为空,则其左子树上所有点的附加权值均小于其根节点的值。

  • 若二叉搜索树的右子树不为空,则其右子树上所有点的附加权值均大于其根节点的值。

  • 二叉搜索树的左右子树均为二叉搜索树。

复杂度

二叉搜索树上的基本操作所花费的时间与这棵树的高度成
\(\color{#40c0bb}{正比}\)
。对于一个有
\(n\)
个结点的二叉搜索树中,这些操作的最优时间复杂度为
\(O(\log n)\)
,最坏为
\(O(n)\)
。随机构造这样一棵二叉搜索树的
\(\color{#40c0bb}{期望高度}\)

\(O(\log n)\)

性质

其实也就是定义


\(x\)
是二叉搜索树中的一个结点。

如果
\(y\)

\(x\)
左子树中的一个结点,那么
\(y.key≤x.key\)

如果
\(y\)

\(x\)
右子树中的一个结点,那么
\(y.key≥x.key\)

在二叉搜索树中:

  1. 若任意结点的左子树不空,则左子树上所有结点的值均不大于它的根结点的值。

  2. 若任意结点的右子树不空,则右子树上所有结点的值均不小于它的根结点的值。

  3. 任意结点的左、右子树也分别为二叉搜索树。

用途

二叉搜索树通常可以高效地完成以下操作:

  1. 查找最小/最大值

  2. 搜索元素

  3. 插入一个元素

  4. 删除一个元素

  5. 求元素的排名

  6. 查找排名为 k 的元素

平衡树

定义

由二叉搜索树的复杂度分析可知:操作的复杂度与树的高度
\(h\)
有关。

那么我们可以通过一定操作维持树的高度(平衡性)来降低操作的复杂度,这就是
\(\color{#40c0bb}{平衡树}\)

\(\color{#40c0bb} \large \textbf{平衡性}\)
通常指每个结点的左右子树的高度之差的绝对值(平衡因子)最多为
\(1\)

平衡的调整过程——树旋转

定义

树旋转是在二叉树中的一种子树调整操作, 每一次旋转并
\(\color{#40c0bb}{不影响}\)
对该二叉树进行
\(\color{#40c0bb}{中序遍历}\)
的结果。
树旋转通常应用于需要调整树的局部平衡性的场合。树旋转包括两个不同的方式,分别是
\(\color{#40c0bb}{左旋(Left Rotate 或者  zag)}\)

\(\color{#40c0bb}{右旋(Right Rotate 或者 zig)}\)
。 两种旋转呈镜像,而且互为逆操作。

具体操作

右旋

对于结点
\(A\)
的右旋操作是指:将
\(A\)
的左孩子
\(B\)
向右上旋转,代替
\(A\)
成为根节点,将
\(A\)
结点向右下旋转成为
\(B\)
的右子树的根结点,
\(B\)
的原来的右子树变为
\(A\)
的左子树。

左旋

完全同理

具体情况

其实你只需要知道二叉搜索树的几条基本性质即可:

  1. 每个结点都满足左子树的结点的值都
    小于
    自己的值,右子树的结点的值都
    大于
    自己的值,
    左右子树也是二叉搜索树

  2. 中序遍历二叉搜索树可以得到一个由这棵树的所有结点的值组成的
    有序
    序列。(即所有的值排序后的结果)

正片

背景

不难发现
\(BST树\)
的一种极端情况:
\(\color{#40c0bb}{退化情况}\)

这种
毒瘤
数据让时间复杂度从
\(O(log(n))\)
退化到了恐怖的
\(O(n)\)

于是就有各种各样的科学家们,开始思考人生,丧心病狂地创造出了各种优化BST的方法...

Splay

定义

啥是
\(Splay\)

她实际上就是一种可以旋转的平衡树。
她可以通过
Splay/伸展操作
不断将某个节点旋转到根节点,使得整棵树仍然满足二叉查找树的性质,能够在均摊
\(O(\log n)\)
时间内完成插入,查找和删除操作,并且保持平衡而不至于退化成链。

原理&实现

节点维护信息

rt tot fa[N] ch[N][2] val[N] cnt[N] sz[N]
节点编号 父节点 子节点 左0右1 权值 节点大小 子树大小

基本操作

首先你要了解的就是一些基本操作:

  • \(maintain(x)\)
    :在改变节点位置后,将节点
    \(x\)

    \(\text{size}\)
    更新。
  • \(get(x)\)
    :判断节点
    \(x\)
    是父亲节点的左儿子还是右儿子。
  • \(clear(x)\)
    :清空节点
    \(x\)

void maintain(int x){
	sz[x]=sz[ch[x][0]]+sz[ch[x][1]]+cnt[x];
}
	
bool get(int x){
	return x==ch[fa[x]][1];
}
	
void clear(int x){
	ch[x][0]=ch[x][1]=fa[x]=val[x]=sz[x]=cnt[x]=0;
}

很简单对吧?

接下来可就要上难度了。

旋转操作(rotate)

由定义可知,我们要将某个节点旋转到根节点。

而要想知道怎么将一个节点旋转到根节点,首先要考虑怎么将她旋转到父亲节点。

当该节点为左儿子时

如图,方框表示子树,圆框表示节点

现在,我们要将
\(x\)
节点往上爬一层到他的父节点
\(y\)
,为了保证不改变中序遍历顺序,我们可以让
\(y\)
成为
\(x\)
的右儿子。

但是原来的
\(x\)
节点是有右儿子
\(B\)
的,显然我们要把
\(B\)
换一个位置才能达到目的。

我们知道:
\(x\)
节点的右子树必然是大于
\(x\)
节点的;
\(y\)
节点必然是大于
\(x\)
节点的右子树和
\(x\)
节点本身的(因为
\(x\)
节点及其右子树都是原来
\(y\)
的左子树,肯定比
\(y\)
小(根据二叉搜索树性质))

因此我们可以把
\(x\)
节点原来的右子树放在
\(y\)
的左儿子的位置上,达成目的。

实际上,这也就是
\(\color{#40c0bb}\textbf{右旋}\)
的原理。

当该节点为右儿子时

原理相同。

旋转为

通解

若节点
\(x\)

\(y\)
节点的位置
\(z\)
(
\(z=0\)
为左节点,
\(z=1\)
为右节点 )


  1. \(y\)
    节点放到
    \(x\)
    节点的
    \(z \oplus 1\)
    的位置.(也就是,
    \(x\)
    节点为
    \(y\)
    节点的右子树,那么
    \(y\)
    节点就放到左子树,
    \(x\)
    节点为
    \(y\)
    节点左子树,那么
    \(y\)
    节点就放到右子树位置)

  2. 如果说
    \(x\)
    节点的
    \(z \oplus 1\)
    位置上,已经有节点,或者一棵子树,那么我们就将原来
    \(x\)
    节点
    \(z \oplus 1\)
    位置上的子树,放到
    \(y\)
    节点的位置
    \(z\)
    上面.

这里有个小口诀:“
左旋拎右左挂右,右旋拎左右挂左

看懂文字了,就可以尝试理解一下代码了。

实现

void rotate(int x){
	int y=fa[x],z=fa[y],chk=get(x);
    //y为x的父亲,z为x的爷爷,chk判断x是左儿子还是右儿子

	ch[y][chk]=ch[x][chk^1];
	if(ch[x][chk^1]) fa[ch[x][chk^1]]=y;
	ch[x][chk^1]=y;
	fa[y]=x;
	fa[x]=z;
	if(z) ch[z][y==ch[z][1]]=x;
	maintain(y),maintain(x);
}

Splay操作

Splay(x,to)
是要将
\(x\)
节点旋转至
\(to\)
节点。

单旋

很暴力的办法,对于
\(x\)
节点,每次上旋至
\(fa[x]\)
,直到
\(to\)
节点。

但是,如果你真的这么写可能会
T成SB
被某些毒瘤数据卡成

\(n^2\)

所以不要看单旋简单好写,这里更推荐双旋的写法。

双旋

双旋的优化在于:

  1. 如果当前处于共线状态的话,那么先旋转
    \(y\)
    ,再旋转
    \(x\)
    。这样可以强行让他们不处于共线状态,然后平衡这棵树.

  2. 如果当前不是共线状态的话,那么只要旋转
    \(x\)
    即可。

void splay(int x,int goal=0){
	if(goal==0) rt=x;
	while(fa[x]!=goal){
		int f=fa[x];
		if(fa[fa[x]]!=goal){
			rotate(get(x)==get(f)?f:x);
		}
		rotate(x);
	}
}

查找操作

当然你也可以不写。

查找操作是因为查
\(k\)
的前驱后继时需要将
\(k\)
旋到根节点的位置。

实际上你也可以直接
splay(k,0)
或先插入
\(k\)
查询后再将它删去。

Splay也是一颗二叉搜索树,因此满足左侧都比他小,右侧都比他大。

因此只需要相应的往左/右递归即可。

void find(int x){
    int u=root;
    if(!u)return;//树空
    while(t[u].ch[x>t[u].val]&&x!=t[u].val)
        u=t[u].ch[x>t[u].val];
    splay(u,0);
}

插入操作

  • 如果树空了,则直接插入根并退出。
  • 如果当前节点的权值等于
    \(k\)
    则增加当前节点的大小并更新节点和父亲的信息,将当前节点进行 Splay 操作。
  • 否则按照二叉查找树的性质(左侧都比他小,右侧都比他大)向下找,找到空节点就插入即可。
void ins(int k){//insert
	if(!rt){
		val[++tot]=k;
		cnt[tot]++;
		rt=tot;
		maintain(rt);
		return;
	}
	int cur=rt,f=0;
	while(1){
		if(val[cur]==k){
			cnt[cur]++;
			maintain(cur);
			maintain(f);
			splay(cur);
			break;
		}
		f=cur;
		cur=ch[cur][val[cur]<k];
		if(!cur){
			val[++tot]=k;
			cnt[tot]++;
			fa[tot]=f;
			ch[f][val[f]<k]=tot;
			maintain(tot);
			maintain(f);
			splay(tot);
			break;
		}
	}
}

查询
\(x\)
的排名

  • 如果
    \(x\)
    比当前节点的权值小,向其左子树查找。
  • 如果
    \(x\)
    比当前节点的权值大,将答案加上左子树(
    \(size\)
    )和当前节点(
    \(cnt\)
    )的大小,向其右子树查找。
  • 如果
    \(x\)
    与当前节点的权值相同(已存在),将答案加
    \(1\)
    并返回。
int rk(int k){//the rank of "k"
	int res=0,cur=rt;
	while(1){
		if(k<val[cur]){
			cur=ch[cur][0];
		}else{
			res+=sz[ch[cur][0]];
			if(!cur) return res+1;
			if(k==val[cur]){
				splay(cur);
				return res+1;
			}
			res+=cnt[cur];
			cur=ch[cur][1];
		}
	}
}

查询排名
\(x\)
的数

  • 如果左子树非空且剩余排名
    \(k\)
    不大于左子树的大小
    \(size\)
    ,那么向左子树查找。
  • 否则将
    \(k\)
    减去左子树的和根的大小。如果此时
    \(k\)
    的值小于等于
    \(0\)
    ,则返回根节点的权值,否则继续向右子树查找。
int kth(int k){//the number whose rank is "k"
	int cur=rt;
	while(1){
		if(ch[cur][0] && k<=sz[ch[cur][0]]){
			cur=ch[cur][0];
		}else{
			k-=cnt[cur]+sz[ch[cur][0]];
			if(k<=0){
				splay(cur);
				return val[cur];
			}
			cur=ch[cur][1];
		}
	}
}

查询前驱&后继

前驱就是
\(x\)
的左子树中最右边的节点

后继就是
\(x\)
的右子树中最左边的节点

前驱

int pre(){//precursor
	int cur=ch[rt][0];
	if(!cur) return cur;
	while(ch[cur][1]) cur=ch[cur][1];
	splay(cur);
	return cur;
}

后继

其实就是查前驱的反面

int nxt(){//next or successor
	int cur=ch[rt][1];
	if(!cur) return cur;
	while(ch[cur][0]) cur=ch[cur][0];
	splay(cur);
	return cur;
}

查前驱后继有好多种写法,如果想偷懒只写一遍就可以酱紫

int prenxt(int x,int k){//0 pre 1 nxt
	find(x);
	int cur=rt;
	if(!k && val[cur]<x) return cur;
	if(k && val[cur]>x) return cur;
	cur=ch[cur][k];
	while(ch[cur][!k]){
		cur=ch[cur][!k];
	}
	return cur;
}

删除操作

首先将
\(x\)
旋转到根的位置。

  • 如果
    \(cnt[x]>1\)
    (有不止一个
    \(x\)
    ),那么将
    \(cnt[x] - 1\)
    并退出。

  • 否则,合并它的左右两棵子树即可。

void del(int k){//delete
	rk(k);
	if(cnt[rt]>1){
		cnt[rt]--;
		maintain(rt);
		return;
	}
	if(!ch[rt][0] && !ch[rt][1]){//树空
		clear(rt);
		rt=0;
		return;
	}
	if(!ch[rt][0]){
		int cur=rt;
		rt=ch[rt][1];
		fa[rt]=0;
		clear(cur);
		return;
	}
	if(!ch[rt][1]){
		int cur=rt;
		rt=ch[rt][0];
		fa[rt]=0;
		clear(cur);
		return;
	}
	int cur=rt,x=pre();
	fa[ch[cur][1]]=x;
	ch[x][1]=ch[cur][1];
	clear(cur);
	maintain(rt);
}

那么恭喜你,你已经学完了Splay的基本操作。

至于区间翻转什么的...

下次丕定

Code

Elaina's Code
struct Slpay{
	int rt;//根 
	int tot;//节点编号 
	int fa[N];//父节点 
	int ch[N][2];//子节点 左0右1 
	int val[N];//权值 
	int cnt[N];//节点大小 
	int sz[N];//子树大小 
	
	void maintain(int x){
		sz[x]=sz[ch[x][0]]+sz[ch[x][1]]+cnt[x];
	}
	
	bool get(int x){
		return x==ch[fa[x]][1];
	}
	
	void clear(int x){
		ch[x][0]=ch[x][1]=fa[x]=val[x]=sz[x]=cnt[x]=0;
	}
	
	
	void rotate(int x){
		int y=fa[x],z=fa[y],chk=get(x);
		ch[y][chk]=ch[x][chk^1];
		if(ch[x][chk^1]) fa[ch[x][chk^1]]=y;
		ch[x][chk^1]=y;
		fa[y]=x;
		fa[x]=z;
		if(z) ch[z][y==ch[z][1]]=x;
		maintain(y);
		maintain(x);
	}
	
	void splay(int x,int goal=0){
		if(goal==0) rt=x;
		while(fa[x]!=goal){
			int f=fa[x];
			if(fa[fa[x]]!=goal){
				rotate(get(x)==get(f)?f:x);
			}
			rotate(x);
		}
	}
	
	void ins(int k){//insert
		if(!rt){
			val[++tot]=k;
			cnt[tot]++;
			rt=tot;
			maintain(rt);
			return;
		}
		int cur=rt,f=0;
		while(1){
			if(val[cur]==k){
				cnt[cur]++;
				maintain(cur);
				maintain(f);
				splay(cur);
				break;
			}
			f=cur;
			cur=ch[cur][val[cur]<k];
			if(!cur){
				val[++tot]=k;
				cnt[tot]++;
				fa[tot]=f;
				ch[f][val[f]<k]=tot;
				maintain(tot);
				maintain(f);
				splay(tot);
				break;
			}
		}
	}
	
	int rk(int k){//the rank of "k"
		int res=0,cur=rt;
		while(1){
			if(k<val[cur]){
				cur=ch[cur][0];
			}else{
				res+=sz[ch[cur][0]];
				if(!cur) return res+1;
				if(k==val[cur]){
					splay(cur);
					return res+1;
				}
				res+=cnt[cur];
				cur=ch[cur][1];
			}
		}
	}
	
	int kth(int k){//the number whose rank is "k"
		int cur=rt;
		while(1){
			if(ch[cur][0] && k<=sz[ch[cur][0]]){
				cur=ch[cur][0];
			}else{
				k-=cnt[cur]+sz[ch[cur][0]];
				if(k<=0){
					splay(cur);
					return val[cur];
				}
				cur=ch[cur][1];
			}
		}
	}
	
	int pre(){//precursor
		int cur=ch[rt][0];
		if(!cur) return cur;
		while(ch[cur][1]) cur=ch[cur][1];
		splay(cur);
		return cur;
	}
	
	int nxt(){//next or successor
		int cur=ch[rt][1];
		if(!cur) return cur;
		while(ch[cur][0]) cur=ch[cur][0];
		splay(cur);
		return cur;
	}
	
	void del(int k){//delete
		rk(k);
		if(cnt[rt]>1){
			cnt[rt]--;
			maintain(rt);
			return;
		}
		if(!ch[rt][0] && !ch[rt][1]){
			clear(rt);
			rt=0;
			return;
		}
		if(!ch[rt][0]){
			int cur=rt;
			rt=ch[rt][1];
			fa[rt]=0;
			clear(cur);
			return;
		}
		if(!ch[rt][1]){
			int cur=rt;
			rt=ch[rt][0];
			fa[rt]=0;
			clear(cur);
			return;
		}
		int cur=rt,x=pre();
		fa[ch[cur][1]]=x;
		ch[x][1]=ch[cur][1];
		clear(cur);
		maintain(rt);
	}


    void find(int x){
		int cur=rt;
		if(!cur) return;
		while(ch[cur][x>val[cur]]&&x!=val[cur]){
			cur=ch[cur][x>val[cur]];
		}
		splay(cur,0);
	}
	
	int get_pre(int x){
		find(x);
		int cur=rt;
		if(val[cur]<x) return cur;
		cur=ch[cur][0];
		while(ch[cur][1]){
			cur=ch[cur][1];
		}
		return cur;
	}
	
	int get_nxt(int x){
		find(x);
		int cur=rt;
		if(val[cur]>x) return cur;
		cur=ch[cur][1];
		while(ch[cur][0]){
			cur=ch[cur][0];
		}
		return cur;
	}

    int prenxt(int x,int k){//0 pre 1 nxt
		find(x);
		int cur=rt;
		if(!k && val[cur]<x) return cur;
		if(k && val[cur]>x) return cur;
		cur=ch[cur][k];
		while(ch[cur][!k]){
			cur=ch[cur][!k];
		}
		return cur;
	}
}tr;

signed main(){
	int m=rd;
	while(m--){
		int opt=rd,x=rd;
		if(opt==1){
			tr.ins(x);
		}else if(opt==2){
			tr.del(x);
		}else if(opt==3){
			printf("%lld\n",tr.rk(x));
		}else if(opt==4){
			printf("%lld\n",tr.kth(x));
		}else if(opt==5){
			tr.ins(x),printf("%lld\n",tr.val[tr.pre()]),tr.del(x);
		}else{
			tr.ins(x),printf("%lld\n",tr.val[tr.nxt()]),tr.del(x);
		}
	}
	return Elaina;
}

学了这么久 奖励你张图吧

才不是我自己想看

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


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. 小结

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


前言

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技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!

【写在前面】

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

而在 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

前言

在前面两篇实战文章中:

覆盖了可观测中的指标追踪和
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 相关的概念与使用还是很有用的,是日常排查问题必不可少的一个工具。