2024年1月

Spring Boot 3.2 于 2023 年 11 月大张旗鼓地发布,标志着 Java 开发领域的一个关键时刻。这一突破性的版本引入了一系列革命性的功能,包括:

  • 虚拟线程:利用 Project Loom 的虚拟线程释放可扩展性,从而减少资源消耗并增强并发性。
  • Native Image支持:通过Native Image编译制作速度极快的应用程序,减少启动时间并优化资源利用率。
  • JVM 检查点:利用 CRaC 项目的 JVM 检查点机制实现应用程序的快速重启,无需冗长的重新初始化。
  • RestClient:采用新的
    RestClient
    接口的功能方法,简化 HTTP 交互并简化代码。
  • Spring for Apache Pulsar:利用 Apache Pulsar 的强大功能实现强大的消息传递功能,无缝集成到您的 Spring Boot 应用程序中。

其中,虚拟线程是最近 Java 版本中引入的最具变革性的特性之一。正如官方文件所述:虚拟线程是轻量级线程,可减少编写、维护和调试高吞吐量并发应用程序的工作量。线程是可以调度的最小处理单元。它与其他此类单位同时运行,并且在很大程度上独立于其他此类单元运行。它是 java.lang.Thread 的一个实例。有两种线程:平台线程和虚拟线程。平台线程是作为操作系统 (OS) 线程的瘦包装器实现的。平台线程在其底层操作系统线程上运行 Java 代码,平台线程在平台线程的整个生命周期内捕获其操作系统线程。因此,可用平台线程数限制为操作系统线程数。与平台线程一样,虚拟线程也是 java.lang.Thread 的实例。但是,虚拟线程不绑定到特定的操作系统线程。虚拟线程仍在操作系统线程上运行代码。但是,当在虚拟线程中运行的代码调用阻塞 I/O 操作时,Java 运行时会挂起虚拟线程,直到它可以恢复为止。与挂起的虚拟线程关联的操作系统线程现在可以自由地对其他虚拟线程执行操作。虚拟线程适用于运行大部分时间被阻塞的任务,通常等待 I/O 操作完成。但是,它们不适用于长时间运行的 CPU 密集型操作。

虽然人们普遍认为虚拟线程在 I/O 密集型方案中表现出色,但它们在 CPU 密集型任务中的性能仍然是一个问号。本系列文章深入探讨了虚拟线程在各种用例中的潜在优势,从基本的“hello world”到静态文件服务(I/O 密集型)、QR 码生成(CPU 密集型)和多部分/表单数据处理(混合工作负载)等实际应用。

在本系列的开头文章中,我们已经了解了虚拟线程与物理线程相比在最简单(且不切实际)的 hello world 情况下的性能。物理线程和虚拟线程之间几乎没有任何性能或资源使用差异。在本文中,我们将更加“实用”,并针对静态文件服务器情况进行比较。这绝对是一个常见且“真实世界”的案例。让我们看看这次我们发现了什么。

如果大家正在做Spring Boot 2.3升级Spring 3.2,这里顺手给大家推荐
Spring Boot 2.x 到 3.2 的升级指南

测试环境

所有测试均在配备 16G RAM、8 个物理内核和 4 个效率内核的 MacBook Pro M2 上执行。测试工具是 Bombardier,它是更快的 HTTP 负载测试器之一(用 Go 编写)。

软件版本为:

  • Java v21.0.1
  • Spring Boot 3.2.1

程序配置

除了主 Java 类之外,不需要编写任何 Java 文件,静态文件服务器只能通过配置就能发挥作用。

application.properties
文件如下:

