内核无锁队列kfifo

文章目录

  • 1、抛砖引玉
  • 2、内核无锁队列kfifo
    • 2.1 kfifo结构
    • 2.2 kfifo分配内存
    • 2.3 kfifo初始化
    • 2.4 kfifo释放
    • 2.5 kfifo入队列
    • 2.6 kfifo出队列
    • 2.7 kfifo的判空和判满
    • 2.8 关于内存屏障

1、抛砖引玉

昨天遇到这样一个问题,有多个生产者,多个消费者,一个公共的消息队列,生产者向消息队列中写数据,消费者从消息队列中读数据,因为消息队列是临界资源,因此需要加锁

在这里插入图片描述

这样做的话,锁竞争太严重,必定会影响效率,有没有一种办法,消费者在从消息队列中读取数据时,不需要加锁?

当然有,就是为每个消费者都建立一个自己的消息队列,生产者共用一个消息队列。生产者互斥的向消息队列中写数据,负载均衡器将数据分发到每个消费者的消息队列中,消息消费者再从自己的消息队列中读取数据,这样就形成了单读单写,这样的消息队列有一个名字,叫ringbuffer(环形缓冲区),适用于单生产者,单消费者的场景,虽然是两个线程,但是却不用加锁,可以用数组或者链表实现

在这里插入图片描述

有人可能会疑问,消费者自己的消息队列(ringbuffer)也是临界资源,也会被消费者和负载均衡器共同访问,难道不需要加锁控制吗?

其实在RingBuffer中设置了两个指针,head和tail。head指向下一次读的位置,tail指向的是下一次写的位置。RingBuffer可用一个数组进行存储。 在进行读操作的时候,我们只修改head的值,而在写操作的时候我们只修改tail的值。在写操作时,我们在写入内容到buffer之后才修改tail的值,而在进行读操作的时候,我们会读取tail的值并将其赋值给copyTail。赋值操作是原子操作。所以在读到copyTail之后,从head到copyTail之间一定是有数据可以读的,不会出现数据没有写入就进行读操作的情况。同样的,读操作完成之后,才会修改head的数值;而在写操作之前会读取head的值来判断是否有空间可以用来写数据。所以,这时候tail到head-1之间一定是有空间可以写数据的,而不会出现一个位置的数据还没有读出就被写操作覆盖的情况。这样就保证了RingBuffer的线程安全性。

理论证明,在一个生产者和一个消费者的情况下,两者之间的同步无需加锁,即可并发访问。

2、内核无锁队列kfifo

在Linux当中,单生产者单消费者的应用场景有很多,例如每个socket都对应着一个接受缓冲区和发送缓冲区,上层应用向发送缓冲区写数据,再将数据拷贝到网卡,发送给对方,接受缓冲区则相反。又比如一个进程A产生数据发给另外一个进程B,进程B需要对进程A传的数据进行处理并写入文件,如果B没有处理完,则A要延迟发送。为了保证进程A减少等待时间,可以在A和B之间采用一个缓冲区,A每次将数据存放在缓冲区中,B每次冲缓冲区中取。

因此Linux中有自己的ringbuffer,不过它的名字叫kfifo,kfifo设计的非常巧妙,代码很精简,以下为kfifo的相关源码,内核版本为4.9.145

2.1 kfifo结构

struct __kfifo {
	unsigned int	in;    //数据到来时,存放数据的位置
	unsigned int	out;   //读取数据的位置
	unsigned int	mask;  //mask+1表示缓冲区data的容量,能容纳多少个元素
	unsigned int	esize; //每个元素的大小
	void		*data;     //缓冲区的起始位置
};

请注意,这里的 in,out 均是无符号的整数类型。

2.2 kfifo分配内存

int __kfifo_alloc(struct __kfifo *fifo, unsigned int size,
		size_t esize, gfp_t gfp_mask)
{
	/*
	 * round down to the next power of 2, since our 'let the indices
	 * wrap' technique works only in this case.
	 */
	size = roundup_pow_of_two(size);

	fifo->in = 0;
	fifo->out = 0;
	fifo->esize = esize;

	if (size < 2) {
		fifo->data = NULL;
		fifo->mask = 0;
		return -EINVAL;
	}

	fifo->data = kmalloc(size * esize, gfp_mask);

	if (!fifo->data) {
		fifo->mask = 0;
		return -ENOMEM;
	}
	fifo->mask = size - 1;

	return 0;
}

