集成定时器事件

news2024/11/25 4:02:27

一,定时器事件

1、概述

libevent提供了高性能定时器的功能,方便执行延迟回调逻辑。在添加事件监听的时候,可以不指定fd和监听的事件,指定超时的时间,实现定时器功能。定时器的实现主要依赖下面的数据结构,如下:
1)最小堆:按事件的超时时间构造,超时时间最早的在堆顶。
2)公共超时队列:这个可以个性化设置每个队列的超时时间,添加事件时时间相同的放到同一个队列里,在最小堆里只添加一个队列首部的事件到最小堆。能避免大量事件都放到最小堆,导致最小堆添加、删除时性能变差的问题。

2、实现原理

Libevent实现超时事件的原理是,多路IO复用函数都是有一个超时值。如果用户需要Libevent同时监听多个超时event,那么Libevent就把超时值最小的那个作为多路IO复用函数的超时值。自然,当时间一到,就会从多路IO复用函数返回,此时对超时event进行处理即可。当运行事件循环时,根据timer-heap中的事件最小超时时间,计算系统I/O demultiplexer的最大等待时间,下面是event_base_loop函数对应的代码段,如下:

3、超时事件管理

3.1、小根堆

Libevent内部使用小根堆来管理超时事件,堆的实现采用了典型的数组来维护,当数组元素满时扩容数组调整最小堆。堆中的元素是struct event,按照事件的ev_timeout字段对超时时间进行排序,如下:

在这里插入图片描述

注意:小根堆按照ev->ev_timeout值(绝对超时时间)进行排序

3.2、往min_heap中添加event

每一个event_base都对应一个min_heap数据结构,event_base中的所有设置了超时的event都会放在event_base的timeheap这一成员中,其定义如下:

struct event_base {
	......
	/** Priority queue of events with timeouts. */
	struct min_heap timeheap;
	......
};

event添加一个超时是通过event_add实现的,而在event_add内部实际上是event_add_internal函数,该函数共有三个传入参数,第一个参数是event指针,第二个参数是一个超时结构体timeval,第三个参数用于指明传入的超时结构体是否为绝对时间。如果传入的timeval非空,说明event是需要设置超时的,通过event_add_internal就可以将该event添加到min_heap中,如下所示:

static inline int
event_add_internal(struct event *ev, const struct timeval *tv,
    int tv_is_absolute)  
{   
	/* 处理超时事件 */
	if (res != -1 && tv != NULL) {
		......
		common_timeout = is_common_timeout(tv, base);
		if (tv_is_absolute) {  //如果是绝对时间 就直接用ev_timeout存储
			ev->ev_timeout = *tv;
		} else if (common_timeout) {  
                ......
		} else {
			evutil_timeradd(&now, tv, &ev->ev_timeout); //如果就只是一个普通的相对时间,就直接用系统时间加上超时时长作为超时时间
		}
		......
		event_queue_insert(base, ev, EVLIST_TIMEOUT); //插入到超时队列中
		......
}

3.3、min_heap中event的激活

在event_base_loop函数中会调用timeout_process函数去处理定时器中超时的event,该函数定义如下:

static void
timeout_process(struct event_base *base)
{
	/* Caller must hold lock. */
	struct timeval now;
	struct event *ev;

	if (min_heap_empty_(&base->timeheap)) {
		return;
	}

	gettime(base, &now);

	while ((ev = min_heap_top_(&base->timeheap))) {
		/* 比较定时器堆顶的event是否超时,如果没有超时说明定时器中没有event超时 */
		if (evutil_timercmp(&ev->ev_timeout, &now, >))
			break;

		/* delete this event from the I/O queues */
		event_del_nolock_(ev, EVENT_DEL_NOBLOCK);

		event_debug(("timeout_process: event: %p, call %p",
			 ev, ev->ev_callback));
		/* 按超时激活类型添加到激活队列中 */
		event_active_nolock_(ev, EV_TIMEOUT, 1);
	}
}

定时器min_heap中event的激活是通过timeout_process函数实现的,在该函数中会把已经超时的event从定时器中删除,并且把该event添加到激活队列中。接着只需要处理激活队列,就可以执行这些超时的event对应的回调函数。

4、公共超时事件

4.1、超时时间相关的概念

1)超时时间:是一个绝对超时时间,即从1970年1月1日到当前的某个时间点经过的时间
2)超时时长:是一个相对超时时间,例如:设置的超时时长是2秒,即在2秒后会超时
3)公共超时时间:带有COMMON_TIMEOUT_MAGIC标志(Libevent定义的一个宏)的一个超时时间