server.port=3000
spring.mvc.static-path-pattern=/static/**
spring.web.resources.static-locations=file:/Users/mayankc/Work/source/perfComparisons/static/

使用虚拟线程时,我们将通过添加以下行来启用它们:

spring.threads.virtual.enabled=true

pom.xml
内容:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.1</version>
    <relativePath/>
 </parent>
 <groupId>com.example</groupId>
 <artifactId>demo</artifactId>
 <version>0.0.1-SNAPSHOT</version>
 <name>demo</name>
 <description>Demo project for Spring Boot</description>
 <properties>
   <java.version>21</java.version>
 </properties>
 <dependencies>
   <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-web</artifactId>
   </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
 </dependencies>

测试数据

大小完全相同但数据不同的 100K 文件被放置在静态资源目录中。每个文件大小正好是 102400 字节。

文件的命名范围为 1 到 100000。

使用 Bombardier 的修改版本,为每次运行生成随机请求 URL:
http://localhost:3000/static/<file-name>

应用场景

为了确保结果一致,每个测试在开始数据收集之前都会经历 5K 请求预热阶段。

然后,在不同范围的并发连接级别(50、100 和 300)中仔细记录测量结果,每个级别都承受 500 万个请求工作负载。

结果评估

除了简单地跟踪原始速度之外,我们还将采用详细的指标框架来捕获延迟分布(最小值、百分位数、最大值)和吞吐量(每秒请求数)。

CPU 和内存的资源使用情况监控将补充此分析,从而提供不同工作负载下系统性能的全面了解。

测试结果

结果以图表形式呈现如下:

总结

对静态文件服务的分析表明,物理线程在性能和资源效率方面略胜一筹(与我们的预期相反)。

不过,这种受 I/O 限制的场景可能并不是充分发挥虚拟线程潜力的理想场所。涉及数据库交互的任务可能会显示出更多令人信服的优势。也许负载不足以让虚拟线程发挥出最大的作用。为了找出答案,我们将在接下来的文章中介绍 URL短链(数据库驱动)、二维码生成(CPU受限)和混合工作负载场景(如表单数据处理),旨在揭示虚拟线程真正出类拔萃的案例。

欢迎关注我的公众号:程序猿DD。第一时间了解前沿行业消息、分享深度技术干货、获取优质学习资源

一、背景简介

站长工作台,致力于为京东物流所有站长、运营管理人员提供高效工作平台,拥有多元化的业务形态。我们力求
提升团队研发效率

实现敏捷业务交付
,以打造一支具备灵活性、高度协作和强适应能力的敏捷团队。

二、提效案例描述

2.1、痛点分析

站长工作台的报表页面和任务卡片页面,大多数的UI风格和交互方式一致,面对新业务诉求时,开发人员难以避免重复工作,从而导致团队开发效率降低、代码质量难把控以及项目维护困难等诸多问题。

2.2、解决方案

根据业务场景,构建一套低代码工具链,涵盖代码编辑器、版本管理、解析器、能力编排等,提供可视化开发能力、预设组件、集成部署等功能。借助图形化界面和组件,助力开发人员加速应用程序开发进程,迅速响应业务需求并及时交付。

三、案例实践步骤

3.1、关键设计及实践方式

低代码工具链以表单驱动数据为核心,通过界面化的低成本交互方式加上少量的胶水代码进行业务分析,满足开发需求并提供高效、灵活和可维护的需求开发体验。其主要架构为自定义DSL、页面编辑器、组件库、属性管理、渲染器、部署发布、物料管理等模块。

各模块的主要职责如下:

1.
DSL
:DSL作为低代码工具链的核心,涵盖组件描述协议、页面描述协议及字段逻辑协议等,旨在实现对工具链的约束与扩展。在扩展物料过程中,需遵循相应的DSL规范;在预览和发布阶段,通过DSL生成JSON;同时,编辑器和解析器间亦依赖DSL进行通信。
2.
页面编辑器
:页面编辑器包含丰富的原子组件;通过可视化方式选择组件或页面模版,在画板上进行页面的编排,组合拼装后将根据页面包含的DOM元素、样式、方法等信息,生成JSON格式的DSL。
3.
属性管理
:属性管理提供三种基本设置:样式、数据绑定和触发器与事件。样式功能用于设置页面或组件的外观和行为;数据绑定功能可将页面元素与数据模型中的字段进行绑定,以实现数据的显示和操作;触发器和事件设置则可绑定在特定条件下执行的方法。
4.
渲染器
:工具链编辑器和渲染器秉持着数据驱动视图的思想,根据DSL协议,递归解析存储的JSON文件动态生成页面。编辑页面时能够实时模拟最新运行效果,用于及时调整配置以达到最贴合业务需求的水准。
5.
部署发布
:部署发布是将最终将生成的DSL存储至OSS,并生成新页面的URL地址,根据新页面的地址能够获取DSL,经解析器解析,通过按需加载页面元素的方式向用户展示。
6.
物料管理
:工具链提供物料管理功能,用于管理应用程序开发中使用的各种资源和组件,允许开发者跟踪和管理不同版本的组件和应用程序,以确保团队协作时的一致性和可追溯性。同时提供可重用的表单和模版,使开发者能快速创建数据输入和报表显示的界面。

低代码工具链的整体目标是为了提效,所以在方案设计过程中我们也特别关注工具的易用性和可扩展性,同时也要保障不会因为工具链的引入,影响工作台原本的高可用性。

3.1.1、核心功能

低代码工具链的核心功能由一系列关键组件构成,这些组件负责处理应用程序的设计、开发、部署和运行等各个环节,包括通信协议、可视化编辑器、数据模型、业务逻辑、渲染器、部署以及版本控制等功能。以下仅对关键模块进行简单介绍:

1.
页面描述协议DSL设计

我们选型了JSON作为页面描述协议,而没有使用看来起描述能力更强的XML,主要因为使用JSON作为页面描述协议不仅有助于提高可读性、灵活性、可扩展性,同时JSON实现了与JavaScript的天然集成和RESTful API的兼容性。这为开发者提供了更直观、方便、跨平台的开发体验。页面UI组件树 JSON 可视为SchemaNodeTree,每个 SchemaNode 结构针对组件属性和事件分别进行处理。

1.
可视化编辑器

从组件库、信息集、功能到UI,提供了友好的可视化编辑器,不涉及代码的编写,集成了可供UI运转的所有配置。开发者只需要简单掌握相关概念,通过简单操作就能快速搭建页面。具体功能如下:


组件库
:包括丰富的预设组件:如按钮、表格、表单、业务卡片等,用户可从组件库中选择适当的组件制作页面。

实时预览
:本功能有助于迅速调整和优化设计,达到所见即所得的效果。

布局工具
:支持布局工具,允许开发者配置页面的结构和排列方式,如网格布局。

动态数据绑定
:允许开发者通过简单的配置将用户界面元素与数据模型中的字段进行绑定,实现动态数据展示。

事件触发器
:通过绑定事件,开发者能够定义在用户交互或浏览器事件发生时执行的操作。如点击、页面初始化后、表单提交、分页切换等事件。

版本管理
:记录和管理设计的历史版本,允许开发者回溯到之前的设计状态,有助于追踪和恢复变更。

在DSL的约束下,首先,我们将建立一个组件构造映射表,专门用于存放组件名称及其对应的构造方法。其次,我们实现了一个构建引擎,核心功能是读取由DSL转换而来的ComponentNode,然后采用递归深度遍历的方式,持续读取ComponentNode及其子节点,以获取相应组件的构造方法,进而将ComponentNode构建为VueNode。最后,为减少功能代码的重复,并便于未来扩展,我们采用了切面设计方案,将处理流程中的部分环节纳入切面,从而实现灵活处理的目标,同时便于外部调用者进行定制开发。

1.
数据引擎

数据引擎创建元数据模型,赋予业务编排能力,运用抽象流程节点,实现自定义任务、触发规则及闭环规则。研发团队可持续扩展微流程节点,以满足多样化业务场景需求。

1.
渲染器设计

低代码工具链中的渲染器是一个关键的组件,负责将可视化编辑器中定义的组件、布局和样式信息转换为实际的UI元素;其提供的实时渲染功能,使开发者在编辑器中得到即时反馈;动态数据绑定,确保渲染的UI元素能够与应用程序的数据模型同步更新。

在底层,我们实现了一个组件池,由统一的ComponentManager负责全局管理。其上是扩展层,对应各个组件的实现。例如,Parsers负责将 SchemaNode 解析为相应组件,并赋予组件个性化能力;Validators实现了验证逻辑;Actions负责处理事件,我们封装了诸如获取参数、调用接口、数据设置等能力。最上层提供对外接口,负责呈现整个引擎的渲染结果。

3.1.2、容灾方案

对于非低代码模块,前端页面由浏览器加载代码资源进行渲染和框架构建,通过异步调用各种远程接口来加载数据。在这种情况下,我们只需关注代码质量,确保即使请求无数据,页面结构也能正常渲染。从用户的角度来看,系统始终可用,而不会出现白屏等故障。

当使用工具链生成前端页面后,页面主体描述逻辑由浏览器加载代码资源转移到远端存储OSS上。为此,我们重点考虑了OSS故障的场景。整体的处理逻辑如下:

在页面制作和发布过程中,我们会将数据双写入OSS,生成两个不同的文件地址A和B。在使用OSS时,我们会优先选择地址A。当A出现故障时,系统将切换到地址B,并继续使用B,双写地址间的切换。只要A和B不同时出现故障,整个系统的可用性就能得到很好的保障。

3.1.3、接入方式

对接工程能力:引入依赖,
仅需四行代码
即可实现站长工作台引入依赖文件

import pageBuilder from '@xxx/page-builder'
import '@xxxx/page-builder/page-builder/page-builder.css'

Vue.use(pageBuilder, { registerComponents: { AreaCondition: NewAreaCondition } });

-builder </page-builder>

3.2、平台使用展示

(脱敏后,内部系统页面暂不展示,仅阐述流程)

3.2.1、页面制作

编排页面基础结构 ->进行页面基础配置 ->页面元素事件定义 ->页面元素绑定事件

3.2.2、页面发布

页面预览制作出来的页面,确认效果符合预期 ->一键页面发布,并获取访问该页面的URL地址

3.2.3、任务引擎发布

创建任务 -> 配置任务触发规则、闭环规则 ->生成任务

3.3、实践亮点

四、提效达成效果

4.1、效能提升

工具链于2023年7月投入使用,自投入使用以来,本部门的需求交付周期得到了显著改善,自5月到9月以来,需求的全周期交付指标从21.06天缩短至14.71天。同时,需求吞吐量也实现了大幅度提升,除去618封板期间的特殊变化,月均吞吐量从245个增至275个。

4.2、人效提升

在Q3季度,我们利用站长工作台的前端低代码工具,高效完成了7个报表类需求和5个任务卡片类需求的开发。报表和卡片的开发时间从2天降至4小时,工作台新增的待办任务类型需求交付周期从7天大幅缩短至2.5天。

同时后台数据引擎的使用,高效完成了11个业务需求的开发,平均每个需求最多可节省0.5-3人/日的开发时间,进一步提高了研发效率,以达成需求吞吐量提升和敏捷交付业务需求的期望。

4.3、影响力

4.3.1、使用推广效果

工具链投入使用后,在物流侧进行分享,目前已用于三个敏捷团队,后续推广动作正在筹备中。现已承接需求23个。

4.3.2、技术分享推广

1、收录于京东物流技术月报

2、于物流前端通道进行精品课分享

五、总结及后续规划

低代码工具链以"特定领域场景Low Code -> 映射和建模 -> 可视化搭建系统"为核心链路,不断标准化开发流程,为团队提高了整体研发效率,并实现了业务需求的快速交付。使团队的
敏捷交付能力跃升
至一个全新的高度。

后续规划:

技术能力

•前端低代码和后端低代码实现无缝衔接,进一步提升页面开发效率
•实现组件间优雅地互联互通,打通和其它平台的连通性,敏捷实现复杂页面的构建
•打造组件生态,支持外部研发进行组件扩展

团队交付

•从通用报表页和任务卡片页的低代码生成中汲取经验。转变为通用代码生成平台,服务于物流领域所有前端研发人员
•改变产研协作模式,从需求交付型走向持续创作型
•提升团队的需求交付能力,达到新的高度,为业务发展赋予新的能量

作者:京东物流 郭长沙

来源:京东云开发者社区 自猿其说 Tech 转载请注明来源

问题描述

今天下午运维反馈说我们这一个pod一天重启了8次,需要排查下原因。一看Kiban日志,jvm没有抛出过任何错误,服务就直接重启了。显然是进程被直接杀了,初步判断是pod达到内存上限被K8s oomkill了。
因为我们xmx和xsx设置的都是3G,而pod的内存上限设置的是6G,所以出现这种情况还挺诡异的。

排查过程

初步定位

先找运维拉了一下pod的描述,关键信息在这里

Containers:
  container-prod--:
    Container ID:   --
    Image:          --
    Image ID:       docker-pullable://--
    Port:           8080/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Fri, 05 Jan 2024 11:40:01 +0800
    Last State:     Terminated
      Reason:       Error
      Exit Code:    137
      Started:      Fri, 05 Jan 2024 11:27:38 +0800
      Finished:     Fri, 05 Jan 2024 11:39:58 +0800
    Ready:          True
    Restart Count:  8
    Limits:
      cpu:     8
      memory:  6Gi
    Requests:
      cpu:        100m
      memory:     512Mi
  • 可以看到Last State:Terminated,Exit Code: 137。这个错误码表示的是pod进程被SIGKILL给杀掉了。一般情况下是因为pod达到内存上限被k8s杀了。
    因此得出结论是生产环境暂时先扩大下pod的内存限制,让服务稳住。然后再排查为啥pod里会有这么多的堆外内存占用。

进一步分析

但是运维反馈说无法再扩大pod的内存限制,因为宿主机的内存已经占到了99%了。
然后结合pod的内存监控,发现pod被杀前的内存占用只到4G左右,没有达到上限的6G,pod就被kill掉了。

于是问题就来了,为啥pod没有达到内存上限就被kill了呢。
带着疑问,我开始在google里寻找答案,也发现了一些端倪:

  • 如果是pod内存达到上限被kill,pod的描述里会写Exit Code: 137,但是Reason不是Error,而是OOMKilled
  • 宿主机内存已经吃满,会触发k8s的保护机制,开始evict一些pod来释放资源
  • 但是为什么整个集群里,只有这个pod被反复evict,其他服务没有影响?

谜题解开

最终还是google给出了答案:
Why my pod gets OOMKill (exit code 137) without reaching threshold of requested memory

链接里的作者遇到了和我一样的情况,pod还没吃到内存上限就被杀了,而且也是:

  Last State:     Terminated
      Reason:       Error
      Exit Code:    137

作者最终定位的原因是因为k8s的QoS机制,在宿主机资源耗尽的时候,会按照QoS机制的优先级,去杀掉pod来释放资源。

什么是k8s的QoS?

QoS,指的是Quality of Service,也就是k8s用来标记各个pod对于资源使用情况的质量,QoS会直接影响当节点资源耗尽的时候k8s对pod进行evict的决策。官方的描述在
这里
.

k8s会以pod的描述文件里的资源限制,对pod进行分级:

QoS 条件
Guaranteed 1. pod里所有的容器都必须设置cpu和内存的request和limit,2. pod里所有容器设置的cpu和内存的request和容器设置的limit必须相等(容器自身相等,不同容器可以不等)
Burstable 1. pod并不满足Guaranteed的条件,2. 至少有一个容器设置了cpu或者内存的request或者limit
BestEffort pod里的所有容器,都没有设置任何资源的request和limit

当节点资源耗尽的时候,k8s会按照BestEffort->Burstable->Guaranteed这样的优先级去选择杀死pod去释放资源。

从上面运维给我们的pod描述可以看到,这个pod的资源限制是这样的:

    Limits:
      cpu:     8
      memory:  6Gi
    Requests:
      cpu:        100m
      memory:     512Mi

显然符合的是Burstable的标准,所以宿主机内存耗尽的情况下,如果其他服务都是Guaranteed,那自然会一直杀死这个pod来释放资源,哪怕pod本身并没有达到6G的内存上限。

QoS相同的情况下,按照什么优先级去Evict?

但是和运维沟通了一下,我们集群内所有pod的配置,limit和request都是不一样的,也就是说,大家都是Burstable。所以为什么其他pod没有被evict,只有这个pod被反复evict呢?

QoS相同的情况,肯定还是会有evict的优先级的,只是需要我们再去寻找下官方文档。

关于Node资源耗尽时候的Evict机制,
官方文档
有很详细的描述。

其中最关键的一段是这个:

If the kubelet can't reclaim memory before a node experiences OOM, the
oom_killer
calculates an
oom_score
based on the percentage of memory it's using on the node, and then adds the
oom_score_adj
to get an effective
oom_score
for each container. It then kills the container with the highest score.

This means that containers in low QoS pods that consume a large amount of memory relative to their scheduling requests are killed first.

简单来说就是pod evict的标准来自oom_score,每个pod都会被计算出来一个oom_score,而oom_score的计算方式是:
pod使用的内存占总内存的比例加上pod的oom_score_adj值

oom_score_adj的值是k8s基于QoS计算出来的一个偏移值,计算方法:

QoS oom_score_adj
Guaranteed -997
BestEffort 1000
Burstable min(max(2, 1000 - (1000 × memoryRequestBytes) / machineMemoryCapacityBytes), 999)

从这个表格可以看出:

  • 首先是BestEffort->Burstable->Guaranteed这样的一个整体的优先级
  • 然后都是Burstable的时候,pod实际占用内存/pod的request内存比例最高的,会被优先Evict

总结

至此已经可以基本上定位出Pod被反复重启的原因了:

  • k8s节点宿主机内存占用满了,触发了Node-pressure Eviction
  • 按照Node-pressure Eviction的优先级,k8s选择了oom_score最高的pod去evict
  • 由于所有pod都是Burstable,并且设置的request memery都是一样的512M,因此内存占用最多的pod计算出来的oom_score就是最高的
  • 所有pod中,这个服务的内存占用一直都是最高的,所以每次计算出来,最后都是杀死这个pod

那么如何解决呢?

  • 宿主机内存扩容,不然杀死pod这样的事情无法避免,无非就是杀哪个的问题
  • 对于关键服务的pod,要把request和limit设置为完全一致,让pod的QoS置为Guaranteed,尽可能降低pod被杀的几率

现代 CMake 模块化项目管理指南

参考小彭老师的视频教程整理笔记,学习同时方便快速查阅,视频链接如下

【公开课】现代 CMake 模块化项目管理指南【C/C++】

对应课程 PPT 和源码见

https://github.com/parallel101/course

文件/目录组织规范

完整案例参考源码仓库
https://github.com/parallel101/course/tree/master/16/00

推荐的目录组织方式

.
├── biology
│   ├── CMakeLists.txt
│   ├── include
│   │   └── biology
│   │       └── Animal.h
│   └── src
│       └── Animal.cpp
├── CMakeLists.txt
└── pybmain
    ├── CMakeLists.txt
    ├── include
    │   └── pybmain
    │       └── myutils.h
    └── src
        └── main.cpp

第一点,划分了 biology 和 pybmain 两个子项目,每个子项目在各自目录下各有自己的 CMakeLists.txt 文件。

第二点,所有子项目中都被划分为了 include 和 src 两个子目录,分别用来放头文件和源码文件,而其中 include 目录又套了一层项目名,这样可以避免头文件名称冲突。子项目的 CMakeLists.txt 文件中需要使用

target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)

源码文件中这样写,

#include <biology/Animal.h>
#include <pybmain/myutils.h>

第三点,推荐每个模块都有自己的命名空间,头文件中需要

#pragma once
namespace biology {
class Animal {
//...
};
}

源码文件中需要

#include <biology/Animal.h>

namespace biology {
//...
};

根项目的 CMakeLists.txt 配置

cmake_minimum_required(VERSION 3.15)

if (NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release)
endif()

set(CMake_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

project(CppCMakeDemo LANGUAGES CXX)

add_subdirectory(biology)
add_subdirectory(pybmain)

最后两行是关键,用来添加子项目,会调用子项目的 CMakeLists.txt 文件。

子项目的 CMakeLists.txt 配置

库文件的配置

# biology/CMakeLists.txt
file(GLOB_RECURSE srcs CONFIGURE_DEPENDS src/*.cpp include/*.h)
add_library(biology STATIC ${srcs})
target_include_directories(biology PUBLIC include)

首先使用
GLOB_RECURSE
命令获取所有源码文件,然后使用
add_library
命令添加静态库,最后使用
target_include_directories
命令添加头文件搜索路径。

  1. PUBLIC
    修饰符表示这个头文件搜索路径会被暴露给其他依赖这个库的项目,链接了 biology 库的 pybmain 项目也可以共享这个路径。
  2. 注意到我们将 .h 文件也一并添加到了
    add_library
    命令中,这样可以确保 .h 文件也会被添加到 IDE 中,方便查看。
  3. GLOB_RECURSE
    相比
    GLOB
    允许匹配嵌套的目录。
  4. CONFIGURE_DEPENDS
    选项表示如果源码文件发生变化,
    cmake --build build
    会检测目录是否更新,有新文件则自动重新运行
    cmake -B build
    重新配置项目。

可执行文件的配置

# pybmain/CMakeLists.txt
file(GLOB_RECURSE srcs CONFIGURE_DEPENDS src/*.cpp include/*.h)
add_executable(pybmain ${srcs})
target_include_directories(pybmain PUBLIC include)

target_link_libraries(pybmain PUBLIC biology)

基本和库文件的配置一致,只是使用
add_executable
命令添加可执行文件,使用
target_link_libraries
命令链接库文件。

CMake 的 include 功能

和 C/C++ 的 #include 一样,CMake 也有一个 include 命令,CMake 会在 CMAKE_MODULE_PATH 中搜索相应的 XXX.cmake 文件。

# ./CMakeLists.txt
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

project(CppCMakeDemo LANGUAGES CXX)

include(MyUsefulFuncs)
# cmake/MyUsefulFuncs.cmake
macro (my_add_target name type)
    # 用法:my_add_target(pybmain EXECUTABLE)
    file(GLOB_RECURSE srcs CONFIGURE_DEPENDS src/*.cpp src/*.h)
    if ("${type}" MATCHES "EXECUTABLE")
        add_executable(${name} ${srcs})
    else()
        add_library(${name} ${type} ${srcs})
    endif()
    target_include_directories(${name} PUBLIC include)
endmacro()

set(SOME_USEFUL_GLOBAL_VAR    ON)
set(ANOTHER_USEFUL_GLOBAL_VAR OFF)

macro 和 function 的区别

  • macro 相当于直接把代码粘贴过去,直接访问调用者的作用域。这里写的相对路径 include 和 src,是基于调用者所在路径。
  • function 则是会创建一个闭包,优先访问定义者的作用域。这里写的相对路径 include 和 src,则是基于定义者所在路径。

include 和 add_subdirectory 的区别

  • include 相当于直接把代码粘贴过去,直接访问调用者的作用域。这里创建的变量和外面共享,直接 set(key val) 则调用者也有 ${key} 这个变量了。
  • function 中则是基于定义者所在路径,优先访问定义者的作用域。这里需要 set(key val PARENT_SCOPE) 才能修改到外面的变量。

第三方库/依赖项配置

主要讲解
find_package
命令,其官方文档为
https://cmake.org/cmake/help/latest/command/find_package.html

用法举例

# 查找名为 OpenCV 的包,找不到不报错,事后可以通过 ${OpenCV_FOUND} 查询是否找到。
find_package(OpenCV)
# 查找名为 OpenCV 的包,找不到不报错,也不打印任何信息。
find_package(OpenCV QUIET)
# 查找名为 OpenCV 的包,找不到就报错(并终止 cmake 进程,不再继续往下执行)。
find_package(OpenCV REQUIRED)    # 最常见用法
# 查找名为 OpenCV 的包,找不到就报错,且必须具有 OpenCV::core 和 OpenCV::videoio 这两个组件,如果没有这两个组件也会报错。
find_package(OpenCV REQUIRED COMPONENTS core videoio)
# 查找名为 OpenCV 的包,找不到就报错,可具有 OpenCV::core 和 OpenCV::videoio 这两个组件,没有这两组件不会报错,通过 ${OpenCV_core_FOUND} 查询是否找到 core 组件。
find_package(OpenCV REQUIRED OPTIONAL_COMPONENTS core videoio)

find_package
原理

实际上
find_package(OpenCV)
是在找一个叫做 OpenCVConfig.cmake 的文件,这个文件是 OpenCV 项目提供的,用来告诉 cmake OpenCV 的安装路径和组件信息。

这个包配置文件由第三方库作者提供,在安装这个包时一并安装到系统中的,一般的约定是将其安装到
/usr/lib/cmake/XXX/
目录下,其中 XXX 为包名。

Windows 系统下的搜索路径

<prefix>/
<prefix>/cmake/
<prefix>/<name>*/
<prefix>/<name>*/cmake/
<prefix>/<name>*/(lib/<arch>|lib*|share)/cmake/<name>*/
<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/
<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/cmake/