在为kfifo分配内存之前,需要检测传入的size是否为2的整数次幂,roundup_pow_of_two用于计算最接近且大于等于n的2的整数次幂,它的定义如下:

#define roundup_pow_of_two(n)			\
(						\
	__builtin_constant_p(n) ? (		\
		(n == 1) ? 1 :			\
		(1UL << (ilog2((n) - 1) + 1))	\
				   ) :		\
	__roundup_pow_of_two(n)			\
 )

它可以等价为以下代码,方便理解:
unsigned int roundup_pow_of_two(unsigned int n)
{
	if(n == 1) return 1;
	int i = 0;
	for(; n != 0; ++i)
	{
		n >> 1;
	}
	return 1U << i;
}

假设n为5,二进制位0101,在for循环内,需要循环3次,n向右移3位,才为0,
最后1向左移3为,二进制为1000,十进制为8,为2的整数次幂,并且离5最近且大于5

为什么要这么做呢?因为kfifo是环形队列,它的可读可写位置必然会回到初始位置,因此就需要用到取余操作,那么 m%n 在CPU看来就等价于 m-n*floor(m/n),其中乘法最终是通过加法和移位操作完成的,而除法首先转变为乘法,减法又会通过补码转变为加法,因此效率就比较低。

但是如果缓冲区的长度为2的整数次幂,m%n = m & (n - 1),只有减法和位运算,效率就提高了,所以才会将缓冲区的长度设置为2的整数次幂,并且将 mask 设置为 size(容量) - 1,方便后续进行位运算

计算完size后,接着将 in/out 指向的位置初始化为0,因为此刻队列还未准备好,里面并没有任何数据。

esize 赋值给 fifo->esize 这个是代表了队列中数据的类型的 size,比如队列数据类型如果为 int,则 esize 等于 4,队列数据类型为char,则 esize 等于 1

接着调用 kmalloc_array 接口,分配一个 esize * size 大小的空间,作为缓冲区

最后将 fifo->mask 赋值为 size - 1

分配好队列后,实际情况如下所示:

在这里插入图片描述

2.3 kfifo初始化

这里跟kfifo分配内存有点不同,kfifo初始化是使用自己定义的buffer,不在需要调用 kmalloc_array 接口申请空间了

int __kfifo_init(struct __kfifo *fifo, void *buffer,
		unsigned int size, size_t esize)
{
	size /= esize;

	size = roundup_pow_of_two(size);

	fifo->in = 0;
	fifo->out = 0;
	fifo->esize = esize;
	fifo->data = buffer;

	if (size < 2) {
		fifo->mask = 0;
		return -EINVAL;
	}
	fifo->mask = size - 1;

	return 0;
}

这里依旧需要检测传入的size是否为2的整数次幂,roundup_pow_of_two用于计算最接近且大于等于n的2的整数次幂,其他部分都大致类似

2.4 kfifo释放

void __kfifo_free(struct __kfifo *fifo)
{
	kfree(fifo->data);
	fifo->in = 0;
	fifo->out = 0;
	fifo->esize = 0;
	fifo->data = NULL;
	fifo->mask = 0;
}

释放kfifo比较简单,直接释放data缓冲区,将所有的数据都置为0即可

2.5 kfifo入队列

static inline unsigned int kfifo_unused(struct __kfifo *fifo)
{
	return (fifo->mask + 1) - (fifo->in - fifo->out);
}


static void kfifo_copy_in(struct __kfifo *fifo, const void *src,
		unsigned int len, unsigned int off)
{
	unsigned int size = fifo->mask + 1;
	unsigned int esize = fifo->esize;
	unsigned int l;

	off &= fifo->mask;
	if (esize != 1) {
		off *= esize;
		size *= esize;
		len *= esize;
	}
	l = min(len, size - off);

	memcpy(fifo->data + off, src, l);
	memcpy(fifo->data, src + l, len - l);
	/*
	 * make sure that the data in the fifo is up to date before
	 * incrementing the fifo->in index counter
	 */
	smp_wmb();
}