4.2、common_timeout的作用

common_timeout把base中所有拥有共同点的event放在了一起,而这个所谓的“共同点”就是指超时时长相同,这些超时时长相同的event,他们的超时时间是不同的。 拥有相同超时时长的所有event构成一个链表events,并且让它们按照超时时间的先后按升序排列(即相同超时时长中最先超时的那个event放在最前面),而events中设置一个内部使用的timeout_event作为代表,把最先超时的那个event的超时时间添加到timeout_event中,然后把超时事件放到min_heap中,当放到min_heap中的timeout_event超时,就回到events中,从前往后把所有超时的event全部激活。如下:

在这里插入图片描述

4.3、common_timeout的结构定义

在event_base的结构体中,含有以下定义:

struct event_base
{
    ......
 
    /** An array of common_timeout_list* for all of the common timeout
	 * values we know. */
	struct common_timeout_list **common_timeout_queues;   //common_timeout_list *数组,存放不同超时时长的common_timeout_list的指针
	/** The number of entries used in common_timeout_queues */
	int n_common_timeouts;  //common_timeout_queues中实际的元素个数
	/** The total size of common_timeout_queues. */
	int n_common_timeouts_allocated;  //common_timeout_queues的容量
    ......
}

在event_base中,common_timeout_queues是一个common_timeout_list *类型的指针数组,其中每个元素都指向一个common_timeout_list,common_timeout_list的定义如下:

//event-internal.h
struct common_timeout_list {
	/* List of events currently waiting in the queue. */
	struct event_list events;  //event的双向链表
	/* 'magic' timeval used to indicate the duration of events in this
	 * queue. */
	struct timeval duration;  //该common_timeout_list的超时时长,events双向链表中的所有event都是相同的超时时长
	/* Event that triggers whenever one of the events in the queue is
	 * ready to activate */
	struct event timeout_event;  //“event代表”,最终只有这个event实际插到了min_heap中
	/* The event_base that this timeout list is part of */
	struct event_base *base;  //该common_timeout_list所在的event_base
};
 
//event_struct.h
 
TAILQ_HEAD (event_list, event);  //由event组成的双向链表

4.4、common_timeout与一般timeout的区分

对于一个timeval超时结构体来说,它有两个成员,一个数tv_sec用来指明超时时间中的秒数,一个就是tv_usec用来指明超时时间中的微秒数。由于微秒的数值范围只能是0~999999,而tv_usec的变量类型实际上是32位的,能表示的数值范围远远大于999999,因此用低20位足以来表示timeval中的tv_usec,这样一来,tv_usec的高12位就是没有使用的。而libevent中则是通过这高12位来区分一个timeval超时结构体是common_timeout还是普通的timeout。有如下定义:

//event-internal.h
#define COMMON_TIMEOUT_MICROSECONDS_MASK       0x000fffff   //取低20位掩码
 
//event.c
#define MICROSECONDS_MASK       COMMON_TIMEOUT_MICROSECONDS_MASK   //取低20位,即微秒超时时长
#define COMMON_TIMEOUT_IDX_MASK 0x0ff00000   //20~27bit为该超时时长在common_timeout_queues中的位置
#define COMMON_TIMEOUT_IDX_SHIFT 20          //微秒最大为999999,因此用低20位存储即可,高12位中前4位标识是否为common_timeout 
#define COMMON_TIMEOUT_MASK     0xf0000000   //取高四位掩码
#define COMMON_TIMEOUT_MAGIC    0x50000000   //高四位标志是否为common timeout