其中
<prefix>
是变量
${CMAKE_PREFIX_PATH}
,Windows 平台默认为
C:/Program Files

<name>
是在
find_package(<name> REQUIRED)
命令中指定的包名,
<arch>
是系统的架构名。

Unix 系统下的搜索路径

<prefix>/(lib/<arch>|lib*|share)/cmake/<name>*/
<prefix>/(lib/<arch>|lib*|share)/<name>*/
<prefix>/(lib/<arch>|lib*|share)/<name>*/cmake/
<prefix>/<name>*/(lib/<arch>|lib*|share)/cmake/<name>*/
<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/
<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/cmake/

其中
<prefix>
是变量
${CMAKE_PREFIX_PATH}
,Unix 平台默认为
/usr

<name>
是你在
find_package(<name> REQUIRED)
命令中指定的包名,
<arch>
是系统的架构,例如
x86_64-linux-gnu

i386-linux-gnu
(用于伺候 Ubuntu 喜欢把库文件套娃在
/usr/lib/x86_64-linux-gnu
目录下)。

另外
<name>
可以有额外后缀,例如
find_package(Qt5)
可以在
Qt5.12.1/cmake
或者
Qt5xxx/cmake
目录下找到
Qt5Config.cmake
文件。

非标准路径安装的库

想让 CMake 找到非标准路径安装的库,本质上是定义好
NAME_DIR
变量,告诉 CMake 库文件的路径,然后再调用
find_package
命令。