unsigned int __kfifo_in(struct __kfifo *fifo,
		const void *buf, unsigned int len)
{
	unsigned int l;

	l = kfifo_unused(fifo);
	if (len > l)
		len = l;

	kfifo_copy_in(fifo, buf, len, fifo->in);
	fifo->in += len;
	return len;
}

数据入队列时,调用了unsigned int __kfifo_in(struct __kfifo *fifo, const void *buf, unsigned int len)

在函数内部首先调用了kfifo_unused来判断当前还剩多少空间可以使用

前面我们已经提到过,fifo->mask 在初始化的时候被赋值成为 size - 1, 所以这里 (fifo->mask + 1) 就等于申请的时候的 size 值。size 的值代表着总的存储对象的个数,而每次在推数据进入 fifo 的时候,in 都会增加,取出数据的时候,out 都会增加。所以计算当前 fifo 中还剩余多少空间就使用了:

(fifo->mask + 1) - (fifo->in - fifo->out)

在这里插入图片描述

注意:这里的 in/out 是不断增加的无符号整形

接着会调用函数 static void kfifo_copy_in(struct __kfifo *fifo, const void *src, unsigned int len, unsigned int off)

首先还是通过 fifo->mask 得到了整个 size 的大小。然后是用:

off &= fifo->mask;

展开就是:

fifo->in = fifo->in & fifo->mask;

因为fifo->in是一直在增加的,但是缓冲区的容量是不变的,所以在写入数据之前,就需要找到具体在哪个位置写,也就是需要知道在缓冲区中,in的偏移量,因此就需要取余操作,但是呢,前面申请的空间已经是2的整数次幂,因此这里的位运算就替代了取余操作,提高了运算效率

接着判断 esize 的值,就是每个元素的占用内存的情况,如果不是 1 的话(一个字节),则需要对 off,size,len 分别乘以 esize。因为在使用 memcpy 时,需要以1个字节为单位进行数据拷贝。

举个例子:

在这里插入图片描述

接着使用:

l = min(len, size - off);

取得复制数据的长度 len 和 size-off(末尾的剩余空间) 之间的最小值,由是环形的缓冲区 ,所以在此处存在两种情况:

① 复制数据的长度len要小于size-off(末尾的剩余空间):

在这里插入图片描述

② 复制数据的长度len要大于size-off(末尾的剩余空间):

在这里插入图片描述

所以在这个地方,先去取一个 len 和 size-off 之间最小的那个值 l,即,先打算尝试把尾巴上能用的空间先用完,如果不够再去用从头部开始的剩余空间,这两个 memcpy 用得十分巧妙,不需要做额外的判断:

针对第一种情况(复制数据的长度len要小于size-off(末尾的剩余空间)):

第一条 memcpy:将 len 的数据 memcpy 到以 fifo->data (之前用过 kmalloc 分配的内存起始地址),加上 off 偏移(in 对应的偏移)的地方开始,copy 进 src 数据。

第二条 memcpy:len -l 等于0,memcpy 什么都不会做

针对第二种情况(复制数据的长度len要大于size-off(末尾的剩余空间)):

第一条 memcpy:和前一种情况一样

第二条 memcpy:len -l 大于0,将剩余的数据拷贝到fifo->data的头部

最终整个环形缓冲区的数据拷贝完成

最后在退出 kfifo_copy_in 后,在 __kfifo_in 函数中对 fifo->in 做累加:

fifo->in += len;

做完上述的拷贝后,对于上述两种情况,最后体现出来的是:

① 复制数据的长度len要小于等于size-off(末尾的剩余空间):

在这里插入图片描述

② 复制数据的长度len要大于size-off(末尾的剩余空间):

在这里插入图片描述

前面谈到的入队列都是out 在前,in在后,假设in在前,out在后呢?

复制数据的长度len要小于size-off(in和out之间的剩余空间):

在这里插入图片描述

第一条 memcpy:将 len 的数据 memcpy 到以 fifo->data,加上 off 偏移(in 对应的偏移)的地方开始,copy 进 src 数据。

第二条 memcpy:len -l 等于0,memcpy 什么都不会做

在这里插入图片描述

复制数据的长度len要大于size-off(in和out之间的剩余空间):

在这里插入图片描述

第一条 memcpy:将 len 的数据 memcpy 到以 fifo->data,加上 off 偏移(in 对应的偏移)的地方开始,copy 进 src 数据。