1)timeval结构体中的tv_usec由32位表示,而实际上微秒的数值只需低20位即可表示,因此,tv_usec & MICROSECONDS_MASK 即可得到低20位的值,也就是实际的微秒数值。
2)对于高12位来说,tv_usec的高4位用来判断一个这个timeval是否是common_timeout,用 tv_usec & COMMON_TIMEOUT_MASK可以获取高4位的值,如果高4位是COMMON_TIMEOUT_MAGIC(即0x5000000),说明这个timeval是一个common_timeout,否则就表示这个timeval只是一个普通的超时时间。
3)如果是common_timeout,那么这个event是放在base的common_timeout_queues某一项(如common_timeout_queues[i])所对应的common_timeout_list中的,而tv_usec剩下的20~27bit则用来表示这个common_timeout所在的common_timeout_list在common_timeout_queues数组中的索引。例如:如果tv_usec的20-27bit为00000101,则说明这个tv_usec对应的common_timeout_list放在common_timeout_queues[5]下面

4.5、获取common_timeout在common_timeout_queues中的下标

通过下面的宏可以获取common_timeout对应的下标,将tv_usec与COMMON_TIMEOUT_IDX_MASK按位与,屏蔽掉tv_usec除2027bit以外的其他位,得到的结果再右移20位得到的结果就是原来的tv_usec的2027bit的值,这个值就是common_timeout对应的common_timeout_list对应在common_timeout_queues中的下标。宏定义如下:

#define COMMON_TIMEOUT_IDX(tv) \ //获取tv所在的common_timeout_list在common_timeout_queues中的位置
	(((tv)->tv_usec & COMMON_TIMEOUT_IDX_MASK)>>COMMON_TIMEOUT_IDX_SHIFT)//20~27bit右移20位得到下标

4.6、判断一个timeval是否为common_timeout

调用下面的函数可以判断一个超时时间是否是一个common_timeout。 该函数先取出tv_usec的高四位来检查传入的timeval是否为common_timeout,在此基础上还要去判断tv_usec的20~27bit所对应的下标是否合法,因为有可能存在高四位表明为common_timeout,但是20-27bit是非法的情况。

static inline int
is_common_timeout(const struct timeval *tv,
    const struct event_base *base)
{
	int idx;
	if ((tv->tv_usec & COMMON_TIMEOUT_MASK) != COMMON_TIMEOUT_MAGIC)//取高4位,COMMON_TIMEOUT_MAGIC说明它是一个common timeout,如果高四位不等于COMMON_TIMEOUT_MAGIC,那么就不是commontimeout
		return 0;
	idx = COMMON_TIMEOUT_IDX(tv);
	return idx < base->n_common_timeouts; //下标必须小于 base中common_timeout_queues的实际元素个数
}

4.7、创建一个common_timeout

调用event_base_init_common_timeout接口可以创建一个common_timeout,下面是接口定义:

const struct timeval *
event_base_init_common_timeout(struct event_base *base,
    const struct timeval *duration) //查看base中是否有duration相应的common_timeout_list,如果没有就分配一个,并且将新分配中的timeout_event进行设置回调函数。传入的duration既可以是带掩码的也可以是不带掩码的,返回的是相应的common_timeout_list的duration
{
	int i;
	struct timeval tv;
	const struct timeval *result=NULL;
	struct common_timeout_list *new_ctl;
 
	EVBASE_ACQUIRE_LOCK(base, th_base_lock);
	if (duration->tv_usec > 1000000) { //微秒最大值应该是999999,如果超过了1000000,要么它是一个common_timeout,就取出实际的超时时间,否则就把微秒进位到秒上去
		memcpy(&tv, duration, sizeof(struct timeval));
		if (is_common_timeout(duration, base))
			tv.tv_usec &= MICROSECONDS_MASK;
		tv.tv_sec += tv.tv_usec / 1000000;
		tv.tv_usec %= 1000000;
		duration = &tv;   //更新duration的实际时长
	}
	for (i = 0; i < base->n_common_timeouts; ++i) { //遍历现在有的common_timeout_list,查看是否存在超时时长等于duration的list
		const struct common_timeout_list *ctl =
		    base->common_timeout_queues[i];
		if (duration->tv_sec == ctl->duration.tv_sec &&
		    duration->tv_usec ==
		    (ctl->duration.tv_usec & MICROSECONDS_MASK)) {
			EVUTIL_ASSERT(is_common_timeout(&ctl->duration, base));
			result = &ctl->duration; //如果存在duration等于传入的参数的common_timeout_list,那么就把这个common_timeout_list的duration存到result中返回即可。
			goto done;
		}
	}
	
	if (base->n_common_timeouts == MAX_COMMON_TIMEOUTS) {
		event_warnx("%s: Too many common timeouts already in use; "
		    "we only support %d per event_base", __func__,
		    MAX_COMMON_TIMEOUTS);
		goto done;
	}
	if (base->n_common_timeouts_allocated == base->n_common_timeouts) { //如果base中的common_timeout_list分配满了
		int n = base->n_common_timeouts < 16 ? 16 : //如果少于16则分配16的容量,否则容量加倍
		    base->n_common_timeouts*2;
		struct common_timeout_list **newqueues =
		    mm_realloc(base->common_timeout_queues,
			n*sizeof(struct common_timeout_queue *));//重新分配common_timeout_queues的空间大小
		if (!newqueues) {
			event_warn("%s: realloc",__func__);
			goto done;
		}
		base->n_common_timeouts_allocated = n;  //更新common_timeout_queues地址及其容量
		base->common_timeout_queues = newqueues;
	}
    //执行到这里说明没有common_timeout_list的duration等于传入的参数duration
	new_ctl = mm_calloc(1, sizeof(struct common_timeout_list));   //新分配一个common_timeout_list
	if (!new_ctl) {
		event_warn("%s: calloc",__func__);
		goto done;
	}
	TAILQ_INIT(&new_ctl->events); //初始化该duration对应的events链表为空
	new_ctl->duration.tv_sec = duration->tv_sec;
	new_ctl->duration.tv_usec =
	    duration->tv_usec | COMMON_TIMEOUT_MAGIC |
	    (base->n_common_timeouts << COMMON_TIMEOUT_IDX_SHIFT);  //把微秒转换为为带掩码、并且添上下标位
	evtimer_assign(&new_ctl->timeout_event, base,
	    common_timeout_callback, new_ctl); //给新分配的common_timeout_list中的timeout_event注册信息,回调函数为common_timeout_callback
	new_ctl->timeout_event.ev_flags |= EVLIST_INTERNAL;  //标志为内部使用的event
	event_priority_set(&new_ctl->timeout_event, 0);  //设置timeout_event的优先级为0
	new_ctl->base = base;
	base->common_timeout_queues[base->n_common_timeouts++] = new_ctl; //在common_timeout_queues现有元素的最后加上新创建的common_timeout_list
	result = &new_ctl->duration;  //result保存common_timeout_list的duration
 
done:
	if (result)
		EVUTIL_ASSERT(is_common_timeout(result, base));
 
	EVBASE_RELEASE_LOCK(base, th_base_lock);
	return result;  //返回的result是已经设置过相应标志位的common_timeout,也就是指定了超时时长为duration的common_timeout,之后就可以用result作为参数调用event_add,就可以把event添加到相应的common_timeout_list中
}

4.8、激活common_timeout对应的event

被插入到heap中的timeout_event设置的回调函数是common_timeout_callback,当min_heap中的timeout_event发生超时而激活后,就会直接去调用common_timeout_callback,该函数定义如下:

static void
common_timeout_callback(evutil_socket_t fd, short what, void *arg)
{
	struct timeval now;
	struct common_timeout_list *ctl = arg;  //传入的参数是event所在的那个common_timeout_list
	struct event_base *base = ctl->base;
	struct event *ev = NULL;
	EVBASE_ACQUIRE_LOCK(base, th_base_lock);
	gettime(base, &now);  //获取系统时间
	while (1) {
		ev = TAILQ_FIRST(&ctl->events); //遍历这个common_timeout_list中的所有event,如果有超时的就添加到激活队列中
		if (!ev || ev->ev_timeout.tv_sec > now.tv_sec ||
		    (ev->ev_timeout.tv_sec == now.tv_sec &&
			(ev->ev_timeout.tv_usec&MICROSECONDS_MASK) > now.tv_usec))
			break;
		event_del_internal(ev);
		event_active_nolock(ev, EV_TIMEOUT, 1);
	}
	if (ev) //此时的ev如果不为空,那么它就是未来最先超时的那个event
		common_timeout_schedule(ctl, &now, ev); //重新将这个event的超时时间加上common_timeout_callback添加到min_heap中
	EVBASE_RELEASE_LOCK(base, th_base_lock);
}