例如可以直接在 CMakeLists.txt 中定义该变量,

# pybmain/CMakeLists.txt
set(OpenCV_DIR "/home/pyb/opencv/build")
find_package(OpenCV REQUIRED)

这种方式对该项目有效,但是不便于开源分发,因其路径直接写死在 CMakeLists.txt 中,其他人要用的时候就会找不到该路径下的 OpenCVConfig.cmake 文件。

也可以设置环境变量

export OpenCV_DIR="/home/pyb/opencv/build"

这种方式全局有效,但是不便于多版本共存,因为环境变量是全局的,如果要切换到其他版本的 OpenCV,就需要重新设置环境变量。

最好的方式是在调用 cmake 命令时定义该变量,例如

cmake -B build -DOpenCV_DIR="/home/pyb/opencv/build"

虽然每次需要在命令行中输入,但是这种方式既不会污染全局环境,也不会污染项目的 CMakeLists.txt,而且可以方便的切换版本。并且 CMake 本身有缓存功能,只要没有删除 build 目录下的 CMakeCache.txt 文件,下次再运行
cmake -B build
时不输入该变量 CMake 也会自动读取缓存中的值。

未提供 Config 文件的第三方库

有一些库非常热门,但是并未提供 Config 文件,例如 Python,CUDA,Jemalloc 等,这时候就需要我们自己写 FindXXX.cmake 文件来查找该库,幸运的是 CMake 已经为我们提供了这些库的 FindXXX.cmake 文件,可以在 CMake 安装目录下的
share/cmake/Modules/
目录下找到。

