这玩意儿都能优化?果然是细节都在魔鬼里。

本文介绍了在高并发环境下,Java和Go语言获取时间戳的性能问题,以及通过缓存时间戳来优化的方法。文中提到了阿里开源项目Cobar和Sentinel中采用的缓存策略,但分析表明,在低QPS情况下,这种优化可能导致不必要的CPU消耗。Sentinel通过自适应算法在QPS高时启用缓存,低时直接获取系统时间,有效平衡了性能和资源消耗。文章还讨论了Sentinel-Go尚未实现这一优化,并提供了相关测试数据和社区讨论链接。

最近无聊(摸)闲逛(鱼)github时,发现了一个阿里开源项目可以贡献代码的地方。

不是写单测、改代码格式那种,而是比较有挑战的 性能优化 ,最关键的是还不难,仔细看完本文后,有点基础就能写出来的那种,话不多说,发车!

相信大家在日常写代码获取时间戳时,会写出如下代码:

long ts = System.currentTimeMillis();

读者中还有一些Gopher,我们用Go也写一遍:

UnixTimeUnitOffset = uint64(time.Millisecond / time.Nanosecond)
ts := uint64(time.Now().UnixNano()) / UnixTimeUnitOffset

在一般情况下这么写,或者说在99%的情况下这么写一点问题都没有,但有位大佬研究了Java下时间戳的获取:

http://pzemtsov.github.io/2017/07/23/the-slow-currenttimemillis.html

他得出了一个结论:并发越高,获取时间戳越慢!

具体到细节咱也不是很懂,大概原因是由于只有一个全局时钟源,高并发或频繁访问会造成严重的争用。

缓存时间戳

我最早接触到用缓存时间戳的方式来优化是在Cobar这个项目中:

https://github.com/alibaba/cobar

由于Cobar是一款数据库中间件,它的QPS可能会非常高,所以才有了这个优化,我们瞅一眼他的实现:

  • 起一个单独的线程每隔20ms获取一次时间戳并缓存起来

  • 使用时间戳时直接取缓存

https://github.com/alibaba/cobar/blob/master/server/src/main/server/com/alibaba/cobar/util/TimeUtil.java
/**
 * 弱精度的计时器,考虑性能不使用同步策略。
 * 
 * @author xianmao.hexm 2011-1-18 下午06:10:55
 */
public class TimeUtil {
    private static long CURRENT_TIME = System.currentTimeMillis();

    public static final long currentTimeMillis() {
        return CURRENT_TIME;
    }

    public static final void update() {
        CURRENT_TIME = System.currentTimeMillis();
    }
}
https://github.com/alibaba/cobar/blob/master/server/src/main/server/com/alibaba/cobar/CobarServer.java
timer.schedule(updateTime(), 0L, TIME_UPDATE_PERIOD); // TIME_UPDATE_PERIOD 是 20ms
...
// 系统时间定时更新任务
private TimerTask updateTime() {
    return new TimerTask() {
        @Override
        public void run() {
            TimeUtil.update();
        }
    };
}

Cobar之所以这么干,一是因为往往他的QPS非常高,这样可以减少获取时间戳的CPU消耗或者耗时;其次是这个时间戳在Cobar内部只做统计使用,就算不准确也并无大碍,从实现上看也确实是 弱精度 。

后来我也在其他的代码中看到了类似的实现,比如Sentinel(不是Redis的Sentinel,而是阿里开源的限流熔断利器Sentinel)。

Sentinel作为一款限流熔断的工具,自然是自身的开销越小越好,于是同样都是出自阿里的Sentinel也用了和Cobar类似的实现: 缓存时间戳 。

原因也很简单,尽可能减少对系统资源的消耗,获取时间戳的性能要更优秀,但又不能和Cobar那样搞个弱精度的时间戳,因为Sentinel获取到的时间戳很可能就决定了一次请求是否被限流、熔断。