common_timeout_callback函数的作用就是遍历common_timeout_list中的event,激活所有超时的event,并且根据未来最先超时的那个event重新设置一个新的“代表”timeout_event插入到min_heap中。

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

java 多线程基础 万字详解(通俗易懂)

目录 一、前言 二、定义 1.进程 : 2.线程 : 3.单线程与多线程 : 4.并发与并行 : 三、线程的创建 1.创建线程的两种基本方式 : 1 继承Thread类&#xff0c;并重写run方法 1.5 多线程的执行机制(重要) 2 实现Runnable接口&#xff0c;并重写run方法 2. 两种创建线程方式…

【C++】继承---下(子类默认成员函数、虚继承对象模型的详解等)

前言&#xff1a; 上篇文章我们一起初步了解了继承的概念和使用&#xff0c;本章我们回家新一步深入探讨继承更深层次的内容。 前文回顾——>继承---上 目录 &#xff08;一&#xff09;派生类的默认成员函数 &#xff08;1&#xff09;6个默认成员函数 &#xff08;…

Pytorch全连接神经网络实现手写数字识别

问题Mnist手写数字识别数据集作为一个常见数据集&#xff0c;包含10个类别&#xff0c;在此次深度学习的过程中&#xff0c;我们通过pytorch提供的库函数&#xff0c;运用全连接神经网络实现手写数字的识别方法设置参数input_size 784hidden_size 500output_size 10num_epoc…

JavaScript对象类型之function

目录 一、Function 定义函数 调用函数 默认参数 匿名函数 箭头函数 二、函数是对象 三、函数作用域 四、闭包 五、let、var与作用域 一、Function 定义函数 function 函数名(参数) {// 函数体return 结果; } 例如&#xff1a; function add(a, b) {return a b; …

应届生通过Java培训班转行IT有前途吗?

借用邓小平同志曾说过的一句话&#xff1a;科学技术是第一生产力。IT行业作为科技行业中的一员&#xff0c;不管是在自身的发展&#xff0c;还是支持其他行业的发展中都扮演了不可或缺的角色&#xff0c;“互联网”是社会发展的趋势&#xff0c;前途是无限的。而计算机语言是目…

dolphinscheduler之hivecli 任务

hivecli 任务 Hivecli任务说明 dolphinscheduler的hivecli任务是专门执行hivesql的任务类型。其中子类型分为FROM_SCRIPT和FROM_FILE。 FROM_SCRIPT 执行的脚本可以直接在文本框中编写 执行的底层采用-e参数执行 hive -e "show databases;show tables"FROM_FILE…

建造者模式解读

目录 话题引进 传统方式解决盖房需求 传统方式的问题分析 建造者模式基本介绍 基本介绍 四个角色 原理类图 ​编辑 应用实例 改进代码 建造者模式在 JDK 的应用和源码分析 建造者模式的注意事项和细节 抽象工厂模式 VS 建造者模式 话题引进 1) 需要建房子&#xff1a;…

剑指 Offer (第 2 版)

&#xff08;简单&#xff09;剑指 Offer 03. 数组中重复的数字 找出数组中重复的数字。 在一个长度为 n 的数组 nums 里的所有数字都在 0&#xff5e;n-1 的范围内。数组中某些数字是重复的&#xff0c;但不知道有几个数字重复了&#xff0c;也不知道每个数字重复了几次。请…

Python实现采集某二手房源数据并做数据可视化展示

目录环境介绍&#xff1a;模块使用:实现爬虫思路&#xff1a;代码环境介绍&#xff1a; Python 3.8Pycharm 模块使用: requests >>> pip install requests 数据请求模块 parsel >>> pip install parsel 数据解析模块 csv 内置模块 实现爬虫思路&#x…

如何搭建自己的V Rising自建服务器,以及常见的V Rising服务器问题解决方案

V rising官方服务器经常无法连接&#xff0c;无法和小伙伴玩耍&#xff1b;如何搭建自己的V rising服务器呢&#xff1f;还可以修改掉落倍率&#xff0c;加快游戏进度&#xff0c;搭建自己的私人服务器。 前言 最近V rising这个游戏很火呀&#xff0c;迫不及待地和小伙伴一起…