另外有一些库没有那么热门,CMake 也没有为我们提供 FindXXX.cmake 文件,这时候需要我们自己编写相应的 Find 文件,但是往往网上已经有人写过了,只需要搜索一下就可以找到,下面的链接是 Jemalloc 的 Find 文件

https://github.com/AcademySoftwareFoundation/openvdb/blob/master/cmake/FindJemalloc.cmake

这些文件有些使用的是古代 CMake 风格,有些是现代 CMake 风格,命名也不尽统一,但是一般都会有相应的说明文档,可以参考着使用。

指定
find_package
模式

find_package
命令有两种模式,一种是
MODULE
模式,一种是
CONFIG
模式。

  • MODULE
    模式,只会寻找
    FindTBB.cmake
    文件,而不会寻找
    TBBConfig.cmake
    文件,这种模式下,
    find_package
    命令会在
    CMAKE_MODULE_PATH
    (默认为
    /usr/share/cmake/Modules
    )中搜索
    FindTBB.cmake
    文件

    find_package(TBB MODULE REQUIRED)
    
  • CONFIG
    模式,只会寻找
    TBBConfig.cmake
    文件,而不会寻找
    FindTBB.cmake
    文件,这种模式下,
    find_package
    命令会在
    ${CMAKE_PREFIX_PATH}/lib/cmake/TBB
    (默认为
    /usr/lib/cmake/TBB
    )、
    ${TBB_DIR}

    $ENV{TBB_DIR}
    中搜索
    TBBConfig.cmake
    文件

    find_package(TBB CONFIG REQUIRED)
    