第二条 memcpy:len -l 等于0,memcpy 什么都不会做

在这里插入图片描述

到现在有人可能会有疑问,为什么在拷贝数据时为什么没有判断缓冲区是否满了呢?

其实这里调用了kfifo_unused函数计算剩余空间,如果剩余空间为0,虽然依旧会进入kfifo_copy_in函数,l = min(len, size - off),但这里是取了剩余空间和需要拷贝数据的最小值,即为0,两个memcpy什么都不会做

注意:如果拷贝的数据量大于剩余空间,会用数据将剩余的空间填充满,返回值就是拷贝了多少字节的数据

2.6 kfifo出队列

出队列和入队列的逻辑是差不多的,这里就不多赘述了

调用顺序是__kfifo_out–》__kfifo_out_peek–》kfifo_copy_out

static void kfifo_copy_out(struct __kfifo *fifo, void *dst,
		unsigned int len, unsigned int off)
{
	unsigned int size = fifo->mask + 1;
	unsigned int esize = fifo->esize;
	unsigned int l;

	off &= fifo->mask;
	if (esize != 1) {
		off *= esize;
		size *= esize;
		len *= esize;
	}
	l = min(len, size - off);

	memcpy(dst, fifo->data + off, l);
	memcpy(dst + l, fifo->data, len - l);
	/*
	 * make sure that the data is copied before
	 * incrementing the fifo->out index counter
	 */
	smp_wmb();
}

unsigned int __kfifo_out_peek(struct __kfifo *fifo,
		void *buf, unsigned int len)
{
	unsigned int l;

	l = fifo->in - fifo->out;
	if (len > l)
		len = l;

	kfifo_copy_out(fifo, buf, len, fifo->out);
	return len;
}
EXPORT_SYMBOL(__kfifo_out_peek);

unsigned int __kfifo_out(struct __kfifo *fifo,
		void *buf, unsigned int len)
{
	len = __kfifo_out_peek(fifo, buf, len);
	fifo->out += len;
	return len;
}

2.7 kfifo的判空和判满

源代码中并没有判断空和判断满的函数,但是对于入队列时有一个计算剩余空间的函数,前面也提到过

static inline unsigned int kfifo_unused(struct __kfifo *fifo)
{
	return (fifo->mask + 1) - (fifo->in - fifo->out);
}

它的判满主要是看in - out 的值是等于 mask (size - 1)

对于出队列时,在__kfifo_out_peek 函数内,有l = fifo->in - fifo->out,当in和out相等时,就表示空的,也就是empty

关于in/out溢出问题
kfifo中的in和out只会一直增加,因为它俩是无符号整数,因此最终就会回到0,即使到in出现溢出,在out之前,in-out的值仍然为无符号整数,依旧能表示已经使用的buffer的长度,这点无需担心。这正是这个机制的精妙之处。

2.8 关于内存屏障

尽管单消费者和单生产者能够对kfifo的进行无锁并发访问,但是在源码中,入队列和出队列依旧使用了smp_wmb(),也就是内存屏障

编译器编译源代码时,会将源代码进行优化,将源代码的指令进行重排序,以适合于CPU的并行执行。然而,内核同步必须避免指令重新排序,优化屏障避免编译器的重排序优化操作,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。

软件可通过读写屏障强制内存访问次序。读写屏障像一堵墙,所有在设置读写屏障之前发起的内存访问,必须先于在设置屏障之后发起的内存访问之前完成,确保内存访问按程序的顺序完成。Linux内核提供的内存屏障API函数说明如下表。内存屏障可用于多处理器和单处理器系统,如果仅用于多处理器系统,就使用smp_xxx函数,在单处理器系统上,它们什么都不要。

内存屏障含义
smp_rmb适用于多处理器的读内存屏障
smp_wmb适用于多处理器的写内存屏障
smp_mb适用于多处理器的内存屏障

所以在 kfifo_copy_in 和 kfifo_copy_out 的尾部都插入了 smp_wmb() 的写内存屏障的代码

它的作用是确保 fifo->in 和 fifo->out 增加 len 的这个操作在内存屏障之后,也就是保证了在 SMP 多处理器下,一定是先完成了 fifo 的内存操作,然后再进行变量的增加。以免被优化后的混乱访问,导致策略失败