基于粒子群优化算法的面向综合能源园区的三方市场主体非合作交易方法(Matlab代码实现)

&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️&#x1f4a5;&#x1f4a5; &#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清晰&#xff0c;为了方便读者。 ⛳️座右铭&a…

【JSP学习笔记】4.JSP 隐式对象及客户端请求

前言 本章介绍JSP的隐式对象及客户端请求。 JSP 隐式对象 JSP隐式对象是JSP容器为每个页面提供的Java对象&#xff0c;开发者可以直接使用它们而不用显式声明。JSP隐式对象也被称为预定义变量。 JSP所支持的九大隐式对象&#xff1a; 对象描述requestHttpServletRequest 接…

一文吃透Arthas常用命令!

Arthas 常用命令 简介 Arthas 是Alibaba开源的Java诊断工具&#xff0c;动态跟踪Java代码&#xff1b;实时监控JVM状态&#xff0c;可以在不中断程序执行的情况下轻松完成JVM相关问题排查工作 。支持JDK 6&#xff0c;支持Linux/Mac/Windows。这个工具真的很好用&#xff0c;…

【C++】模板进阶--非类型模板参数模板特化及分离编译

文章目录一、非类型模板参数二、模板的特化1.模板特化的概念2.函数模板的特化3.类模板的特化3.1 全特化3.2 偏特化4.类模板特化应用示例三、模板的分离编译四、模板总结一、非类型模板参数 模板参数分为类型形参与非类型形参&#xff0c;其中&#xff0c;类型形参即出现在模板…

MBD-PMSM闭环控制模型(FOC算法)

目录 前面 Speed_and_Position_Estimator 获取HALL信号 HALL状态更新 计算转速 位置判断 ADC相电流/总线电流电压 获取AD值 计算实际值 低速高速切换 SlowLoopControl FastLoopControl 最后 前面 前面分析了BLDC的开环与闭环&#xff0c;接下来分析PMSM或者说FOC…

MySQL 异步复制、半同步复制、增强半同步复制(史上最全)

背景&#xff1a;来自于小伙伴问题 小伙伴的难题&#xff1a; mysql主从同步的时候&#xff0c;半同步和增强半同步是怎样的一个概念&#xff0c;我看网上说的有点不明不白的&#xff0c;也没找到合适的解释。 这里尼恩给大家做一下系统化、体系化的梳理。也一并把这个题目以…

【每天学习一点点】RocketMQ的架构、写数据、高效的数据查询索引、负载均衡

Rocket一、学习目标二、RocketMQ的架构运行图2.1、NameServer2.1.1 为什么需要NameServer2.1.1.1 不可以没有nameserver吗&#xff1f;2.1.2 NameServer需要单独部署吗2.1.3 Nameserver可以动态注册和注销Broker、Topic和Consume 是什么意思2.1.4 可以使用nacos的配置中心替代N…

成本与体验的“非零和博弈”

随着移动互联网和智能终端的普及&#xff0c;越来越多的海内外互联网企业开始发力短视频业务。在短视频用户全球化&#xff0c;短视频产品及内容消费井喷式增长的今天&#xff0c;用户开始逐渐对体验有了越来越高的要求。为了更清晰更流畅地播放&#xff0c;用户播放成本也随着…

TensorFlow GPU不可用,WSL2安装

这个帖子写给23年刚买电脑、系统是win11&#xff0c;tensorflow版本是2.10以上的兄弟们。不符合的可以去看其他答案了。 这是以我三天来的安装经历来写的&#xff0c;希望能给后来的兄弟们减少时间的浪费。 win11&#xff0c;安装的tensorflow的版本都是2.12的&#xff0c;但…

(二)Cmd Markdown 编辑阅读器的使用效果 | 以 Cmd Markdown 编辑阅读器为例

Cmd Markdown 编辑阅读器使用指南 &#xff08;一&#xff09;Cmd Markdown 编辑阅读器的使用示例 | 以 Cmd Markdown 编辑阅读器为例&#xff08;二&#xff09;Cmd Markdown 编辑阅读器的使用效果 | 以 Cmd Markdown 编辑阅读器为例 在 Cmd Markdown 编辑阅读器&#xff08; …