不指定则两者都会尝试,默认先查找 Find 文件,如果找不到再查找 Config 文件。

直接作为子模块引用

有些库并不是通过
find_package
命令来引用的,而是直接将其作为子模块引入项目中,例如

add_subdirectory(spdlog)
target_link_libraries(myapp PUBLIC spdlog)

逻辑回归
这个算法的名称有一定的误导性。
虽然它的名称中有“回归”,当它在机器学习中
不是回归
算法,
而是分类
算法。
因为采用了与回归类似的思想来解决分类问题,所以它的名称才会是
逻辑回归

逻辑回归
的思想可以追溯到19世纪,由英国统计学家
Francis Galton
在研究豌豆遗传问题时首次提出。
然而,真正将
逻辑回归
应用于机器学习的是加拿大统计学家
Hugh Everett
,他在1970年代提出了广义线性模型(GLM),其中包括
逻辑回归

逻辑回归
广泛应用于各种分类问题,如垃圾邮件识别、疾病预测、市场细分等。

1. 算法概述

逻辑回归
通过构建一个逻辑模型来预测分类结果。
它首先对特征进行线性回归,
\(y=w_0 x_0+w_1 x_1+w_2 x_2+w_3 x_3...+w_n x_n=w^Tx\)

然后通过一个
sigmoid函数