不过,多个消费者、生产者的并发访问还是需要加锁限制

最后再提一句,pthread_mutex中不包含内存屏障,而spin_lock中包含内存屏障

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/223903.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

使用Java网络编程,窗口,线程,IO,内部类等实现多人在线聊天1.0

1.整体思路 思路图 整体思路如上: 涉及知识点:线程网络编程集合IO等 TCP 协议 2.代码实现过程 服务端 import javax.swing.*; import java.awt.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyAdapter; import jav…

SQL手工注入漏洞测试(Sql Server数据库)-墨者

———靶场专栏——— 声明&#xff1a;文章由作者weoptions学习或练习过程中的步骤及思路&#xff0c;非正式答案&#xff0c;仅供学习和参考。 靶场背景&#xff1a; 来源&#xff1a; 墨者学院 简介&#xff1a; 安全工程师"墨者"最近在练习SQL手工注入漏洞&#…

大模型应用设计的10个思考

技术不是万能的&#xff0c;但没有技术却可能是万万不能的&#xff0c;对于大模型可能也是如此。基于大模型的应用设计需要聚焦于所解决的问题&#xff0c;在自然语言处理领域&#xff0c;大模型本身在一定程度上只是将各种NLP任务统一成了sequence 到 sequence 的模型。利用大…

使用 Webshell 访问 SQL Server 主机并利用 SSRS

本文将指导您使用RDS SQL Server实例的主机账号登录和管理SQL Server Reporting Services&#xff08;SSRS&#xff09;数据库。 背景信息 RDS SQL Server提供Webshell功能&#xff0c;用户可以通过Web界面登录RDS SQL Server实例的操作系统。通过Webshell&#xff0c;用户可…

一次重新加载所有 maven 项目产生的 OOM

1、解决什么问题&#xff1f; 忘了截图了&#xff0c;用文字描述就是由于Reload All Maven Projects导致的 OOM 异常。 2、尝试与解决 2.1、尝试 2.1.1、尝试清理idea缓存&#xff08;无效&#xff09; 2.1.2、重启idea&#xff08;无效&#xff09; 2.1.3、重启电脑&am…

硬件连通性测试对象与实施过程

硬件连通性测试是一种系统性的测试方法&#xff0c;用于验证硬件设备之间的连接、通信和协作是否正常。这包括各种硬件组件&#xff0c;如计算机、网络设备、传感器、打印机等。测试的目的是确保硬件设备在其设计和运行环境中能够正确地交互和通信。 一、硬件连通性测试对象 网…

Slurm集群管理系统

Slurm集群管理系统 Slurm&#xff08;Simple Linux Utility for Resource Management&#xff0c;https://slurm.schedmd.com/&#xff09;是一个开源的、容错的、高度可扩展的集群管理和作业调度系统&#xff0c;适用于大型和小型高性能计算&#xff08;HPC&#xff09;集群。…

憋了个大招_群发版

大家好&#xff0c;我是良许。 憋了个大招&#xff0c;兄弟们&#xff01;我花了两个月的时间&#xff0c;搭建了一个自己的网站啦&#xff5e; 不卖关子&#xff0c;网站链接为&#xff1a; www.lxlinux.net/e/ 网站首页截图如下&#xff1a; 这个网站全部都是关于嵌入式及…

【JavaWeb学习笔记】6 - Tomcat