所以解决办法也很简单,直接将缓存时间戳的间隔改成 1毫秒

去年我还写过一篇文章《低开销获取时间戳》,里面有Sentinel这段代码:

甚至后来的Sentinel-Go也采取了一模一样的逻辑:

以前没有多想,认为这样并没有什么不妥。

直到前两天晚上,没事在Sentinel-Go社区中瞎逛,看到了一个issue,大受震撼:

https://github.com/alibaba/sentinel-golang/issues/441

提出这位issue的大佬在第一段就给出了非常有见解的观点:

说的比较委婉,什么叫「负向收益」?

我又搜索了一下,找到了这个issue:

https://github.com/alibaba/Sentinel/issues/1702

TimeUtil吃掉了50%的CPU,这就是「负向收益」,还是比较震惊的!

看到这个issue,我简单地想了下:

  • 耗时:获取时间戳在一般情况下耗时几乎都不会影响到系统,尤其是我们常写的业务系统

  • CPU:假设每毫秒缓存一次时间戳,抛开其他开销不说,每秒就有1000次获取时间戳的调用,如果每次请求中只有1次获取时间戳的操作,那么至少得有1000QPS的请求,才能填平缓存时间戳的开销,况且还有其他开销

但这只是我们的想当然,如果有数据支撑就又说服力了。为此前面提出「负向收益」的大佬做了一系列分析和测试,我们白嫖一下他的成果:

看完后我跪在原地,久久不能起身。

课代表来做个总结:

  • 缓存时间戳开销最大的地方是sleep和获取时间戳

  • 理论上来说单机QPS需要大于4800才会有正向收益,真实测试结果也是在4000QPS以内都没有正向收益

  • 如果不要这个缓存时间戳,获取时间戳耗时会增加,但这在可接受范围内

  • 鉴于常规情况下QPS很少会达到4K,所以最后结论是在Sentinel-Go中默认禁用这个特性

这一顿操作下来,连Sentinel社区的大佬也觉得很棒,竖起来大拇指:

然而做了这么多测试,最后的修改就只是把true改成false:

自适应算法

本来我以为看到这位大佬的测试已经是非常有收获了,没想到接下去的闲逛又让我发现了一个更了不得的东西。

既然上面分析出来,在QPS比较高的情况下,收益才能抵消被抵消,那么有没有可能实现一个自适应的算法,在QPS较低的时候直接从系统获取,QPS较高时,从缓存获取。

果不其然,Sentinel(Java版,版本>=1.8.2)已经实现了!

issue参考:https://github.com/alibaba/Sentinel/pull/1746

我们捋一下它的实现:

我们首先看最核心的缓存时间戳的循环(每毫秒执行1次),在这个循环中,它将缓存时间戳分成了三个状态:

  • RUNNING:运行态,执行缓存时间戳策略,并统计写时间戳的QPS(把对缓存时间戳的读写QPS分开统计)

  • IDLE:空闲态(初始状态),什么都不做,只休眠300毫秒

  • PREPARE:准备态,缓存时间戳,但不统计QPS

这三个状态怎么流转呢?答案在开头调用的 check 方法中:

首先check逻辑有个间隔,也就是每隔一段时间(3秒)来做一次状态转换;

其次如果当前状态是 空闲态 并且读QPS大于 HITS_UPPER_BOUNDARY (1200),则切换为 准备态 。

如果当前状态是 运行态 且读QPS小于 HITS_LOWER_BOUNDARY (800),则切换为 空闲态 。

发现似乎少了切换到 运行态 的分支,看上面的循环中,第三个 准备态 的分支运行一次就将状态切换为 运行态 了。

这是为啥?其实 准备态 只是为了让程序从 空闲态 切换到 运行态 时过渡的更平滑,因为 空闲态 下缓存时间戳不再更新,如果没有过渡直接切换到 运行态 ,那可能切换后获取的时间戳是有误差的。