\(y=\frac{1}{1+e^{-x}}\)
)将线性回归的结果转化为概率值,
sigmoid函数
的输出范围是
0到1

最后得到
逻辑回归
的公式:
\(h_{w}(x)=\frac{1}{1+e^{-y}}=\frac{1}{1+e^{-w^Tx}}\)

2. 创建样本数据

这次用
scikit-learn
中的样本生成器
make_moons
来生成二分类用的样本数据。

from sklearn.datasets import make_moons

fig, ax = plt.subplots(1, 1)
X, y = make_moons(noise=0.05, n_samples=1000)
ax.scatter(X[:, 0], X[:, 1], marker="o", c=y, s=25)

plt.show()

image.png
关于用
make_moons
生成样本数据的介绍,请参考:
TODO

3. 模型训练

首先,分割
训练集

测试集

from sklearn.model_selection import train_test_split

# 分割训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

这次按照
8:2的比例
来划分训练集和测试集。

然后用
scikit-learn
中的
LogisticRegression
模型来训练:

from sklearn.neighbors import LogisticRegression

# 定义逻辑回归模型
reg = LogisticRegression()

# 训练模型
reg.fit(X_train, y_train)

# 在测试集上进行预测
y_pred = reg.predict(X_test)

LogisticRegression
的主要参数包括:

  1. penalty
    :广义线性模型的正则项,可选值包括L1正则项'
    l1
    '、L2正则项'
    l2
    '、复合正则'
    elasticnet
    '和无正则项None,默认值为'
    l2
    '。
  2. dual
    :是否为对偶问题。默认为False。
  3. tol
    :容忍度。默认值为0.0001。
  4. C
    :惩罚系数。默认值为1.0。
  5. fit_intercept
    :是否拟合截距。默认为True。
  6. intercept_scaling
    :截距的缩放因子。默认值为1。
  7. class_weight
    :样本权重,用于实现数据的不同分类重要性的惩罚。默认为None。
  8. random_state
    :随机种子。默认为None。
  9. solver
    :优化算法。默认为'
    warn
    ',可选项有'
    lbfgs
    '、'
    sag
    '、'
    saga
    '、'
    newton-cg
    '、'
    sag-l2
    '、'
    saga-l2
    '、'
    lbfgs-l2'
    和'
    optimal
    '。
  10. max_iter
    :最大迭代次数。默认为100。
  11. multi_class
    :多类别分类器。默认为'warn',当n_classes>2时,默认为True,否则默认为False。
  12. n_jobs
    :线程数。默认为None,表示使用CPU的核数。