项目代码 零、在线文档 Apache Tomcat 8 (8.0.53) - Documentation Index WEB开发 1. WEB,在英语中web表示网/网络资源(页面&#xff0c;图片,css,js)意思&#xff0c;它用于表示WEB服务器(主机)供浏览器访问的资源 2. WEB服务器(主机)上供外界访问的Web资源分为: 静态web…

动手学习深度学习-跟李沐学AI-自学笔记(3)

一、深度学习硬件-CPU和GPU 芯片&#xff1a;Intel or AMD 内存&#xff1a;DDR4 显卡&#xff1a;nVidia 芯片可以和GPU与内存通信 GPU不能和内存通信 1. CPU 能算出每一秒能运算的浮点运算数&#xff08;大概0.15左右&#xff09; 1.1 提升CPU利用率 1.1.1 提升缓存…

Vite4、Vue3、Axios 针对请求模块化封装搭配自动化导入(简单易用)

针对请求模块化封装搭配自动化导入&#xff08;简单易用&#xff09; 目标目录目标代码前提步入正题src / utils / index.jssrc /api / index.jssrc /api / request.jssrc /api / service.jssrc /api / utils.jssrc /api / modules / demo.js 自动化配置vite.config.jseslint 校…

2023中医药国际传承传播大会暨中医药图片和非遗艺术展隆重揭幕

由世界针灸学会联合会、中新社国际传播集团、中国新闻图片网、中国民族医药学会、中国针灸学会联合主办的“2023中医药国际传承传播大会”3日在广东省深圳市举办&#xff0c;“中医药国际传承传播图片展”与“非遗艺术展”在大会举办期间开展迎客。会议聚焦非遗健康、非遗传承等…

案例049:基于微信小程序的校园外卖平台设计与实现

文末获取源码 开发语言&#xff1a;Java 框架&#xff1a;SSM JDK版本&#xff1a;JDK1.8 数据库&#xff1a;mysql 5.7 开发软件&#xff1a;eclipse/myeclipse/idea Maven包&#xff1a;Maven3.5.4 小程序框架&#xff1a;uniapp 小程序开发软件&#xff1a;HBuilder X 小程序…

【vue】点击导航菜单切换局部页面,打开展示默认栏目,页面刷新等问题

非专业前端&#xff0c;局限性较高&#xff0c;有些问题看起来很小&#xff0c;但是初次接触很棘手&#xff0c;需要查找很多博客&#xff0c;内容也很杂。以下只是过程中总结下来的&#xff0c;要解决的就是标题中的三个问题。 这是我需要达成的效果。 1.第一个是进入导航菜单…

LeetCode:2646. 最小化旅行的价格总和(dfs + 树形dp C++、Java)

目录 2646. 最小化旅行的价格总和 题目描述&#xff1a; 实现代码与解析&#xff1a; DFS DP 原理思路&#xff1a; 2646. 最小化旅行的价格总和 题目描述&#xff1a; 现有一棵无向、无根的树&#xff0c;树中有 n 个节点&#xff0c;按从 0 到 n - 1 编号。给你一个整数…

团队git操作流程

项目的开发要求&#xff1a;&#xff08;1&#xff09;项目组厉员代码提交不少于20次 &#xff08;2&#xff09;项目组厉员每天提交不少于20次 &#xff08;3&#xff09;企业项目开发代码的每天的提交一般提交3-5次 &#xff08;4&#xff09;代码仓库的管理 git的基础操作流…

案例042:基于微信小程序的居住证申报系统

文末获取源码 开发语言&#xff1a;Java 框架&#xff1a;SSM JDK版本&#xff1a;JDK1.8 数据库&#xff1a;mysql 5.7 开发软件&#xff1a;eclipse/myeclipse/idea Maven包&#xff1a;Maven3.5.4 小程序框架&#xff1a;uniapp 小程序开发软件&#xff1a;HBuilder X 小程序…

OpenCV-python numpy和基本作图

文章目录 一、实验目的二、实验内容三、实验过程Numpy1.NumPy 操作2.NumPy Ndarray 对象3.NumPy 基本类型4.NumPy 数组属性ndarray.ndimndarray.shapendarray.itemsizendarray.flags 5.NumPy 创建数组numpy.emptynumpy.zerosnumpy.ones 6.NumPy 从已有的数组创建数组numpy.asar…

【Python】Python读Excel文件生成xml文件

目录 ​前言 正文 1.Python基础学习 2.Python读取Excel表格 2.1安装xlrd模块 2.2使用介绍 2.2.1常用单元格中的数据类型 2.2.2 导入模块 2.2.3打开Excel文件读取数据 2.2.4常用函数 2.2.5代码测试 2.2.6 Python操作Excel官方网址 3.Python创建xml文件 3.1 xml语法…

HCL Domino 12系统管理员考试

大家好&#xff0c;才是真的好。 12月份的某一天&#xff0c;趁着风和日丽&#xff0c;天朗气清&#xff0c; 下了舍弃100多美金的狠心&#xff0c;在Pearson Vue官网上报了HCL Domino 12系统管理员考试的名 。时隔十五年后&#xff0c;骑着电动车风风火火地前往某一当地考试中…