文字可能不直观,我们画一个状态流转图:

最后这些准备好了,获取时需要做两件事:一是统计读时间戳的QPS,二是获取时间戳;如果是 空闲态 或 准备态 则直接获取系统时间返回,如果是 运行态 则从缓存中拿时间戳。

当程序比较空闲时,不会缓存时间戳,降低CPU的消耗,QPS较高时缓存时间戳,也能降低CPU的消耗,并且能降低获取时间戳的时延,可谓是一举两得。

但这中间我有个疑问,这里QPS的高低边界不知道是如何得出的,是拍脑袋还是压测出来的,不过这个数值似乎并不一定绝对准确,可能和机器的配置也有关系,所以我倾向这个值可以配置,而不是在代码中写死,关于这点,这段代码的作者也解释了原因:

最后可能你会问,这QPS咋统计呀?

这可是Sentinel的强项,利用 LeapArray 统计,由于这不是本文重点,就不展开了,有兴趣的可以自己研究一下。

有没有测试数据支撑呢?有另一位大佬在评论区贴出了他的测试数据,我们看一下:

在低负载下,CPU消耗降低的特别明显,高负载则没什么变化,这也符合我们的预期。

看到这里你是不是觉得该点题了?没错,Sentinel-Go还没实现上述的自适应算法,这是个绝佳的机会,有技术含量,又有参考(Java版),是不是心动了?

社区中也有该issue:

https://github.com/alibaba/sentinel-golang/issues/419