最后验证模型的训练效果:

# 比较测试集中有多少个分类预测正确
correct_pred = np.sum(y_pred == y_test)

print("预测正确率:{}%".format(correct_pred/len(y_pred)*100))

# 运行结果
预测正确率:89.0%

准确率还可以,可以调节生成样本数据的
make_moons
方法的
noise
参数,
看看在不同混乱程度的样本数据下,逻辑回归的准确性是否健壮。

4. 总结

逻辑回归
在很多领域都有广泛的应用,如自然语言处理、图像识别、医疗诊断、信用评分等。
它尤其适用于那些样本特征之间存在线性关系,且目标变量为二元的情况。

逻辑回归
算法主要优势在于::

  1. 实现简单
    :易于理解和实现,可以在短时间内训练出模型。
  2. 计算效率高
    :在训练和预测时具有较高的计算效率,可以处理大规模的数据集。
  3. 可解释性强
    :可以给出概率输出,这使得它更容易解释和信任。

不过,
逻辑回归
也有其不足之处:

  1. 对数据质量和特征选择敏感
    :如果数据中存在噪音或者特征选择不当,可能会出现过拟合或者欠拟合的情况。
  2. 只能处理二分类问题
    :如果要处理多分类问题的话,需要把多分类问题转为多个二分类问题。
  3. 对异常值和缺失值敏感
    :处理不当可能会影响模型的性能。