2024年1月

前言

日常Bug排查系列都是一些简单Bug排查。笔者将在这里介绍一些排查Bug的简单技巧,同时顺便积累素材
_

Bug现场

最近碰到一个产线问题,表现为某个应用集群所有的节点全部下线了。导致上游调用全部报错。而且从时间线分析来看。这个应用的节点是逐步失去响应的。因为请求量较小,直到最后一台也失去响应后,才发现这个集群有问题。

线程逐步耗尽

笔者观察了下监控,发现每台机器的BusyThread从上次发布开始就逐步增长,一直到BusyThread线程数达到200才停止,而这个时间和每台机器从注册中心中摘除的时间相同。看了下代码,其配置的最大处理请求线程数就是200。

查看线程栈

很容易的,我们就想到去观察相关机器的线程栈。发现其所有的的请求处理线程全部Block在com.google.common.util.concurrent.SettableFuture的不同实例上。卡住的堆栈如下所示:


at sun.misc.Unsafe.park (Native Method: )
at java.util.concurrent.locks.LockSupport.park (LockSupport.java: 175)
at com.google.common.util.concurrent.AbstractFuture.get (AbstractFuture.java: 469)
at com.google.common.util.concurrent.AbstractFuture$TrustedFuture.get (AbstractFuture.java: 76)
at com.google.common.util.concurrent.Uninterruptibles.getUninterruptibly (Uninterruptibles.java: 142)
at com.google.common.cache.LocalCache$LoadingValueReference.waitForValue (LocalCache.java: 3661)
at com.google.common.cache.LocalCache$Segment.waitForLoadingValue (LocalCache.java: 2315)
at com.google.common.cache.LocalCache$Segment.get (LocalCache.java: 2202)
at com.google.common.cache.LocalCache.get (LocalCache.java: 4053)
at com.google.common.cache.LocalCache.getOrLoad (LocalCache.java: 4057)
at com.google.common.cache.LocalCache$LocalLoadingCache.get (LocalCache.java: 4986)
at com.google.common.cache.ForwardingLoadingCache.get (ForwardingLoadingCache.java: 45)
at com.google.common.cache.ForwardingLoadingCache.get (ForwardingLoadingCache.java: 45)
at com.google.common.cache.ForwardingLoadingCache.get
......
at com.XXX.business.getCache
......

从GuavaCache获取缓存为什么会被卡住

GuavaCache是一个非常成熟的组件了,为什么会卡住呢?使用的姿势不对?于是,笔者翻了翻使用GuavaCache的源代码。其简化如下:

private void initCache() {
    ExecutorService executor  =  new ThreadPoolExecutor(1, 1,
            60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(50), // 注意这个QueueSize
            new NamedThreadFactory(String.format("cache-reload-%s")),
            (r, e) -> {
                log.warn("cache reload rejected by threadpool!"); // 注意这个reject策略

            });
    this.executorService = MoreExecutors.listeningDecorator(executor);
    cache = CacheBuilder.newBuilder().maximumSize(100) // 注意这个最大值
        .refreshAfterWrite(1, TimeUnit.DAYS).build(new CacheLoader<K, V>() {
            @Override
            public V load(K key) throws Exception {
                return innerLoad(key);
            }

            @Override
            public ListenableFuture<V> reload(K key, V oldValue) throws Exception {
                ListenableFuture<V> task = executorService.submit(() -> {
                    try {
                        return innerLoad(key);
                    } catch (Exception e) {
                        LogUtils.printErrorLog(e, String.format("重新加载缓存失败,key:%s", key));
                        return oldValue;
                    }
                });
                return task;
            }
        });
}

这段代码事实上写的还是不错的,其通过重载reload方法并在加载后段缓存出问题的时候使用old Value。保证了即使获取缓存的后段存储出问题了,依旧不会影响到我们缓存的获取。逻辑如下所示:

那么为什么会卡住呢?一时间看不出什么问题。那么我们就可以从系统的日志中去寻找蛛丝马迹。

日志

对应时间点日志空空如也

对于这种逐渐失去响应的,我们寻找日志的时候一般去寻找源头。也就是第一次出现卡在SettableFuture的时候发生了什么。由于我们做了定时的线程栈采集,那么很容易的,笔者挑了一台机器找到了3天之前第一次发生线程卡住的时候,grep下对应的线程名,只发现了一个请求过来到了这个线程然后卡住了,后面就什么日志都不输出了。

异步缓存的日志

继续回顾上面的代码,代码中缓存的刷新是异步执行的,很有可能是异步执行的时候出错了。再grep异步执行的相关关键词“重新加载缓存失败”,依旧什么都没有。线索又断了。

继续往前追溯

当所有线索都断了的情况下,我们可以翻看时间点前后的整体日志,看下有没有异常的点以获取灵感。往前多翻了一天的日志,然后一条线程池请求被拒绝的日志进入了笔者的视野。

cache reload rejected by threadpool!

看到这条日志的一瞬间,笔者立马就想明白了。GuavaCache的reload请求不是出错了,而是被线程池给丢了。在reload请求完成之后,GuavaCache会对相应的SettableFuture做done的动作以唤醒等待的线程。而由于我们的Reject策略只打印了日志,并没有做done的动作,导致我们请求Cache的线程一直在卡waitForValue上面。如下图所示,左边的是正常情况,右边的是异常情况。