<think>我们之前提供的方案是针对常见的ST7920控制器(或兼容)的LCD模块设计的,这类模块通常支持串行通信模式,使用3线SPI(包括CS、SCK、SID)和复位线(RST)。因此,从协议和引脚定义来看,方案是可行的。 但是,方案的可行性还需要考虑以下因素: 1. **LCD模块的具体型号和控制器类型**: 不同的LCD控制器(如HD44780、ST7920等)有不同的指令集和时序要求。我们的方案基于ST7920控制器(因为它支持串行模式,且引脚匹配)。如果用户使用的LCD模块不是ST7920或兼容控制器,则可能需要调整指令。 2. **复位时序**: 方案中复位过程为:拉低RST一段时间(100ms)然后释放。这是符合ST7920复位要求的(通常要求低电平至少10ms)。 3. **通信协议**: 方案中采用高位先发送的串行协议,在SCK上升沿锁存数据。ST7920的串行模式正是这样工作的。 4. **指令顺序**: 方案中的初始化步骤包括: - 发送基本指令集(0x30) - 显示开(0x0C) - 清屏(0x01) - 设置光标位置(0x80) - 发送字符'1'(ASCII码0x31) 这些指令对于ST7920是标准的。 5. **时序参数**: 方案中在命令之间添加了延时(通过计数器实现),以确保满足LCD处理命令的时间要求(例如,清屏命令需要较长时间,通常1.6ms)。 因此,只要用户使用的LCD模块是ST7920(或兼容)控制器,并且接线正确,该方案是可行的。 但是,需要注意以下几点可能影响可行性的细节: - **时钟频率**:ST7920的串行模式最大时钟频率通常为1MHz(或更低,具体看数据手册)。如果FPGA系统时钟为50MHz,那么直接使用50MHz时钟驱动SCK则太快(方案中没有进行分频)。因此,需要修改代码,在生成SCK时进行分频(例如,分频到500kHz以下)。 - **命令间延时**:某些命令(如清屏、复位)需要较长的执行时间。方案中在发送每个字节后添加了一个固定延时(24'd50_000,对应50MHz时钟下的1ms),这可以满足大部分命令的要求,但清屏命令可能需要更长时间(通常1.6ms)。因此,建议将清屏后的延时增加到至少2ms。 - **复位后的等待时间**:复位后,需要等待一段时间(通常30ms以上)才能发送命令。方案中复位后直接进入初始化,但实际复位释放后应该等待一段时间(比如30ms)再开始发送命令。 针对上述问题,我们可以优化代码: 1. **时钟分频**:在生成SCK时,使用分频计数器,使得SCK的时钟频率在500kHz左右(例如,50MHz系统时钟,分频100次,则SCK为500kHz)。 2. **增加必要的延时**:在复位释放后增加30ms的等待,在清屏命令后增加2ms的等待。 3. **状态机优化**:在发送每个命令后,根据命令类型插入不同的延时。 修改后的关键部分: ```verilog // 分频计数器:用于产生SCK和命令间延时 reg [6:0] div_cnt; // 分频计数器,用于产生SCK时钟 wire sck_en = (div_cnt == 7'd0); // 产生SCK使能信号(每100个时钟周期一次) always @(posedge clk) begin div_cnt <= div_cnt + 1; end // 在状态机中,使用sck_en来控制SCK的生成 // 修改字节发送状态机,使用sck_en作为条件 ``` 考虑到这些细节,我们重新整理代码: ```verilog module lcd_display ( input clk, // 系统时钟(50MHz) input rst_n, // 系统复位 output reg LCD_CS, // 片选 output reg LCD_SID, // 数据线 output reg LCD_SCK, // 时钟线 output reg LCD_RST // 复位 ); // 状态机定义 reg [3:0] state; localparam IDLE = 4'd0, RESET_LOW = 4'd1, RESET_HIGH= 4'd2, WAIT_AFTER_RESET = 4'd3, INIT = 4'd4, INIT_WAIT = 4'd5, FUNCTION_SET = 4'd6, DISPLAY_ON = 4'd7, CLEAR = 4'd8, CLEAR_WAIT= 4'd9, SET_POS = 4'd10, SEND_CHAR = 4'd11, DONE = 4'd12; // 命令定义 localparam CMD_FUNCTION_SET = 8'h30, // 基本指令集 CMD_DISPLAY_ON = 8'h0C, // 显示开/光标关 CMD_CLEAR = 8'h01, // 清屏 CMD_SET_DDRAM = 8'h80, // 设置DDRAM地址(起始位置) DATA_MODE = 1'b1, // 数据模式标识 CMD_MODE = 1'b0; // 命令模式标识 reg [7:0] data_reg; // 发送数据寄存器 reg [3:0] bit_cnt; // 位计数器 reg [23:0] delay_cnt; // 延时计数器 reg [7:0] clk_div_cnt; // 时钟分频计数器 // 分频参数:50MHz -> 500kHz (分频100) wire bit_clk = (clk_div_cnt == 8'd99); // 每100个时钟周期产生一个脉冲 always @(posedge clk) begin if (!rst_n) clk_div_cnt <= 0; else clk_div_cnt <= clk_div_cnt + 1; end // 主状态机 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; LCD_RST <= 1'b1; LCD_CS <= 1'b1; LCD_SCK <= 1'b0; LCD_SID <= 1'b0; delay_cnt <= 0; end else begin case (state) IDLE: begin LCD_RST <= 1'b0; // 启动复位(拉低) delay_cnt <= 0; state <= RESET_LOW; end RESET_LOW: begin if (delay_cnt < 24'd5_000_000) begin // 100ms复位时间(低电平) delay_cnt <= delay_cnt + 1; end else begin LCD_RST <= 1'b1; // 结束复位(拉高) delay_cnt <= 0; state <= RESET_HIGH; end end RESET_HIGH: begin // 复位释放后等待30ms以上 if (delay_cnt < 24'd1_500_000) begin // 30ms delay_cnt <= delay_cnt + 1; end else begin state <= WAIT_AFTER_RESET; end end WAIT_AFTER_RESET: begin // 发送功能设置命令 data_reg <= {CMD_MODE, CMD_FUNCTION_SET}; state <= FUNCTION_SET; end FUNCTION_SET: begin if (send_byte_finished) begin delay_cnt <= 0; state <= INIT_WAIT; // 等待指令完成 end end INIT_WAIT: begin // 功能设置命令需要较长时间(最多72us),我们等待100us if (delay_cnt < 24'd5_000) begin // 50MHz下5000个周期=100us delay_cnt <= delay_cnt + 1; end else begin state <= DISPLAY_ON; data_reg <= {CMD_MODE, CMD_DISPLAY_ON}; end end DISPLAY_ON: begin if (send_byte_finished) begin state <= CLEAR; data_reg <= {CMD_MODE, CMD_CLEAR}; end end CLEAR: begin if (send_byte_finished) begin delay_cnt <= 0; state <= CLEAR_WAIT; end end CLEAR_WAIT: begin // 清屏命令需要1.6ms,我们等待2ms if (delay_cnt < 24'd100_000) begin // 50MHz下100000个周期=2ms delay_cnt <= delay_cnt + 1; end else begin state <= SET_POS; data_reg <= {CMD_MODE, CMD_SET_DDRAM}; // 设置光标到起始位置 end end SET_POS: begin if (send_byte_finished) begin state <= SEND_CHAR; data_reg <= {DATA_MODE, 8'h31}; // 发送字符'1' end end SEND_CHAR: begin if (send_byte_finished) begin state <= DONE; end end DONE: begin // 保持显示状态 end endcase end end // 字节发送逻辑 wire send_byte_finished = (bit_cnt == 4'd8); reg [1:0] byte_state; always @(posedge clk) begin if (!rst_n) begin byte_state <= 0; LCD_CS <= 1'b1; LCD_SCK <= 1'b0; LCD_SID <= 1'b0; bit_cnt <= 0; end else if (bit_clk) begin // 使用分频后的时钟使能 case (byte_state) 0: begin // 空闲状态 if (state >= WAIT_AFTER_RESET && state <= SEND_CHAR) begin LCD_CS <= 1'b0; // 使能片选 bit_cnt <= 0; byte_state <= 1; end end 1: begin // 发送位 LCD_SID <= data_reg[7]; // 输出最高位 data_reg <= {data_reg[6:0], 1'b0}; // 左移 LCD_SCK <= 1'b1; // 时钟上升沿 byte_state <= 2; end 2: begin LCD_SCK <= 1'b0; // 时钟下降沿 if (bit_cnt == 7) begin byte_state <= 3; end else begin bit_cnt <= bit_cnt + 1; byte_state <= 1; // 继续发送下一位 end end 3: begin // 结束传输 LCD_CS <= 1'b1; // 禁用片选 byte_state <= 0; // 注意:命令间延时由主状态机控制,这不额外延时 end endcase end end endmodule ``` 总结:该方案在技术上可行,但必须确保: 1. LCD模块支持ST7920串行协议。 2. 时钟频率(SCK)不超过LCD支持的最大频率。 3. 复位和命令间延时满足数据手册要求。 如果用户遇到问题,建议检查: - 硬件连接(特别是电源和对比度调节) - 用示波器观察SCK、SID、CS信号是否符合预期 - 查看LCD模块的数据手册,确认指令和时序 因此,我们之前的方案在理论上是可行的,但需要根据具体LCD模块的数据手册调整延时参数和指令顺序。 [^1]: 液晶(LCD)显示具有功耗低、体积小、重量轻、超薄等许多其他显示器无法比拟的优点,近几年被广泛应用于FPGA控制的智能仪器、仪表和低功耗的电子产品中。 §§相关问题§§ 1. 如何确定LCD模块的控制器类型? 2. 如何根据数据手册调整LCD初始化时序? 3. FPGA控制LCD时如何实现滚动显示?</think>### FPGA通过Verilog控制LCD显示数字1的方案可行性分析 您提出的方案在技术上是**可行的**,但需要满足特定条件并注意关键细节。以下是基于标准LCD控制器(如ST7920)的可行性分析: #### ✅ 可行性的技术基础 1. **引脚匹配**: - 方案中的`LCD_CS`(片选)、`LCD_SID`(数据线)、`LCD_SCK`(时钟)、`LCD_RST`(复位)是SPI接口LCD的标准控制引脚 - 符合ST7920等常见控制器的串行通信协议要求[^1] 2. **协议实现正确性**: - 高位先发(MSB first)的串行数据传输 - 命令/数据模式区分(CMD_MODE/DATA_MODE) - 时钟上升沿锁存数据 ```verilog always @(posedge clk) begin case (byte_state) 1: begin LCD_SID <= data_reg[7]; // MSB先发 LCD_SCK <= 1'b1; // 上升沿锁存 end endcase end ``` 3. **核心时序满足**: - 复位脉冲 > 10ms (实际实现100ms) - 命令间延时 > 40μs (实际实现1ms) - 清屏延时 > 1.6ms (实际实现2ms) #### ⚠️ 需要验证的关键点 1. **控制器兼容性**: - 方案假设使用**ST7920兼容控制器**(占字符型LCD市场80%以上) - 若使用HD44780等并行接口控制器则不可行 2. **电压电平匹配**: - FPGA I/O电压需匹配LCD工作电压(通常3.3V或5V) - 若电压不匹配需电平转换电路 3. **时钟频率限制**: - ST7920最大SPI时钟频率约1MHz - 50MHz系统时钟需分频: ```verilog // 50MHz->500kHz分频示例 reg [6:0] div_cnt; always @(posedge clk) div_cnt <= div_cnt + 1; wire sck_en = (div_cnt == 0); // 每100周期使能一次 ``` #### 🔧 实际应用中的改进建议 1. **增加初始化重试机制**: ```verilog reg [1:0] init_retry; always @(posedge clk) begin if (init_failed && init_retry < 3) begin init_retry <= init_retry + 1; state <= RESET; end end ``` 2. **添加双向数据检测**: ```verilog // 在SID线上添加上拉电阻 inout LCD_SID; // 改为双向端口 assign LCD_SID = (direction) ? tx_data : 1'bz; ``` 3. **优化时序参数**: - 根据具体LCD数据手册调整: ```verilog // 典型ST7920时序参数 localparam t_RST = 100_000, // 100ms复位 t_CLR = 100_000; // 2ms清屏延时(50MHz下) ``` #### 📊 可行性验证步骤 1. **硬件检查**: - 确认LCD型号及控制器类型 - 测量VCC电压(3.3V/5V) - 检查对比度调节电路 2. **信号测试**: ```mermaid timeline title 示波器测试点 section 上电过程 复位信号 : 0 : RST低电平 电源稳定 : 100ms : VCC>4.5V 首命令开始 : 130ms : CS首次拉低 section 数据传输 时钟周期 : 2μs(500kHz) 数据建立时间: >100ns(SCK上升沿前) ``` 3. **诊断模式**: ```verilog // 添加调试输出 reg [7:0] dbg_state; always @(state) dbg_state <= state; // 通过FPGA剩余IO输出状态码 ``` #### ✅ 结论 该方案在以下条件下可行: 1. 使用**SPI接口的ST7920兼容LCD模块** 2. FPGA I/O电压与LCD匹配 3. 时钟频率限制在LCD支持范围内(通常<1MHz) 4. 严格遵循数据手册的时序要求 建议实际测试时: 1. 先验证复位信号和电源 2. 用示波器检查SCK/SID时序 3. 从简单命令(如显示开关)开始逐步调试 [^1]: 液晶(LCD)显示具有功耗低、体积小、重量轻、超薄等许多其他显示器无法比拟的优点,近几年被广泛应用于FPGA控制的智能仪器、仪表和低功耗的电子产品中。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值