为什么会触发线程池拒绝策略

注意我们初始化线程池的源代码

    ExecutorService executor  =  new ThreadPoolExecutor(1, 1,
            60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(50), // 注意这个QueueSize
            new NamedThreadFactory(String.format("cache-reload-%s")),
            (r, e) -> {
                log.warn("cache reload rejected by threadpool!"); // 注意这个reject策略

            });

这个线程池是个单线程线程池,而且Queue只有50,一旦遇到同时过来的请求大于50个,就很容易触发拒绝策略。

源码分析

好了,这时候我们就可以上一下GuavaCache的源代码了。

   V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {
      checkNotNull(key);
      checkNotNull(loader);
      try {
        if (count != 0) { // read-volatile
          // don't call getLiveEntry, which would ignore loading values
          ReferenceEntry<K, V> e = getEntry(key, hash);
          if (e != null) {
            long now = map.ticker.read();
            V value = getLiveValue(e, now);
            if (value != null) {
              recordRead(e, now);
              statsCounter.recordHits(1);
              // scheduleRefresh中一旦Value不为null,就直接返回旧值
              return scheduleRefresh(e, key, hash, value, now, loader);
            }
            ValueReference<K, V> valueReference = e.getValueReference();
            // 如果当前Value还在loading,则等loading完毕
            if (valueReference.isLoading()) {
            	 // 这次的Bug就一直卡在loadingValue上
              return waitForLoadingValue(e, key, valueReference);
            }
          }
        }

        // at this point e is either null or expired;
        return lockedGetOrLoad(key, hash, loader);
      } catch (ExecutionException ee) {
    	......
    }

为什么没有直接返回oldValue而是卡住

等等,在上面的GuavaCache源代码里面。一旦缓存有值之后,肯定是立马返回了。对应这段代码。

			if (value != null) {
              recordRead(e, now);
              statsCounter.recordHits(1);
              // scheduleRefresh中一旦Value不为null,就直接返回旧值
              return scheduleRefresh(e, key, hash, value, now, loader);
            }

所以就算异步刷新请求被线程池Reject掉了。它也不会讲原来缓存中的值给删掉。业务线程也不应该卡住。那么这个卡住是什么原因呢?为什么缓存中没值没有触发load而是等待valueReference有没有加载完毕呢。
稍加思索之后,笔者就找到了原因,因为上述那段代码中设置了缓存的maxSize。一旦超过maxSize,一部分数据就被驱逐掉了,而如果这部分数据恰好就是线程池Reject掉请求的数据,那么就会进值为空同时需要等待valueReference loading的代码分支,从而造成卡住的现象。让我们看一下源代码:

localCache.put //
 |->evictEntries
  |->removeEntry
   |->removeValueFromChain

ReferenceEntry<K,V> removeValueFromChain(...) {
 ......
 if(valueReference.isLoading()){
  // 设置原值为null
  valueReference.notifyNewValue(null);
  return first;
 }else {
  removeEntryFromChain(first,entry) 
 }
} 

我们看到,代码中valueReference.isLoading()的判断,一旦当前value还处于加载状态中,那么驱逐的时候只会将对应的entry(key)项设置为null而不会删掉。这样,在下次这个key的缓存被访问的时候自然就走到了value为null的分支,然后就看到当前的valueReference还处于loading状态,最后就会等待那个由于被线程reject而永远不会done的future,最后就会导致这个线程卡住。逻辑如下图所示:

什么是逐渐失去响应

因为业务的实际缓存key的项目是大于maxSize的。而一开始系统启动后加载的时候缓存的cache并没有达到最大值,所以这时候被线程池reject掉相应的刷新请求依旧能够返回旧值。但一旦出现了大于缓存cache最大Size的数据导致一些项被驱逐后,只要是一个请求去访问这些缓存项都会被卡住。但很明显的,能够被驱逐出去的旧缓存肯定是不常用的缓存(默认LRU缓存策略),那么就看这个不常用的数据的流量到底是在哪台机器上最多,那么哪台机器就是最先失去响应的了。

总结

虽然这是个较简单的问题,排查的时候也是需要一定的思路的,尤其是发生问题的时间点并往前追溯到第一个不同寻常的日志这个点,这个往往是我们破局的手段。GuavaCache虽然是个使用非常广泛的缓存工具,但不合理的配置依旧会导致灾难性的后果。

Hugging Face是一个机器学习(ML)和数据科学平台和社区,帮助用户构建、部署和训练机器学习模型。它提供基础设施,用于在实时应用中演示、运行和部署人工智能(AI)。用户还可以浏览其他用户上传的模型和数据集。Hugging Face通常被称为机器学习界的GitHub,因为它让开发人员公开分享和测试他们所训练的模型。

本次分享如何快速部署本地训练的 Bert-VITS2 语音模型到 Hugging Face。

本地配置HuggingFace

首先注册HuggingFace平台:

https://huggingface.co/join

随后在用户的设置界面新建token,也就是令牌:

这里令牌有两种权限类型,一种是写权限,另外一种是读权限。

随后本地安装Huggingface客户端:

pip install huggingface_hub

随后运行命令登录Huggingface账号:

huggingface-cli login

此时需要用到刚刚创建的token,复制写token,粘贴到命令行中:

E:\work>huggingface-cli login  
  
    _|    _|  _|    _|    _|_|_|    _|_|_|  _|_|_|  _|      _|    _|_|_|      _|_|_|_|    _|_|      _|_|_|  _|_|_|_|  
    _|    _|  _|    _|  _|        _|          _|    _|_|    _|  _|            _|        _|    _|  _|        _|  
    _|_|_|_|  _|    _|  _|  _|_|  _|  _|_|    _|    _|  _|  _|  _|  _|_|      _|_|_|    _|_|_|_|  _|        _|_|_|  
    _|    _|  _|    _|  _|    _|  _|    _|    _|    _|    _|_|  _|    _|      _|        _|    _|  _|        _|  
    _|    _|    _|_|      _|_|_|    _|_|_|  _|_|_|  _|      _|    _|_|_|      _|        _|    _|    _|_|_|  _|_|_|_|  
  
    A token is already saved on your machine. Run `huggingface-cli whoami` to get more information or `huggingface-cli logout` if you want to log out.  
    Setting a new token will erase the existing one.  
    To login, `huggingface_hub` requires a token generated from https://huggingface.co/settings/tokens .  
Token can be pasted using 'Right-Click'.  
Token:  
Add token as git credential? (Y/n) y  
Token is valid (permission: write).  
Cannot authenticate through git-credential as no helper is defined on your machine.  
You might have to re-authenticate when pushing to the Hugging Face Hub.  
Run the following command in your terminal in case you want to set the 'store' credential helper as default.  
  
git config --global credential.helper store  
  
Read https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage for more details.  
Token has not been saved to git credential helper.  
Your token has been saved to C:\Users\zcxey\.cache\huggingface\token  
Login successful

显示Login successful即代表登录成功。

随后,可以使用命令来创建模型的repo项目:

huggingface-cli repo create wizard3

这里创建巫师3系列角色模型。

程序返回:

E:\work>huggingface-cli repo create wizard3  
git version 2.31.0.windows.1  
git-lfs/2.13.2 (GitHub; windows amd64; go 1.14.13; git fc664697)  
  
You are about to create v3ucn/wizard3  
Proceed? [Y/n] y  
  
Your repo now lives at:  
  https://huggingface.co/v3ucn/wizard3  
  
You can clone it locally with the command below, and commit/push as usual.  
  
  git clone https://huggingface.co/v3ucn/wizard3

说明已经创建好模型项目了。

当然,过程中可能会报443的错误,如果您身在国内,这是十分合理的现象。

此时,可以通过给git配置代理来解决:

配置socks5  
  
git config --global http.proxy socks5 127.0.0.1:7890  
git config --global https.proxy socks5 127.0.0.1:7890  
  
配置http  
  
git config --global http.proxy 127.0.0.1:7890  
git config --global https.proxy 127.0.0.1:7890

其中7890为您在国内学术上网用的端口号,啥叫学术上网?很抱歉这里无法多做解释。

同时也可以通过命令取消git学术上网:

git config --global --unset http.proxy  
git config --global --unset https.proxy

接着本地克隆项目:

git clone https://huggingface.co/v3ucn/wizard3

随后将模型本体和配置文件config.json放入wizard3目录。

提交后,推送即可:

E:\work>cd wizard3  
  
E:\work\wizard3>git add -A  
  
E:\work\wizard3>git commit -m "commit from liuyue "  
[main cd327b9] commit from liuyue  
 2 files changed, 114 insertions(+)  
 create mode 100644 G_200.pth  
 create mode 100644 config.json  
  
E:\work\wizard3>git push  
Uploading LFS objects:   0% (0/1), 925 MB | 2.4 MB/s

此时,git就会把模型推送到Huggingface云端。

推送完毕后,访问线上地址,即可查看模型:

https://huggingface.co/v3ucn/wizard3/tree/main

结语

Hugging Face的优势包括可访问性、集成性、快速原型设计和部署、社区和成本效益,是不可多得的机器学习交流平台。

1、准备材料

开发板(
正点原子stm32f407探索者开发板V2.4

ST-LINK/V2驱动
STM32CubeMX软件(
Version 6.10.0

keil µVision5 IDE(
MDK-Arm

CH340G Windows系统驱动程序(
CH341SER.EXE

XCOM V2.6串口助手
3个滑动变阻器

2、实验目标

使用STM32CubeMX软件配置STM32F407开发板的ADC实现ADC多通道DMA采集,具体为
使用ADC_IN5/6/7三个通道进行DMA连续ADC转换

3、实验流程

3.0、前提知识

“STM32CubeMX教程13 ADC - 单通道转换”实验中提到过,规则通道只有一个16位的数据寄存器,
因此规则通道同时只能转换一个ADC通道
,而且每次转化完一个ADC通道就需要及时从数据寄存器中取出转化的数据,否则会被后面转化完毕的通道数据覆盖

这个时间非常短,
一般不采用像单通道转化中使用中断提取处理每个单通道数据的方法,而是采用DMA连续转化的方法
,将多通道转化完毕之后,在DMA的数据存储中将采集到的所有通道的数据一起处理

ADC是利用片上的模数转换器将外部的模拟量转化为数字量存储到内存中,
数据传输方向应该只有从外设到内存这一种方向
,因此可知ADC的DMA方向也只有外设到内存一种

从“STM32CubeMX DMA 直接内存读取”实验中
可知ADC1的DMA通道有DMA2_Stream0 CH0 和 DMA2_Stream4 CH0 两个通道

ADC的DMA请求模式一般选择循环模式
,在多通道ADC采集时,配合使能扫描转化模式,这样就可以连续转化多通道而不停止

由于ADC采集后的数据一般需要存储在内存中,
因此在选择地址递增时,ADC外设地址不增加,内存地址选择递增

使用HAL_ADC_Start_DMA()以DMA方式启动ADC采集时需要指定存储的内存首地址,从函数的定义可知其为uint32_t*类型,
因此在DMA配置时我们需要选择的数据宽度为字Word

3.1、CubeMX相关配置

3.1.0、工程基本配置

打开STM32CubeMX软件,单击ACCESS TO MCU SELECTOR选择开发板MCU(选择你使用开发板的主控MCU型号),选中MCU型号后单击页面右上角Start Project开始工程,具体如下图所示

开始工程之后在配置主页面System Core/RCC中配置HSE/LSE晶振,在System Core/SYS中配置Debug模式,具体如下图所示

详细工程建立内容读者可以阅读
STM32CubeMX教程1 工程建立

3.1.1、时钟树配置

系统时钟使用8MHz外部高速时钟HSE,HCLK、PCLK1和PCLK2均设置为STM32F407能达到的最高时钟频率,具体如下图所示

3.1.2、外设参数配置

本实验需要需要初始化USART1作为输出信息渠道,具体配置步骤请阅读“
STM32CubeMX教程9 USART/UART 异步通信

设置TIM3通用定时器溢出时间100ms,外部触发事件选择更新事件,参数详解请阅读“
STM32CubeMX教程6 TIM 通用定时器 - 生成PWM波
”实验,具体配置如下图所示

在Pinout & Configuration页面左边功能分类栏目
Analog中单击其中ADC1,勾选IN5/6/7三个通道
,在下方的参数设置中以ADC - 单通道转换实验为模板修改部分参数

Scan Conversion Mode

使能扫描转换模式
,因此现在需要转换5/6/7三个通道,因此使能该模式之后,在规则通道转换为其中一个通道后就会接收转换下一个通道

DMA Continuous Requests

使能DMA连续转换请求
,该参数的使能需要在配置完DMA请求之后才可选,配合参数
Scan Conversion Mode
可以实现连续不间断的对三个通道数据进行采集

End Of Conversion Selection

选择EOC flag at the end of all conversions
,该参数表示当转换完毕一组ADC中的所有通道之后再产生EOC标志,进入中断

Number Of Conversion

规则通道转换数量现在为3
,对应三个不同的通道,通道转换顺序及每个通道的采样时间由Rank及其下参数决定

具体参数配置如下图所示

单击Configuration中的DMA Settings选项卡对ADC1的DMA请求进行设置,单击ADD按键增加DMA请求,这里可选的只有一个ADC1

选择想要使用的DMA Stream,并设置优先级,将DMA请求模式设置为循环模式,外设地址不增加,内存地址递增,数据宽度选择字Word

为何如此配置?

请阅读本实验“3.0、前提知识”

如下图所示为ADC1的DMA请求具体设置

3.1.3、外设中断配置

在Pinout & Configuration页面左边System Core/NVIC中
勾选DMA2 Stream0 全局中断
,然后选择合适的中断优先级即可

注意这里没有勾选ADC1/2/3的全局中断,因为外设DMA中断使用的回调函数和外设本身中断的回调函数一般是同一个回调函数(为什么?请阅读本实验3.2.2小节),
如果同时开始两者中断可能会导致重复进入中断函数

但是有些外设使用DMA时必须开启自身的中断,不同外设情况不一样

建议在外设使用DMA时,尽量不开启外设全局中断,必须开启的可以禁用外设主要事件源产生的硬件中断
(注释1)

上述步骤如下图所示

3.2、生成代码

3.2.0、配置Project Manager页面

单击进入Project Manager页面,在左边Project分栏中修改工程名称、工程目录和工具链,然后在Code Generator中勾选“Gnerate peripheral initialization as a pair of 'c/h' files per peripheral”,最后单击页面右上角GENERATE CODE生成工程,具体如下图所示

详细Project Manager配置内容读者可以阅读“
STM32CubeMX教程1 工程建立
”实验3.4.3小节

3.2.1、外设初始化调用流程

首先在生成的工程主函数main()中调用MX_DMA_Init()函数对ADC1用到的DMA时钟及其流的中断进行了配置

然后调用MX_ADC1_Init()函数对ADC1的基本参数、通道和通道参数进行了配置,并调用了HAL_ADC_Init()使用配置的参数初始化了ADC1

在初始化函数HAL_ADC_Init()中又调用了HAL_ADC_MspInit()函数,在该函数中使能了ADC1/GPIOA的时钟,对ADC1_IN5/6/7的输入引脚做了复用设置,然后对ADC1的DMA参数配置并进行了初始化,最后调用了__HAL_LINKDMA(adcHandle,DMA_Handle,hdma_adc1)将adc1外设与DMA流对象关联

具体的ADC DMA初始化调用流程如下图所示

3.2.2、外设中断调用流程

CubeMX中勾选DMA2_Stream0的全局中断后,会在stm32f4xx_it.c中增加DMA的中断服务函数DMA2_Stream0_IRQHandler()

在中断服务函数DMA2_Stream0_IRQHandler()中调用了HAL库的DMA全局中断处理函数,该函数中根据各种标志判断DMA传输完成/失败/一半完成等事件,然后根据不同的事件调用不同的回调函数,这里DMA传输完成之后调用了hdma->->XferCpltCal1back()

上述过程如下图所示

这个函数指针在以DMA方式启动ADC采集时被指向DMA传输完成回调ADC_DMAConvCplt()函数

在该DMA传输完成回调ADC_DMAConvCplt()函数中
最终调用了ADC采集完成回调HAL_ADC_ConvCpltCallback()函数
,该函数上一个实验我们重新实现过

上述过程如下图所示

之前所有的外设回调函数都是直接调用了HAL库提前准备好的虚函数,比如ADC的采集完成回调函数HAL_ADC_ConvCpltCallback(),用户直接实现该虚函数即可

但是DMA不是一个外设,而是数据传输手段,大多数外设都可以使用,因此DMA的各种事件回调函数不是一个真正的函数,而是一个函数指针

当我们以DMA传输的方式启动某个外设的时候,就会将该外设对应事件的中断服务函数地址赋值给对应事件DMA中断回调函数指针

3.2.3、添加其他必要代码

在主函数中以DMA的方式启动ADC采集传输,然后启动ADC1的触发源TIM3定时器
,具体代码如下图所示

在adc.c中重新实现DMA传输完成回调函数
,在该函数中取出ADC转换完成的三通道采集值,然后处理并通过串口输出,具体代码如下图所示

一些定义及函数源代码如下

/*main.c中的全局变量定义*/
uint32_t DataBuffer[BATCH_DATA_LEN];

/*main.h中的变量外扩及宏定义*/
#define BATCH_DATA_LEN 3
extern uint32_t DataBuffer[BATCH_DATA_LEN];

/*DMA转换完成中断回调*/
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    /*定时器DMA启动多通道转换*/
    uint32_t val=0,Volt=0;
    for(uint8_t i=0;i<BATCH_DATA_LEN;i++)
    {
        val=DataBuffer[i];
        Volt=(3300*val)>>12;
        printf("ADC_IN%d, val:%d, Volt:%d\r\n",i+5,val,Volt);
    }
    printf("\r\n");
}

4、常用函数

/*以DMA方式启动ADC采集*/
HAL_StatusTypeDef HAL_ADC_Start_DMA(ADC_HandleTypeDef *hadc, uint32_t *pData, uint32_t Length)

/*结束以DMA方式启动的ADC采集*/
HAL_StatusTypeDef HAL_ADC_Stop_DMA(ADC_HandleTypeDef *hadc)

5、烧录验证

烧录程序,单片机上电之后,串口不断的输出三个通道的ADC采集值,笔者将三个滑动变阻器按照通道5、通道6和通道7的顺序,分别从一端缓慢拧到另一端,可以从串口输出的数据看到,通道5/6/7三个通道采集到的ADC数据从最大4095慢慢变到最小值0

6、注释详解

注释1
:详细内容请阅读
STM32Cube高效开发教程(基础篇)
14.5.1小节内容

更多内容请浏览
STM32CubeMX+STM32F4系列教程文章汇总贴

Implicit

何为隐式?隐式(Implicit)的是显式(explicit)的反义词。

explicit可以简单理解为用网格等信息描述的几何形状,网格信息是离散的,信息量越大描述越精准。Implicit则不需要顶点等显式信息,用方程,或者说
有符号距离场 (Signed Distance Field)
即SDF,表示几何形状的数学模型。

SDF

在SDF中,空间中的每一点都有一个值,表示
该点到最近表面的距离
。这个距离可以是正的(如果点在形状的外部),也可以是负的(如果点在形状的内部)。SDF提供了一种简洁而强大的方式来描述复杂的三维形状,包括难以用传统多边形网格表示的形状。

SDF的优势:

  1. 高效的几何操作:进行布尔运算以及形状变形和平滑处理变得简单高效。
  2. 复杂形状的表示:特别适合描述复杂或有机形状,如流体、云雾和生物组织。
  3. 动态变化的支持:使实时更新和变形成为可能。

对几何primitive的sdf描述
Inigo Quilez :: computer graphics, mathematics, shaders, fractals, demoscene and more (iquilezles.org)

Sphere Tracing

why

传统的光线追踪算法,通过发送光线并检测这些光线与场景中物体的交点,进行着色。但是当场景使用SDF来表示时,传统的交点检测方法不再适用。原因在于SDF和多边形网格表示场景的方式截然不同:

  1. 顶点表示:在传统的多边形网格表示中,物体由许多小的平面片(通常是三角形)构成的。这些三角形有明确的边界和顶点,因此当光线与这些三角形相交时,可以通过数学计算找到
    精确的交点
  2. SDF表示:相比之下,SDF场景被视为一个连续的体。体中每个点都有一个值,表示该点到最近表面的距离。这种表示方法不涉及明确的边界或顶点,而是提供关于
    形状表面的连续信息

由于SDF没有离散的多边形或边界,传统的光线与多边形的交点检测算法(通常涉及线性代数和平面几何计算)不再适用。在SDF表示的场景中,没有明确的多边形表面可以直接与光线进行交点计算。因此需要Sphere Tracing这样的算法来处理SDF场景。

How

原版的SphereTracing十分讨巧,其基本原理非常简洁,也非常聪明:

  1. 从点
    \(p_0\)
    开始投射一条 ray,以 SDF 值
    \(f(p_0)\)
    为步长进行一次 marching。
  2. 以上次 marching 的终点为起点,以
    \(f(p_1)\)
    为步长继续下一次 marching。
  3. 重复 marching 直到
    \(f(p_n)\)
    <
    \(\epsilon\)
    ,
    \(\epsilon\)
    为预设的阈值。此时我们认为
    \(f(p_n)\)
    即为交点。

Enhanced Sphere Tracing

上述的传统 Sphere Tracing 算法冗余的步进次数很多,为了进一步提高效率,诞生了很多种优化方案。

binary search
二分查找法,实际效果不理想,并且遇上TPMS这种复杂结构会有更多问题
Inigo Quilez :: computer graphics, mathematics, shaders, fractals, demoscene and more (iquilezles.org)

Segment Tracing
通过假设场景是
\(C^2\)
连续去加长初始 marching 的距离,有效减少 marching 次数的同时,大幅提升了每次 marching 的消耗,效果不理想,并且在有棱角的场景表现更差
https://diglib.eg.org/bitstream/handle/10.6666661/cgf13951/v39i2pp545-554.pdf
GitHub - aparis69/Segment-Tracing: Source code for the Computer Graphics Forum paper: Segment Tracing Using Local Lipschitz Bounds. Presented at Eurographics 2020.
【光线追踪】Segment Tracing:一种可能加速距离场求交的实时光线追踪方案 - 知乎 (zhihu.com)
Enhanced Sphere Tracing
采用激进的 marching 步长,即设定一个步长倍数
\(\alpha\)
,marching 步长改为
\(f(p_n) * \alpha\)
而不是 Sphere Tracing 保守的
\(f(p_n)\)
。只要两个 sphere 相交,就说明本次 marching 可安全,否则退回到点
\(p_n\)
的位置重新进行保守的 marching。

Accelerating Sphere Tracing
在 enhanced 的基础上更进一步。假设前两次 marching 所形成的 sphere 都相切于同一平面,那么下一次可以尝试 marching 同样与该平面相切并且也与上一次的 marching sphere 相切的距离。同样,如果尝试失败则回退至保守 marching。

上述两个方法都是在2023年以前最优秀的 marching 算法之一,直到 Automatic Step Size Relaxation。

Automatic step size relaxation

Automatic step size relaxation 可以根据历史 marching 的情况,动态调整步长:平面多的地方。可以走相切平面的激进步长,而曲面多的地方则调整为走更保守的步长。

每次 marching 不断更新近似斜率 m,然后用它来指导下一次 marching。

博主拙劣的C++实现

auto trace_auto_relaxation = [&](glm::vec3 p)
{
	float t = 0.0f;
	float r = sdf(p);
	float m = -1.0f;
	float z = r;
	const float beta = 0.3f;

	for (int i = 0; i < max_steps; i++)
	{
		if (r < eps) {
			return true;
		}
		if (t + r > max_dist) {
			break;
		}

		glm::vec3 next_p = p + ray_dir * z * relaxation_factor;
		float R = sdf(next_p);
		bool doBackStep = z > abs(R) + r;

		if (!doBackStep) {
			float M = (R - r) / (z + 1e-5f);
			m = (1.0f - beta) * m + beta * M;
			t += z * relaxation_factor;
			p = next_p;
			r = R;
		}
		else {
			m = -1.0f;
		}

		float omega = glm::max(1.0f, 2.0f / (1.0f - m));
		z = glm::max(eps, r * omega);
	}
	return false;
};

But

讲完了吗?讲完我要开始转了。
上述的所有方法,对
TPMS(Triply Periodic Minimal Surfaces 三周期极小曲面)
,都起不到多少作用。目前效果最好的办法只能是力大砖飞——marching 步长乘以系数
\(\alpha ,\alpha < 1\)
,以非常保守的步长去小心翼翼的找TPMS表面。也是博主目前最头疼的问题,欢迎讨论。

背景

经过前面几篇的理解,我们大致梳理清楚了FeignClient的创建、Feign调用的大体流程,本篇会深入Feign调用中涉及的另一个重要组件:loadbalancer,了解loadbalancer在feign调用中的职责,再追溯其是如何创建的。

在讲之前,我先提个重点,本文章的前期是引用了nacos依赖且开启了如下选项,启用了nacos的Loadbalancer:

spring.cloud.loadbalancer.nacos.enabled=true

nacos的Loadbalancer是支持了基于nacos实例中的元数据进行服务实例筛选,比如权重等元数据。

不开这个选项,则是用默认的Loadbalancer,不知道支不支持基于nacos实例中的元数据进行服务实例筛选(没测试)。

我们这边是打开了这个选项,所以本文就基于打开的情况来讲。

feign调用流程

大体流程

接上一篇文章,feign调用的核心代码如下:

image-20240114113200793

1处主要是封装请求;

2处主要是依靠loadbalancer获取最终要调用的实例。

但是在1和2之间,有一段代码是,获取LoadBalancerLifecycle类型的bean列表,大家看到什么lifecycle之类的名字,大概能知道,这些类是一些listener类,一般包含了几个生命周期相关的方法,比如这里就是:

void onStart(Request<RC> request);

void onStartRequest(Request<RC> request, Response<T> lbResponse);

void onComplete(CompletionContext<RES, T, RC> completionContext);

这几个方法分别就是在loadbalancer的不同阶段进行调用。

比如,我举个例子,我之前发现feign的日志里没打印最终调用的实例的ip、端口,导致查日志不方便,所以我就定义了一个自定义的LoadBalancerLifecycle类,将最终选择的实例的ip端口打印出来。

image-20240114113841901

我们看下,这里是如何获取LoadBalancerLifecycle对象的?

loadBalancerClientFactory.getInstances(serviceId, LoadBalancerLifecycle.class)

工厂用途

loadBalancerClientFactory这个字段,类型为LoadBalancerClientFactory,其定义:

public class LoadBalancerClientFactory extends NamedContextFactory<LoadBalancerClientSpecification>

再看其注释:

A factory that creates client, load balancer and client configuration instances. It creates a Spring ApplicationContext per client name, and extracts the beans that it needs from there.

这里就直说了,这是个工厂,它会给每个client创建一个spring容器。这里的client是啥呢,其实是
org.springframework.cloud.client.loadbalancer.LoadBalancerClient
类型的对象,它是在spring-cloud-commons中定义的接口:

image-20240114115218070

工厂自身的创建

工厂本身是自动装配的:

image-20240114121947011

看上图,需要一个构造函数参数,这个就是一些配置:

image-20240114122040282

调用的构造函数逻辑如下:

public class LoadBalancerClientFactory extends NamedContextFactory<LoadBalancerClientSpecification>

public static final String NAMESPACE = "loadbalancer";    
public static final String PROPERTY_NAME = NAMESPACE + ".client.name";

public LoadBalancerClientFactory(LoadBalancerClientsProperties properties) {
    super(LoadBalancerClientConfiguration.class, NAMESPACE, PROPERTY_NAME);
    this.properties = properties;
}

这里调用了父类构造函数,把几个值存到父类中:

private final String propertySourceName;
private final String propertyName;
private Class<?> defaultConfigType;

public NamedContextFactory(Class<?> defaultConfigType, String propertySourceName, String propertyName) {
    this.defaultConfigType = defaultConfigType;
    this.propertySourceName = propertySourceName;
    this.propertyName = propertyName;
}

完成构造后,我们发现,还调用了:

clientFactory.setConfigurations(this.configurations.getIfAvailable(Collections::emptyList));

这里的configurations类型是:

private final ObjectProvider<List<LoadBalancerClientSpecification>> configurations;

image-20240114122524436

这个字段本身是通过构造函数方式注入的,来源呢,就是spring 容器。

我们有必要探究下,这个LoadBalancerClientSpecification类型的bean,是怎么进入spring 容器的?

其实,这个类也是代表了一份LoadbalancerClient的配置,之前feignClient也是一样的:

public class LoadBalancerClientSpecification implements NamedContextFactory.Specification {

	private String name;

	private Class<?>[] configuration;
}

这种类型的bean,其实是通过LoadBalancerClient注解和LoadBalancerClients注解进入容器的,当你使用这两个注解时,其实是支持配置一个class:

image-20240114123017911

image-20240114123039013

然后,它们两注解都import了一个LoadBalancerClientConfigurationRegistrar类:

image-20240114122913148

这个会负责将对应的配置class,注册到容器中:

image-20240114123306881

注册时,name会有所区别,如果是LoadBalancerClients注解引入的,会加个
default
前缀。

image-20240114123447968

在默认情况下(引入了nacos-discovery、spring-cloud-loadbalancer的情况下),就会在代码中如下三处有@LoadBalancerClients注解:

image-20240114124649366

image-20240114124730849

image-20240114124757216

所以,我们工厂创建时debug,可以看到如下场景:

image-20240114124936926

从工厂获取LoadBalancerLifecycle

上面讲完了工厂的创建,这里回到工厂的使用。我们之前看到,会获取LoadBalancerLifecycle这种bean:

loadBalancerClientFactory.getInstances(serviceId, LoadBalancerLifecycle.class),

但奇怪的是,获取bean不应该先用loadBalancerClientFactory创建的给各个loadBalancerClient的spring容器;再从容器获取bean吗?

这里是简化了,直接让工厂负责全部事务,我要bean的时候,只找工厂要,工厂内部自己再去创建spring容器那些。

所以我们看到,工厂是实现了接口:

public class LoadBalancerClientFactory extends NamedContextFactory<LoadBalancerClientSpecification>
		implements ReactiveLoadBalancer.Factory<ServiceInstance>    

这个接口就有如下方法,这是个泛型方法:

Allows accessing beans registered within client-specific LoadBalancer contexts.
    
<X> Map<String, X> getInstances(String name, Class<X> type);

下面就看看方法如何实现的:

image-20240114125611955

这里就是分了两步,先获取容器,再从容器获取bean。

创建容器

这个获取容器是先从缓存map获取,没有则创建。

image-20240114125754859

我们这里自然是没有的,进入createContext:

image-20240114130159006

这里首先是创建了一个spring上下文,里面是有一个bean容器的,容器里要放什么bean呢,首先就是上图中的configurations中那些LoadBalancerClient注解里指定的配置类,再然后,就是LoadBalancerClients注解里指定的那些默认的配置类,我们这里有3处LoadBalancerClients注解,但是只有nacos那一个,指定了配置类:

@LoadBalancerClients(defaultConfiguration = NacosLoadBalancerClientConfiguration.class)
public class LoadBalancerNacosAutoConfiguration {

所以,这里会把NacosLoadBalancerClientConfiguration这个配置类注册到容器。

接下来,是如下这行:

context.register(PropertyPlaceholderAutoConfiguration.class, this.defaultConfigType);

这里的defaultConfigType是啥呢,其实就是创建工厂时,指定的LoadBalancerClientConfiguration:

image-20240114130737961

到这里为止,基本spring容器该手工放入的bean就这些了。但这个容器内到时候只会有这些bean吗,不是的。

因为我们这里放进去的几个bean,内部又定义了更多的bean。

nacosLoadBalancerClientConfiguration
loadBalancerClientConfiguration    

nacosLoadBalancerClientConfiguration

首先是自动装配一个NacosLoadBalancer(在缺少这种ReactorLoadBalancer bean的情况下)

image-20240114131703086

再下来,会自动装配ServiceInstanceListSupplier bean:

image-20240114131857085

loadBalancerClientConfiguration

这边注意,也是在没注册这个bean的时候,自动装配ReactorLoadBalancer,这个其实会和上面的nacos的产生竞争,最终到底是哪个上岗呢,只能看顺序了:

image-20240114132145192

和nacos一样,自动装配ServiceInstanceListSupplier:

image-20240114132231126

竞争关系谁胜出

我们上面提到,nacos的配置类和spring-cloud-loadbalancer的配置类,是全面竞争的,最终的话,是谁胜出呢?

我们看看容器完成bean创建后的情况:

image-20240114133009841

可以发现,是nacos的配置赢了。

具体为什么赢,这个暂时不细说,基本就是bean的order那些事情。反正现在nacos赢了,看起来也没啥问题,我们就继续往后走,目前是完成了bean容器的创建。

获取LoadBalancerLifecycle类型bean

我这个项目,并没定义这种bean,所以实际是取不到的,注意的是,在LoadbalancerClient对应的容器取不到,还是会去父容器取的。

我们在父容器也没定义,所以最终是取不到。

根据服务名获取最终实例

loadBalancerClient

目前准备分析如下代码:

image-20240114133356196

先看下这个字段来自于哪里:

image-20240114141150266

image-20240114141248933

可以看出,来自于spring容器注入。

image-20240114141418748

所以,这里可以看出,loadBalancerClient类型为BlockingLoadBalancerClient。

loadBalancerClient.choose

进入该方法:

image-20240114141024073

image-20240114141628863

image-20240114141647797

最终就是从容器获取,取到的就是nacos自动装配的NacosLoadBalancer:

image-20240114141739653

loadBalancer.choose

nacos这里的实现用的反应式编程,不怎么了解这块,反正最终是调用getInstanceResponse方法,且会把从nacos获取到的服务列表传递进来:

image-20240114142341843

image-20240114142666666615

可以看到,这里传入的就是实际的服务实例,还包含了nacos相关的元数据,如cluster、weight、是否临时、是否健康等。

后续的逻辑就根据实例的各种属性进行筛选,如meta.nacos.cluster、ipv4/ipv6、

image-20240114142817880

根据权重进行选择:

image-20240114143419421

根据实例进行feign调用

image-20240114143627515

我们跟进去后,发现主要就是feignClient.execute进行调用,在前后则是调用生命周期的相关方法:

image-20240114143957771

我们看到,这个client就是默认的FeignClient,比较原始,直接就是用原生的HttpURLConnection;我们之前文章提到,也是可以使用httpclient、okhttp那些feign.Client的实现,只要引入对应依赖即可。

image-20240114144221192

另外,这个也是没有连接池的,每次都是打开新连接;这里也用了外部options参数中的超时时间。

image-20240114144501645

后面的响应处理就略过不讲了。

总结

我们总算是把大体流程都讲完了,下一篇讲讲我遇到的问题。