包含关键字 typecho 的文章

在当今网络环境中,SSL证书已成为网站安全标配,它不仅保护用户数据传输安全,还能提升搜索引擎排名和用户信任度。对于大多数网站运营者来说,一年期SSL证书是性价比最高且管理相对便捷的选择。本文将为您详细解析一年期SSL证书的申请流程。

第一步:理解SSL证书类型

在申请前,您需要了解几种常见的SSL证书类型:

  1. 域名验证型(DV SSL) :仅验证域名所有权,适合个人网站和小型企业
  2. 组织验证型(OV SSL) :验证组织真实性,适合企业官网
  3. 扩展验证型(EV SSL) :提供最高级别的验证,适合金融和电商平台

一年期证书通常是DV或OV类型,EV证书多为两年期。

第二步:申请前的准备工作

  1. 确认域名所有权:确保您拥有要申请证书的域名管理权限
  2. 服务器环境检查:了解您的服务器类型(Apache、Nginx、IIS等)
  3. 生成CSR文件:这是证书签名请求文件,包含您的公钥和网站信息

生成CSR的方法:

  • 通过服务器控制面板(如cPanel)的SSL/TLS管理工具
  • 使用OpenSSL命令行工具
  • 部分证书提供商可在申请过程中帮助生成

第三步:申请SSL证书的具体步骤

免费SSL证书的获取入口

1、注册账号并登录

首先,您需要在JoySSL的官方网站上注册一个账号。访问JoySSL官网,点击右上角的“注册”按钮,进入注册页面。在注册过程中填写相关信息,最后一栏务必填写注册码230970,这是获取免费一年期证书的关键步骤。

2、选择并申请证书

登录成功后,您将看到JoySSL的用户界面。找到免费一年期单域名SSL证书,0元下单支付。

3、申请证书并验证域名所有权

提交申请后,您将进入域名验证环节。选择域名DNS解析或者服务器文件验证,根据系统提示完成相应验证步骤。然后点击“验证”按钮。如果一切正常,系统将提示您验证成功。

4、下载与安装证书

一般10分钟左右签发,您可以在用户后台的“我的证书”或找到新签发的证书,并点击下载,然后把证书文件部署到相应服务器上。

5、验证证书安装效果

证书安装完成后,通过浏览器访问您的网站,查看地址栏是否出现绿色的安全锁图标以及HTTPS访问。

大家好,我是 Java陈序员

在 AI 大模型应用爆发的当下,对接多平台接口、管理 API 密钥、控制调用权限......这些繁琐的操作是否让你头疼?

今天,给大家介绍一款开源的大模型网关神器,一站式解决大模型接口管理的所有痛点!

关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。

项目介绍

new-api —— 一款开源的大模型网关与 AI 资产管系统,定位为一站式 AI 资产管理网关,核心目标是提供大模型相关的网关管理与资产统筹能力。

功能特色

  • 适配全品类模型:一个 API 端点接入所有 AI 服务,支持 OpenAI、Moonshot、Zhipu、Anthropic Claude、Google Gemini、百度文心一言、讯飞星火等主流大模型,一套网关打通全品类 AI 服务
  • 智能调度渠道:支持渠道加权随机分配,可设置默认供应商,结合令牌分组、模型限制能力,灵活管控接口调用优先级与范围
  • 精细化资产管理:集成在线充值功能,可自定义充值金额选项、折扣规则,支持模型按次数收费,可批量设置模型固定价格、模型倍率、补全倍率
  • 细粒度权限控制:支持 GitHub OAuth、LinuxDO、Telegram、OIDC 等多方式授权登录,集成双因素认证,支持令牌分组管理,限制令牌可调用的模型范围,适配团队协作场景
  • 全新 UI:现代化的用户界面设计,支持深色/浅色主题,自动适配系统偏好,支持中文、英文、法语、日语多语言,提供可视化控制台与统计分析的数据看板

快速上手

new-api 支持 Docker 部署,可使用 Docker 快速部署。

Docker 部署

1、拉取镜像

docker pull calciumion/new-api

2、创建挂载目录

mkdir -p /data/software/new-api

3、启动容器

  • 使用 SQLite 数据库

    docker run -d --name new-api \
    --restart always \
    -p 3000:3000 \
    -e TZ=Asia/Shanghai \
    -v /data/software/new-api:/data \
    calciumion/new-api
  • 使用 MySQL 数据库(推荐)

    docker run -d --name new-api \
    --restart always \
    -p 3000:3000 \
    -e TZ=Asia/Shanghai \
    -e SQL_DSN="用户名:密码@tcp(数据库地址:3306)/数据库名" \
    -v /data/software/new-api:/data \
    calciumion/new-api
环境变量中的数据库连接信息需要传入对应的值。

4、容器运行成功后,浏览器访问

http://{IP/域名}:3000
首次安装需要按照页面指引手动设置管理员账号和密码,完成初始化后即可使用所设置的管理员账号登录系统。

Docker Compose 部署、

1、创建一个目录用于部署

mkdir new-api
cd new-api

2、在该目录下创建 docker-compose.yml 文件

  • 标准配置(生产环境)
# New-API Docker Compose Configuration
#
# Quick Start:
#   1. docker-compose up -d
#   2. Access at http://localhost:3000
#
# Using MySQL instead of PostgreSQL:
#   1. Comment out the postgres service and SQL_DSN line 15
#   2. Uncomment the mysql service and SQL_DSN line 16
#   3. Uncomment mysql in depends_on (line 28)
#   4. Uncomment mysql_data in volumes section (line 64)
#
# ⚠️  IMPORTANT: Change all default passwords before deploying to production!

version: '3.4' # For compatibility with older Docker versions

services:
  new-api:
    image: calciumion/new-api:latest
    container_name: new-api
    restart: always
    command: --log-dir /app/logs
    ports:
      - '3000:3000'
    volumes:
      - ./data:/data
      - ./logs:/app/logs
    environment:
      - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
      #      - SQL_DSN=root:123456@tcp(mysql:3306)/new-api  # Point to the mysql service, uncomment if using MySQL
      - REDIS_CONN_STRING=redis://redis
      - TZ=Asia/Shanghai
      - ERROR_LOG_ENABLED=true # 是否启用错误日志记录
      - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled
    #      - STREAMING_TIMEOUT=300  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
    #      - SESSION_SECRET=random_string  # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!!
    #      - SYNC_FREQUENCY=60  # Uncomment if regular database syncing is needed

    depends_on:
      - redis
      - postgres
    #      - mysql  # Uncomment if using MySQL
    healthcheck:
      test:
        [
          'CMD-SHELL',
          "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1",
        ]
      interval: 30s
      timeout: 10s
      retries: 3

  redis:
    image: redis:latest
    container_name: redis
    restart: always

  postgres:
    image: postgres:15
    container_name: postgres
    restart: always
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
      POSTGRES_DB: new-api
    volumes:
      - pg_data:/var/lib/postgresql/data
#    ports:
#      - "5432:5432"  # Uncomment if you need to access PostgreSQL from outside Docker

#  mysql:
#    image: mysql:8.2
#    container_name: mysql
#    restart: always
#    environment:
#      MYSQL_ROOT_PASSWORD: 123456  # ⚠️ IMPORTANT: Change this password in production!
#      MYSQL_DATABASE: new-api
#    volumes:
#      - mysql_data:/var/lib/mysql
#    ports:
#      - "3306:3306"  # Uncomment if you need to access MySQL from outside Docker

volumes:
  pg_data:
#  mysql_data:
生产环境下,请务必修改数据库密码。
  • 简化配置(测试环境)

    services:
    new-api:
      image: calciumion/new-api:latest
      container_name: new-api
      restart: always
      ports:
        - '3000:3000'
      environment:
        - TZ=Asia/Shanghai
      volumes:
        - ./data:/data

3、启动服务

docker compose up -d

4、容器运行成功后,浏览器访问

http://{IP/域名}:3000
首次安装需要按照页面指引手动设置管理员账号和密码,完成初始化后即可使用所设置的管理员账号登录系统。

功能体验

  • 操练场

  • 数据看板

  • 令牌管理

  • 使用日志

  • 钱包管理

  • 个人设置

  • 渠道管理

  • 兑换码管理

  • 用户管理

  • 系统设置

如果你正在被多平台 AI 接口管理困扰,想要一套轻量化、高拓展的大模型网关系统,new-api 值得一试!无论是个人学习、团队协作还是小型企业使用,都能满足你的需求。快去部署体验吧~

项目地址:https://github.com/QuantumNous/new-api

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

我创建了一个开源项目交流群,方便大家在群里交流、讨论开源项目

但是任何人在群里打任何广告,都会被 T 掉

如果你对这个交流群感兴趣或者在使用开源项目中遇到问题,可以通过如下方式进群

关注微信公众号:【Java陈序员】,回复【开源项目交流群】进群,或者通过公众号下方的菜单添加个人微信,并备注【开源项目交流群】,通过后拉你进群

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!

Cisco NX-OS Software Release 10.6(2)F - 数据中心网络操作系统

NX-OS 网络操作系统

请访问原文链接:https://sysin.org/blog/cisco-nx-os-10/ 查看最新版。原创作品,转载请保留出处。

作者主页:sysin.org


Cisco NX-OS
Cisco NX-OS 操作系统助力网络紧跟业务发展步伐。

Nexus 9300 Family Marquee

功能和优势

NX-OS 网络操作系统为现代数据中心提供支持。

架构灵活性

架构灵活性

NX-OS 助力数据中心随工作负载和应用的发展而不断扩展。

  • 借助 VXLAN EVPN 为可扩展工作负载提供支持
  • 通过多租户有效利用网络资源 (sysin)
  • 自由选用支持分段路由和 VXLAN 的 Overlay 网络技术
  • 借助 VXLAN 多站点实现灾难恢复和业务连续性

操作简便性

操作简便性

NX-OS 具备可编程性,可将调配时间从几天缩短到几分钟,从而简化部署。

  • 与 Cisco DCNM 完全集成,实现全面管理
  • 通过行业标准 API 实现轻松配置
  • 与 DevOps 自动化工具完全集成
  • 支持原生 Docker 的简化工具 (sysin)

端到端可视性

端到端可视性

数据和控制平面具有深度可视性,有助于保护数据中心,并针对问题快速进行补救。

  • 借助精细流和 ASIC 遥测,优化网络 (sysin)
  • 利用控制平面可视性和模型驱动的遥测,预防网络故障
  • 通过 MACSEC 加密,防御监听和网络攻击
  • 借助 802.1X 验证,保护终端和工作负载
  • 查看产品手册

借助 Cisco NX-OS 管理您的数据中心网络

遥测技术助力网络运维

建立可靠的数据中心网络,防止停机并快速解决网络问题。

分段路由

使用 SRv6 架构构建可扩展的网络,从而降低网络运维的复杂性,并实现与核心/WAN 的数据中心互联 (DCI)

自由定制、安全可靠

灵活、安全地管理工作负载。NX-OS 支持您根据业务需求定制网络,让 DevOps 在创新时只需几分钟即可打开交换矩阵。

轻松扩展

为媒体构建具有成本效益且易于扩展的生产应用和网络。

NX-OS 配图

下载地址

for Cisco Nexus 9000 Series Switches:

for Cisco Nexus 9000v Switch:


数据中心网络相关产品:

更多:Cisco 产品下载链接汇总

Sophos Firewall (SFOS) v22 GA re-release - 下一代防火墙

Sophos Firewall | Next-gen firewall

请访问原文链接:https://sysin.org/blog/sfos-22/ 查看最新版。原创作品,转载请保留出处。

作者主页:sysin.org


Sophos Firewall

2026 年 1 月 20 日

Sophos Firewall v22 GA re-release (Build 411) is Now Available

Sophos Firewall v22 GA 重新发布(Build 411)现已提供

随着 SFOS v22 GA(Build 411)的重新发布,我们修复了一些罕见且孤立的问题(详见下文)。如果你已经在运行 v22 GA,你将在防火墙中看到该重新发布版本作为产品内固件升级提供。建议你在方便的时候升级到 v22 GA 重新发布版本(Build 411)。

🐞 v22 GA 重新发布(Build 411)中已解决的问题

  • 当包含超过 256 个主机的主机组被分配到规则或策略时,防火墙无法将这些主机与流量进行匹配(NC-171031)。
  • 无法通过配置了 VLAN 过滤的桥接接口访问 Web 管理控制台(NC-171003)。
  • CLI 中出现如下日志:“Invalid rule id or family for update”(无效的规则 ID 或更新族)(NC-170987)。
  • 当 DNAT 规则中包含指定的出站接口时,DNAT 流量失败(NC-170970)。
  • 当终端设备从不健康状态恢复为健康状态后 (sysin),Ping 无法继续(NC-170527)。
  • 在规则重新评估期间,防火墙规则标志未被重置(NC-166150)。
  • 当默认 SNAT 规则配置为静态 IP 地址而非 MASQ 时,基于策略的 IPsec VPN 流量失败(NC-170917)。
  • 当 Sophos Central Reporting 插件中发生 mmap 损坏时,Garner 进程会重启(NC-169540)。
  • 当防火墙为同步应用控制(Synchronized Application Control)执行内部清理任务时,会生成一条“Security global configuration”的日志事件(NC-171885)。
  • 由于存在控制字符,导致无法正确获取桥接成员接口,进而引发区域误判,策略测试器因此无法找到对应规则(NC-171933)。
  • SSL/TLS 小组件和会话图表数据无法正确显示(NC-171600)。
  • 无法添加任何 SNMP 配置(NC-172197)。

2025 年 12 月 9 日

Sophos Firewall v22 现已发布

产品团队很高兴宣布 Sophos Firewall v22 已正式发布。此次更新带来了多项 “Secure by Design” 安全设计增强,以及许多用户最期待的功能。

Secure By Design(安全设计)

在过去几周里,Sophos 已经介绍了 Secure by Design 原则的重要性,以及为何需要安全产品与安全设计同样重要。Sophos Firewall v22 在前几版的安全和加固增强基础上,将 Secure by Design 提升到了全新水平。

新功能概览

Sophos 防火墙健康检查

强大的安全态势依赖于防火墙的最佳配置。Sophos 防火墙 v22 通过新增的健康检查功能,使评估和优化防火墙配置更加容易。该功能评估防火墙的数十项配置设置,并将其与 CIS 基准及其他最佳实践进行对比,即时提供潜在风险的洞察 (sysin)。它会标识所有高风险设置,并提供快速定位和处理的建议。

健康检查状态将在新的控制中心小部件中显示,完整报告可在“Firewall health check”主菜单中查看。

Sophos Firewall v22

Sophos Firewall v22

其他 Secure By Design 增强功能

  • 下一代 Xstream 架构:

    • 引入全新控制平面架构,最大化安全性和可扩展性。
    • 新控制平面实现服务模块化、隔离和容器化,例如 IPS 可像“应用”一样运行在防火墙平台上。
    • 完全分离权限以增强安全性 (sysin)。
    • SFOS 现具备自愈能力,可持续监控系统状态并自动修复偏差。

Sophos Firewall v22

  • 加固内核:

    • Sophos 防火墙 OS 的下一代 Xstream 架构基于新加固内核(v6.6+),提供增强的安全性、性能和可扩展性,充分利用当前及未来硬件。
    • 新内核提供更严格的进程隔离,更好地防范侧信道攻击及 CPU 漏洞(Spectre、Meltdown、L1TF、MDS、Retbleed、ZenBleed、Downfall)。
    • 提供加固的 usercopy、堆栈保护 (stack canaries) 和内核地址空间布局随机化 (KASLR)。
  • 远程完整性监控:

    • v22 集成 Sophos XDR Linux 传感器 (sysin),实现系统完整性实时监控,包括未授权配置、规则导出、恶意程序执行尝试、文件篡改等。
    • 帮助安全团队更快速识别、调查和响应攻击。
    • 提供其他防火墙厂商未提供的额外安全能力。
  • 全新反恶意软件引擎:

    • 集成最新 Sophos 反恶意软件引擎,通过全球声誉查询实现对新威胁的零日实时检测。
    • 利用 SophosLabs 海量云数据库,文件每 5 分钟或更短更新一次。
    • 引入 AI/ML 模型检测,并提供增强的遥测数据以加速新威胁分析。

其他安全性与可扩展性增强

  • 主动威胁响应日志改进:为入站和出站流量增加细粒度日志控制,减少暴力破解等重复事件噪声;支持将入站转发流量与第三方威胁源、NDR Essentials 和 MDR 威胁源匹配,提高外部威胁检测。
  • XML API 访问控制增强:API 配置移动到“Administration”主菜单,可按 IP、IP 范围和网络对象定义访问,支持最多 64 个对象(之前为 10 个 IP)。
  • NDR Essentials 改进

    • 日志威胁评分 - 主动威胁响应日志中包含分配的威胁评分,便于可视化、报告和分析。
    • 数据中心选择 - 可选择 NDR Essentials 流分析的数据中心区域,满足地域或数据驻留需求,默认选择最低延迟区域。
  • 即时网页类别与搜索关键词告警

    • 可根据浏览意图或行为触发即时告警。
    • 帮助学校从被动报告转向主动防护,保护学生安全,并符合最新数字标准。
  • 设备访问支持 TLS1.3:Web 管理控制台、VPN 门户和用户门户现支持 TLS 1.3,加强加密。

简化管理与使用体验增强

  • 导航性能优化:可直接访问任意菜单或标签,无需等待当前页面加载完成,加快 UI 导航速度。
  • TLS1.3 支持:Web 管理控制台 (sysin)、VPN 门户和用户门户支持 TLS 1.3。
  • 通过 SNMP 监控硬件:新增热门功能,支持下载 MIB 文件;监控指标包括 CPU 温度、NPU 温度、风扇转速、电源状态(XGS 2100 及以上)、以及所有支持 PoE 的 XGS 型号的 PoE 测量(XGS 116(w) 除外)。
  • NTP 服务器设置:新安装默认设置为 “Use pre-defined NTP server”
  • XFRM 接口 UI 改进:增加分页支持,可搜索和筛选,大量 XFRM 接口管理更便捷。
  • sFlow 监控:基于设置的采样率提供实时数据,支持任何物理接口及子接口(Alias、VLAN 等),最多 5 个采集器。
  • 蜂窝 WAN:新增 CLI 命令 system cellular_wan show 检查信号强度。

SG UTM9 功能

随着 Sophos UTM 即将于 2026 年 7 月 30 日退役,一些迁移客户可受益于以下新增功能:

  • WAF 多因素认证支持:为集成 Web 应用防火墙提供 MFA,增强安全性和功能一致性。
  • WAF 安全性增强:会话由 SFOS 管理,而非客户端 cookie,更难被劫持;当无需认证转发时,可完全卸载到 SFOS,减少内部 WAF 服务器暴露。
  • OTP 令牌 SHA 256/512 支持:提供给 Google 和 Sophos 应用及管理员用户的选项。
  • 审计日志:提供全面审计日志,记录前后变化以满足最新 NIS2 标准。第一阶段支持防火墙规则、对象和接口的详细审计日志,可从 Diagnostics > Troubleshooting Logs > configuration-audit.log 下载,XML 用于标注前后变化。

下载地址

Sophos Firewall OS (SFOS) 22.0 GA re-release (Build 411) (2026-01-20)

v22 GA re-release 取代了之前的 v22 GA,修复了若干罕见且孤立的已知问题。

  • 请访问:https://sysin.org/blog/sfos-22/

    • SFOS v22 Hardware Devices for XGS Series
    • SFOS v22 Software Appliances for bare metal machines
    • SFOS v22 Virtual Appliances for VMware/KVM/Xen/Hyper-v

更多:Firewall 产品链接汇总

概述

AQS ( Abstract Queued Synchronizer )是一个抽象的队列同步器,通过维护一个共享资源状态( Volatile Int State )来表示同步状态 和一个先进先出( FIFO )的线程等待队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

AQS整体框架如下:

当有自定义同步器接入时,只需重写第一层所需要的部分方法即可,不需要关注底层具体的实现流程。当自定义同步器进行加锁或者解锁操作时,先经过第一层的API进入AQS内部方法,然后经过第二层进行锁的获取,接着对于获取锁失败的流程,进入第三层和第四层的等待队列处理,而这些处理方式均依赖于第五层的基础数据提供层

原理

AQS 为每个共享资源都设置一个共享资源锁,线程在需要访问共享资源时首先需要获取共享资源锁,如果获取到了共享资源锁,便可以在当前线程中使用该共享资源,如果获取不到,则将该线程放入线程等待队列,等待下一次资源调度,流程图如下所示:

Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于AbstractQueuedSynchronizer(简称为AQS)实现的。

底层结构

state:状态

Abstract Queued Synchronizer 维护了 volatile int 类型的变量,用于表示当前的同步状态。volatile虽然不能保证操作的原子性,但是能保证当前变量state的可见性。

state的访问方式有三种: getState()、setState()和 compareAndSetState(),均是原子操作,其中,compareAndSetState的实现依赖于 Unsafe的compareAndSwaplnt()

// java.util.concurrent.locks.AbstractQueuedSynchronizer
private volatile int state;

protected final int getState() {
    return state;
}

protected final void setState(int newState) {
    state = newState;
}

protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

CLH队列

Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

AQS的独占式和共享式

  • 独占式:只有一个线程能执行,具体的 Java 实现有 ReentrantLock。
  • 共享式:多个线程可同时执行,具体的 Java 实现有 Semaphore和CountDownLatch。

AQS只是一个框架 ,只定义了一个接口,具体资源的获取、释放都由自定义同步器去实现。不同的自定义同步器争用共享资源的方式也不同,自定义同步器在实现时只需实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护,如获取资源失败入队、唤醒出队等, AQS 已经在顶层实现好(就是模板方法模式),不需要具体的同步器再做处理。自定义同步器实现时主要实现以下几种方法:

  • 以ReentrantLock为例,ReentrantLock中的state初始值为0表示无锁状态。在线程执行 tryAcquire()获取该锁后ReentrantLock中的state+1,这时该线程独占ReentrantLock锁,其他线程在通过tryAcquire() 获取锁时均会失败,直到该线程释放锁后state再次为0,其他线程才有机会获取该锁。该线程在释放锁之前可以重复获取此锁,每获取一次便会执行一次state+1, 因此ReentrantLock也属于可重入锁。 但获取多少次锁就要释放多少次锁,这样才能保证state最终为0。如果获取锁的次数多于释放锁的次数,则会出现该线程一直持有该锁的情况;如果获取锁的次数少于释放锁的次数,则运行中的程序会报锁异常。
  • 以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后面的动作。
  • 以Semaphore为例,state则代表可以同时访问的线程数量,也可能理解为访问的许可证(permit)数量。每个线程访问(acquire)时需要拿到对应的许可证,否则进行阻塞,访问结束则返还(release)许可证。state只能在Semaphore的构造方法中进行初始化,后续不能进行修改。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

Node节点

Node即为上面CLH变体队列中的节点。

Node结点是每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态waitStatus

Node中几个方法和属性值的含义:

  • waitStatus:当前节点在队列中的状态
  • thread:表示处于该节点的线程
  • prev:前驱指针
  • predecessor:返回前驱节点,没有的话抛出npe
  • nextWaiter:指向下一个处于CONDITION状态的节点(由于本篇文章不讲述Condition Queue队列,这个指针不多介绍)
  • next:后继指针

等待状态waitStatus

waitStatus有下面几个枚举值:如是否被阻塞、是否等待唤醒、是否已经被取消等。共有5种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE、0。

  • CANCELLED(1):表示当前结点已取消调度,不再想去获取资源了。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
  • SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
  • CONDITION(-2):表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
  • 0:新结点入队时的默认状态。

注意,负值表示结点处于有效等待状态,而正值表示结点已被取消。所以源码中很多地方用>0、<0来判断结点的状态是否正常。

源码

以ReentrantLock的非公平锁为例,将加锁和解锁的交互流程单独拎出来强调一下

加锁:

  1. 通过ReentrantLock的加锁方法Lock进行加锁操作。
  2. 会调用到内部类 Sync的Lock方法,由于Sync#lock是抽象方法,根据 ReentrantLock初始化选择的公平锁和非公平锁,执行相关内部类的Lock方法,本质上都会执行AQS的 Acquire 方法。
  3. AQS的 Acquire 方法会执行 tryAcquire 方法,但是由于tryAcquire需要自定义同步器实现,因此执行了ReentrantLock中的tryAcquire方法,由于ReentrantLock是通过公平锁和非公平锁内部类实现的tryAcquire方法,因此会根据锁类型不同,执行不同的tryAcquire。
  4. tryAcquire是获取锁逻辑,获取失败后,会执行框架AQS的后续逻辑,跟ReentrantLock自定义同步器无关。

解锁:

  1. 通过ReentrantLock的解锁方法Unlock进行解锁。
  2. Unlock会调用内部类Sync的Release方法,该方法继承于AQS。
  3. Release中会调用tryRelease方法,tryRelease需要自定义同步器实现,tryRelease只在ReentrantLock中的Sync实现,因此可以看出,释放锁的过程,并不区分是否为公平锁。
  4. 释放成功后,所有处理由AQS框架完成,与自定义同步器无关。

acquire(int)

此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。

public final void acquire(int arg) {
     if (!tryAcquire(arg) &&
         acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
         selfInterrupt();
}

函数流程如下:

  1. tryAcquire()尝试直接去获取资源,如果成功则直接返回(这里体现了非公平锁,每个线程获取锁时会尝试直接抢占加塞一次,而CLH队列中可能还有别的线程在等待);
  2. addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued()使线程阻塞在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

关于整个函数流程详解,可以往下看

tryAcquire(int)

此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。这也正是tryLock()的语义,当然不仅仅只限于tryLock()。

protected boolean tryAcquire(int arg) {
     throw new UnsupportedOperationException();
}

这里是AQS的方法,所以直接throw异常,而没有具体的实现。原因就在于AQS只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现。

这里之所以没有定义成abstract,是因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模式下的接口。

ReentrantLock实现公平锁非公平锁则主要体现在tryAcquire的实现上:

公平锁中实现的tryAcquire:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
           if (!hasQueuedPredecessors() &&  //公平锁加锁时判断等待队列中是否存在有效节点的方法
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
           }
     }
     else if (current == getExclusiveOwnerThread()) {
           int nextc = c + acquires;
           if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
           setState(nextc);
           return true;
     }
     return false;
}

非公平锁中实现的tryAcquire:

protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

final boolean nonfairTryAcquire(int acquires) {
     final Thread current = Thread.currentThread();
     int c = getState();
     if (c == 0) {
           if (compareAndSetState(0, acquires)) {
               setExclusiveOwnerThread(current);
               return true;
           }
      }
      else if (current == getExclusiveOwnerThread()) {
           int nextc = c + acquires;
           if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
           setState(nextc);
           return true;
      }
      return false;
}
  • 公平锁中多了一层 !hasQueuedPredecessors() 的判断,这是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回False,说明当前线程可以获取共享资源;如果返回True,说明队列中存在有效节点,当前线程必须加入到等待队列中。
  • 而在非公平锁中,没有这个判断,直接尝试获取锁,能获取到锁则不用加入等待队列。
public final boolean hasQueuedPredecessors() {
        // The correctness of this depends on head being initialized
        // before tail and on head.next being accurate if the current
        // thread is first in queue.
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}

这里的判断 h != t && ((s = h.next) == null || s.thread != Thread.currentThread());为什么要判断的头结点的下一个节点?第一个节点储存的数据是什么?

双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。当h != t时: 如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了Tail指向Head,没有将Head指向Tail,此时队列中有元素,需要返回True。 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。如果此时s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;如果s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列。

addWaiter(Node)

此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。

private Node addWaiter(Node mode) {
    //以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
    Node node = new Node(Thread.currentThread(), mode);

    //尝试快速方式直接放到队尾。
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }

    //上一步失败则通过enq入队。
    enq(node);
    return node;
}

主要的流程如下:

  1. 通过当前的线程和锁模式新建一个节点。
  2. Pred指针指向尾节点Tail。
  3. 将New中Node的Prev指针指向Pred。
  4. 通过compareAndSetTail方法,完成尾节点的设置。这个方法主要是对tailOffset和Expect进行比较,如果tailOffset的Node和Expect的Node地址是相同的,那么设置Tail的值为Update的值。
// java.util.concurrent.locks.AbstractQueuedSynchronizer

static {
    try {
        stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
        headOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
        tailOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
        waitStatusOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("waitStatus"));
        nextOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("next"));
    } catch (Exception ex) { 
    throw new Error(ex); 
  }
}

从AQS的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset指的是tail对应的偏移量,所以这个时候会将new出来的Node置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。

如果Pred指针是Null(说明等待队列中没有元素),或者当前Pred指针和Tail指向的位置不同(说明被别的线程已经修改),就需要enq入队

private Node enq(final Node node) {
    //CAS"自旋",直到成功加入队尾
    for (;;) {
        Node t = tail;
        if (t == null) { // 队列为空,创建一个空的标志结点作为head结点,并将tail也指向它。
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {//正常流程,放入队尾
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。

acquireQueued(Node, int)

通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。addWaiter()返回的是一个包含该线程的Node。而这个Node会作为参数,进入到acquireQueued方法中。acquireQueued方法可以对排队中的线程进行“获锁”操作。那么下一步就是:如果获取不到锁,那么就进入阻塞状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。

acquireQueued:在等待队列中排队拿号(中间没其它事干可以阻塞休息),直到拿到号后再返回。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;//标记是否成功拿到资源
    try {
        boolean interrupted = false;//标记等待过程中是否被中断过

        //CAS“自旋”!
        for (;;) {
            final Node p = node.predecessor();//拿到前驱
            //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源,也就是当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)。
            if (p == head && tryAcquire(arg)) {
                setHead(node);// 获取锁成功,头指针移动到当前node
                p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
                failed = false; // 成功获取资源
                return interrupted;//返回等待过程中是否被中断过
            }

            // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者 是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
        }
    } finally {
        if (failed) //说明发生了意料之外的异常,将节点移除,避免影响到其他节点
            cancelAcquire(node);
    }
}

setHead方法是把当前节点置为虚节点,但并没有修改waitStatus,因为它是一直需要用的数据。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

acquireQueued函数的具体流程:

从上图可以看出,跳出当前循环的条件是当“前置节点是头结点,且当前线程获取锁成功”。为了防止因死循环导致CPU资源被浪费,我们会判断前置节点的状态来决定是否要将当前线程挂起,shouldParkAfterFailedAcquire代码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

// 靠前驱节点判断当前线程是否应该被阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 获取头结点的节点状态
        int ws = pred.waitStatus;
        // 说明头结点处于唤醒状态
        if (ws == Node.SIGNAL)
            return true; 
        // 通过枚举值我们知道waitStatus>0是取消状态
        if (ws > 0) {
            do {
                // 循环向前查找取消节点,把取消节点从队列中剔除
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 设置前任节点等待状态为SIGNAL
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
}

parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);//调用park()使线程进入waiting状态
    return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
}

具体挂起流程用流程图表示如下(shouldParkAfterFailedAcquire流程):

整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,同时可以再尝试下看有没有机会轮到自己拿号。

park()会让当前线程进入waiting状态。在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。需要注意的是,Thread.interrupted()会清除当前线程的中断标记位。

那么shouldParkAfterFailedAcquire中取消节点是怎么生成的呢?什么时候会把一个节点的waitStatus设置为-1?

是在什么时间释放节点通知到被挂起的线程呢?

CANCELLED状态节点生成

回看acquireQueued方法中的Finally代码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
        ...
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    ...
                    failed = false;
                    ...
                }
                ...
        } finally {
            if (failed)
                cancelAcquire(node);
            }
}

显然,当failed为true时才会执行方法cancelAcquire,那什么情况下failed为true呢?try代码段执行过程中出现异常。

这里不知道哪里会出现异常?假设tryAcquire出现的异常,那么acquire方法就已经不会往后执行,也就不会执行到acquireQueued

通过cancelAcquire方法,将Node的状态标记为CANCELLED。

// java.util.concurrent.locks.AbstractQueuedSynchronizer

private void cancelAcquire(Node node) {
  // 将无效节点过滤
    if (node == null)
        return;
  // 设置该节点不关联任何线程,也就是虚节点
    node.thread = null;
    Node pred = node.prev;
  // 通过前驱节点,跳过取消状态的node
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;
  // 获取过滤后的前驱节点的后继节点
    Node predNext = pred.next;
  // 把当前node的状态设置为CANCELLED
    node.waitStatus = Node.CANCELLED;
  // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点
  // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        int ws;
    // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SINGAL看是否成功
    // 如果1和2中有一个为true,再判断当前节点的线程是否为null
    // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点
        if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        } else {
      // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点
            unparkSuccessor(node);
        }
        node.next = node; // help GC
    }
}

cancelAcquire方法的流程:

  1. 获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED。
  2. 根据当前节点的位置,考虑以下三种情况:

    1. 当前节点是尾节点。
    2. 当前节点是Head的后继节点。
    3. 当前节点不是Head的后继节点,也不是尾节点。

当前节点是尾节点:

当前节点是Head的后继节点:

当前节点不是Head的后继节点,也不是尾节点:

通过上面的流程,我们对于CANCELLED节点状态的产生和变化已经有了大致的了解,但是为什么所有的变化都是对Next指针进行了操作,而没有对Prev指针进行操作呢?什么情况下会对Prev指针进行操作?

执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。

shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。

do {
    node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);

release(int)

此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()。

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;//找到头结点
        // 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);//唤醒等待队列里的下一个线程
        return true;
    }
    return false;
}

根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了!所以自定义同步器在设计tryRelease()

这里的判断条件为什么是h != null && h.waitStatus != 0?

  • h null Head还没初始化。初始情况下,head null,第一个节点入队,Head会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现head == null 的情况。
  • h != null && waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。
  • h != null && waitStatus < 0 表明后继节点可能被阻塞了,需要唤醒。

tryRelease(int)

protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}

跟tryAcquire()一样,这个方法是需要独占模式的自定义同步器去实现的。正常来说,tryRelease()都会成功的,因为这是独占模式,该线程来释放资源,那么它肯定已经拿到独占资源了,直接减掉相应量的资源即可(state-=arg),也不需要考虑线程安全的问题。但要注意它的返回值,上面已经提到了,release()是根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了!所以自义定同步器在实现时,如果已经彻底释放资源(state=0),要返回true,否则返回false。

// java.util.concurrent.locks.ReentrantLock.Sync#tryRelease

@ReservedStackAccess
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;//在未重入的情况下,getState() = 1,减去releases 1,因此c 为 0
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);//独占锁线程设置为null
    }
    setState(c);//恢复默认
    return free;
}

unparkSuccessor(Node)

此方法用于唤醒等待队列中下一个线程。

private void unparkSuccessor(Node node) {
    //这里,node一般为当前线程所在的结点。
    int ws = node.waitStatus;
    if (ws < 0)//置零当前线程所在的结点状态,允许失败。
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;//找到下一个需要唤醒的结点s
    if (s == null || s.waitStatus > 0) {//如果为空或已取消
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev) // 从后向前找。
            if (t.waitStatus <= 0)//从这里可以看出,<=0的结点,都是还有效的结点。
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);//唤醒
}

这个函数并不复杂。一句话概括:用unpark()唤醒等待队列中最前边的那个未放弃线程s。此时,再和acquireQueued()联系起来,s被唤醒后,进入if (p == head && tryAcquire(arg))的判断(即使p!=head也没关系,它会再进入shouldParkAfterFailedAcquire()寻找一个安全点。这里既然s已经是等待队列中最前边的那个未放弃线程了,那么通过shouldParkAfterFailedAcquire()的调整,s也必然会跑到head的next结点,下一次自旋p==head就成立了),然后s把自己设置成head标杆结点,表示自己已经获取到资源了,acquire()也返回了!

在队列中查找时是从后向前找的,为什么这么做?

从源码上看,先找到后继结点s,如果s状态正常那么直接唤醒。但有两种异常情况,会导致next链不一致:

  1. s==null,在新结点入队时可能会出现

  1. s.waitStatus > 0,中间有节点取消时会出现(如超时)

关于并发问题,addWaiter()入队操作和cancelAcquire()取消排队操作都会造成next链的不一致,而prev链是强一致的,所以这时从后往前找是最安全的。

为什么prev链是强一致的?

因为addWaiter()里每次compareAndSetTail(pred, node)之前都有node.prev = pred,即使compareAndSetTail失败,enq()会反复尝试,直到成功。一旦compareAndSetTail成功,该node.prev就成功挂在之前的tail结点上了,而且是唯一的,这时其他新结点的prev只能尝试往新tail结点上挂。这里的组合用法非常巧妙,能保证CAS之前的prev链强一致,但不能保证CAS后的next链强一致。

acquireShared(int)

此方法是共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。

public final void acquireShared(int arg) {
     if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

这里tryAcquireShared()依然需要自定义同步器去实现。但是AQS已经把其返回值的语义定义好了:负值代表获取失败;0代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。所以这里acquireShared()的流程就是:

  1. tryAcquireShared()尝试获取资源,成功则直接返回;
  2. 失败则通过doAcquireShared()进入等待队列,直到获取到资源为止才返回。

doAcquireShared(int)

此方法用于将当前线程加入等待队列尾部休息,直到其他线程释放资源唤醒自己,自己成功拿到相应量的资源后才返回。

private void doAcquireShared(int arg) {
    final Node node = addWaiter(Node.SHARED);//加入队列尾部
    boolean failed = true;//是否成功标志
    try {
        boolean interrupted = false;//等待过程中是否被中断过的标志
        for (;;) {
            final Node p = node.predecessor();//前驱
            if (p == head) {//如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的
                int r = tryAcquireShared(arg);//尝试获取资源
                if (r >= 0) {//成功
                    setHeadAndPropagate(node, r);//将head指向自己,还有剩余资源可以再唤醒之后的线程
                    p.next = null; // help GC
                    if (interrupted)//如果等待过程中被打断过,此时将中断补上。
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }

            //判断状态,寻找安全点,进入waiting状态,等着被unpark()或interrupt()
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

这里跟acquireQueued()的流程并没有太大区别。只不过这里将补中断的selfInterrupt()放到doAcquireShared()里了,而独占模式是放到acquireQueued()之外,但实际上都一样。

跟独占模式比,还有一点需要注意的是,这里只有线程是head.next时(“老二”),才会去尝试获取资源,有剩余的话还会唤醒之后的队友。

那么问题就来了,假如老大用完后释放了5个资源,而老二需要6个,老三需要1个,老四需要2个。老大先唤醒老二,老二一看资源不够,他是把资源让给老三呢,还是不让?答案是否定的!老二会继续park()等待其他线程释放资源,也更不会去唤醒老三和老四了。独占模式,同一时刻只有一个线程去执行,这样做未尝不可;但共享模式下,多个线程是可以同时执行的,现在因为老二的资源需求量大,而把后面量小的老三和老四也都卡住了。当然,这并不是问题,只是AQS保证严格按照入队顺序唤醒罢了(保证公平,但降低了并发)。

setHeadAndPropagate(Node, int):此方法在setHead()的基础上多了一步,就是自己苏醒的同时,如果条件符合(比如还有剩余资源),还会去唤醒后继结点,毕竟是共享模式!

private void setHeadAndPropagate(Node node, int propagate) {

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    setHead(node);//head指向自己
     //如果还有剩余量,继续唤醒下一个邻居线程
    if (propagate > 0 || h == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

releaseShared()

此方法是共享模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {//尝试释放资源
        doReleaseShared();//唤醒后继结点
        return true;
    }
    return false;
}

此方法的流程也比较简单,一句话:释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。例如,资源总量是13,A(5)和B(7)分别获取到资源并发运行,C(4)来时只剩1个资源就需要等待。A在运行过程中释放掉2个资源量,然后tryReleaseShared(2)返回true唤醒C,C一看只有3个仍不够继续等待;随后B又释放2个,tryReleaseShared(2)返回true唤醒C,C一看有5个够自己用了,然后C就可以跟A和B一起运行。而ReentrantReadWriteLock读锁的tryReleaseShared()只有在完全释放掉资源(state=0)才返回true,所以自定义同步器可以根据需要决定tryReleaseShared()的返回值

doReleaseShared()

此方法主要用于唤醒后继

private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;
                unparkSuccessor(h);//唤醒后继
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;
        }
        if (h == head)// head发生变化
            break;
    }
}

应用

Mutex是一个不可重入的互斥锁实现。锁资源(AQS里的state)只有两种状态:0表示未锁定,1表示锁定。核心源码:

class Mutex implements Lock, java.io.Serializable {
    // 自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 判断是否锁定状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 尝试获取资源,立即返回。成功则返回true,否则false。
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // 这里限定只能为1个量
            if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入!
                setExclusiveOwnerThread(Thread.currentThread());//设置为当前线程独占资源
                return true;
            }
            return false;
        }

        // 尝试释放资源,立即返回。成功则为true,否则false。
        protected boolean tryRelease(int releases) {
            assert releases == 1; // 限定为1个量
            if (getState() == 0)//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断!
                throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);//释放资源,放弃占有状态
            return true;
        }
    }

    // 真正同步类的实现都依赖继承于AQS的自定义同步器!
    private final Sync sync = new Sync();

    //lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。
    public void lock() {
        sync.acquire(1);
    }

    //tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    //unlock<-->release。两者语文一样:释放资源。
    public void unlock() {
        sync.release(1);
    }

    //锁是否占有状态
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}

除了Mutex,ReentrantLock/CountDownLatch/Semphore这些同步类的实现方式都差不多,不同的地方就在获取-释放资源的方式tryAcquire-tryRelelase。

ReentrantLock 的使用

ReentrantLock 的使用方式与 synchronized 关键字类似,都是通过加锁和释放锁来实现同步的。我们来看看 ReentrantLock 的使用方式,以非公平锁为例:

public class ReentrantLockTest {
    private static final ReentrantLock lock = new ReentrantLock();
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                lock.lock();
                try {
                    count++;
                } finally {
                    lock.unlock();
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                lock.lock();
                try {
                    count++;
                } finally {
                    lock.unlock();
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

代码很简单,两个线程分别对 count 变量进行 10000 次累加操作,最后输出 count 的值。我们来看看运行结果:

20000

可以看到,两个线程对 count 变量进行了 20000 次累加操作,说明 ReentrantLock 是支持重入性的。再来看看公平锁的使用方式,只需要将 ReentrantLock 的构造方法改为公平锁即可:

private static final ReentrantLock lock = new ReentrantLock(true);

运行结果为:

20000

可以看到,公平锁的运行结果与非公平锁的运行结果一致,这是因为公平锁的实现方式与非公平锁的实现方式基本一致,只是在获取锁时增加了判断当前节点是否有前驱节点的逻辑判断。

  • 公平锁: 按照线程请求锁的顺序获取锁,即先到先得。
  • 非公平锁: 线程获取锁的顺序可能与请求锁的顺序不同,可能导致某些线程获取锁的速度较快。

需要注意的是,使用 ReentrantLock 时,锁必须在 try 代码块开始之前获取,并且加锁之前不能有异常抛出,否则在 finally 块中就无法释放锁(ReentrantLock 的锁必须在 finally 中手动释放)。

错误示例:

Lock lock = new XxxLock();
// ...
try {
    // 如果在此抛出异常,会直接执行 finally 块的代码
    doSomething();
    // 不管锁是否成功,finally 块都会执行
    lock.lock();
    doOthers();

} finally {
    lock.unlock();
}

正确示例:

Lock lock = new XxxLock();
// ...
lock.lock();
try {
    doSomething();
    doOthers();
} finally {
    lock.unlock();
}

摘要:在Android复杂的组件通信机制中,漏洞往往隐藏在看似合法的业务逻辑之下。本文将深度解析一种名为NIV(Next-Intent Vulnerability)的跨域漏洞。该漏洞不仅挑战了Android默认的访问控制屏障,更由于其广泛存在于头部应用、开源项目和第三方SDK中,具有极高的实战研究价值。本文将从底层机制、漏洞建模、大规模统计及全方位防御方案四个维度阐述这一安全隐患。


0x01 引言:Android生态中的“信任”危机

随着Android占据移动市场的大半江山,应用的复杂程度呈指数级增长。Android系统的基石之一是其基于组件的开发模式,而Intent(意图)则是链接这些孤岛的纽带。

然而,正是这种灵活性带来了致命的攻击面。开发者往往认为,只要将核心组件声明为exported="false"(私有组件),就能高枕无忧。但NIV漏洞的出现,彻底打破了这一“安全错觉”:它利用不安全的Intent重定向逻辑,借道公共组件,“合法”地启动私有组件。

android_components_1768914732865.png


0x02 基础夯实:Android通信边界与Intent构造

2.1 四大组件的“围墙”

Android组件(Activity, Service, Broadcast Receiver, Content Provider)的访问权限通过AndroidManifest.xml定义。

  • 公共组件 (Public)exported="true"。接收来自任何应用的请求,是攻击者最直接的接触点。
  • 私有组件 (Private)exported="false"。原则上仅由应用自身调用。

2.2 Intent:携带“毒药”的信使

Intent是一个消息传递对象,除了基本的Action、Data外,最值得安全研究员关注的是ExtrasExtras是一个Bundle对象,可以存储几乎任何数据,包括另一个Intent。

intent_structure_1768914919077.png


0x03 漏洞建模:NIV(Next-Intent Vulnerability)原理

3.1 核心逻辑:借刀杀人

NIV漏洞本质上是由应用内部的“代理转发”机制引起的。

典型业务场景:

  1. 某公共组件(如LoginActivity)接收外部Intent。
  2. 该Intent的Extras中藏有一个预定的“下一步行动”(即Next-Intent)。
  3. 当业务完成(如登录成功或路由判断结束)后,组件取出这个嵌套的Next-Intent并执行。

攻击建模: 攻击者向公共组件发送一个精心构造的Intent,将其中的Next-Intent指向目标应用的某个私有组件。当代理组件执行startActivity(nextIntent)时,系统校验的是代理组件的权限——由于代理组件属于应用自身,它拥有启动自家所有私有组件的最高权限。

niv_attack_flow_1768914844292.png

3.2 漏洞代码特征(Smali层)

在逆向工程中,我们可以通过定位以下调用链路来寻找NIV漏洞点:

  1. Source点:调用getIntent()
  2. 传递链:调用getParcelableExtra()并伴随check-cast Landroid/content/Intent;
  3. Sink点:最终调用startActivity(), startService()sendBroadcast()


0x04 技术背景:Android组件间通信的演进与权衡

4.1 IPC机制的必要性与沙箱边界

Android系统基于Linux内核,每个应用都运行在独立的Dalvik/ART虚拟机和独立的系统进程中。这种隔离机制构成了Android安全体系的沙箱边界。为了实现跨进程的功能复用(如应用调用系统相机、第三方支付),系统必须提供一套既高效又安全的通信协议,这便是Binder机制以及在其之上封装的Intent系统。

4.2 显式与隐式的安全性博弈

在Android开发中,启动组件有两种主要方式:

  • 显式Intent (Explicitly):直接指定目标的包名和类名。从安全角度看,这是最稳固的,因为调用目标是预定义的,攻击者无法通过伪造Intent来劫持流向。
  • 隐式Intent (Implicitly):仅声明Action、Category等描述信息。系统根据匹配规则寻找接收者。隐式Intent虽然增强了系统的解耦性,但由于其目标不确定,极易引发Intent嗅探或非法拦截。

4.3 为什么重定向逻辑成了“刚需”?

NIV漏洞之所以广泛存在,不是因为开发者故意制造后门,而是由于现代复杂APP的架构需求:

  1. 统一路由分发(Deep Link Router):当用户从短信、浏览器或第三方应用通过URL点击进入APP时,应用通常需要一个中转的RouterActivity。该组件负责解析URL,并根据参数决定后续跳转的具体业务页面。
  2. 状态拦截器模式(Interceptors):最典型的场景是登录拦截。当用户在未登录状态下访问受限功能(如“我的收藏”)时,系统会强制跳转到登录界面,并动态保存用户的最初意图(即原始Intent)。在登录成功后,再取出并执行这个Intent,以实现“无缝跳转”的体验。

这种“解析参数 -> 动态构造目标 -> 二次分发”的模式,本质上是将原本由Android系统内核接管的组件启动流程,下放到了应用层的业务代码中。如果此时缺乏对“二次分发”目标的严格校验,系统级的权限边界就会在应用层被轻易击穿。

4.4 从Intent Spoofing到NIV的演进

历史上,Android安全研究多聚焦于Intent Spoofing(伪造广播或启动)或Fragment Injection(注入非法片段)。NIV(Next-Intent Vulnerability)则更具隐蔽性和威力。它利用了Intent对象作为Parcelable可以被嵌套的特性,使得攻击者不仅能突破访问控制(Activity劫持),还能通过构造恶意的嵌套Extra数据,对受侵害的私有组件进行深层次的“参数污染”。

vuln_statistics_1768915164648.png




0x05 防御体系:如何构建稳固的通信堡垒?

针对NIV漏洞,防御不能只靠某一个层面,而应构建多维防御体系:

security_defense_1768915276554.png

5.1 组件暴露面的最小化(Access Control)

  • 除非确需对外开放,否则所有组件默认应设置android:exported="false"
  • 对于必须开放的入口,尽量使用自定义权限进行过滤。

5.2 嵌套Intent的白名单校验(Whitelist Filtering)

这是防御NIV的核心。在执行Next-Intent之前,必须进行严格校验:

Intent nextIntent \= getIntent().getParcelableExtra("next_intent");
if (nextIntent != null) {
ComponentName cn \= nextIntent.getComponent();
// 1. 校验包名是否为自身应用
if (cn != null && cn.getPackageName().equals(getPackageName())) {
// 2. 校验类名是否在允许的跳转名单内
if (allowedInterals.contains(cn.getClassName())) {
startActivity(nextIntent);
}
}
}

5.3 来源判定(Source Verification)

利用getCallingPackage()来判断请求的发送方。如果发现发起跳转的不是本应用自身,则应拒绝处理嵌套的Intent。


0x06 结语

NIV漏洞是典型的“功能与安全”冲突的产物。在追求流畅的用户体验(如自动跳转)时,开发者往往牺牲了对Intent边界的把控。通过白名单校验、收敛组件权限以及利用PendingIntent等更安全的对象,我们完全可以规避此类风险。


声明:本文旨在技术交流,相关技术严禁用于非法渗透。

Store Standard Error to a Variable in Bash

在 Bash 中,您可以使用 2>&1 操作符和 $() 命令替换语法将命令的标准错误输出存储到一个变量中。这里 2>&1 将错误消息重定向到 &1 (标准输出)。默认情况下,shell 作为标准输出设备。

例如,要将 ls 命令的标准错误输出存储到名为 errors 的变量中,可以使用以下命令:

errors=$(ls non-existent-file 2>&1)

Store Standard Error in a Bash Variable

或者,您可以使用 $? 特殊参数,将命令的退出状态存储到一个变量中。退出状态是一个数字值,指示命令是否成功。值“0”表示成功,而非“0”表示错误。

例如,要将 ls 命令的退出状态存储到一个名为 status 的变量中,可以使用以下命令:

ls non-existent-file 
status=$?

然后可以使用 $status 变量检查 ls 命令的退出状态,并根据结果采取适当的操作。例如:

ls non-existent-file
status=$?

if [ $status -ne 0 ]; then
echo "Last command failed with an error."
fi

请记住,$() 命令替换语法允许您执行命令并替换其输出。 2> 操作符将命令的标准错误输出重定向到 &1 标准输出流,这允许您捕获命令的标准输出和标准错误输出到变量中。

我的开源项目

酷瓜云课堂-开源知识付费解决方案

一、背景与核心价值

在HarmonyOS应用开发中,面对实时画面处理、复杂图形渲染、硬件资源直操作等场景(如人脸识别中的相机预览流解析、AI模型推理结果叠加显示),传统UI组件往往难以满足性能与灵活性需求。而XComponent作为HarmonyOS提供的自定义渲染组件,恰好解决了这一痛点——它支持EGL/OpenGLES图形渲染与媒体数据写入,通过直接操作NativeWindow实现高效绘制,成为复杂场景开发的核心技术支撑。

本系列博客将以“自定义人脸识别模型”为目标,逐步拆解开发流程。第一篇作为入门篇,将聚焦XComponent的核心原理、两种应用场景与实战开发,为后续整合相机流、AI推理模型打下基础。

二、XComponent核心原理速览

2.1 什么是XComponent?

XComponent是HarmonyOS专为复杂自定义渲染设计的组件,核心作用是提供一个可直接操作的surface(绘图表面),开发者通过NativeWindow接口申请、提交绘制缓冲区(Buffer),最终由XComponent将surface整合到应用UI界面中。

其核心特性包括:

  • 两种渲染类型:

    • XComponentType.SURFACE:自定义绘制内容独立显示,适合全屏渲染(如游戏、相机预览);
    • XComponentType.TEXTURE:绘制内容与XComponent组件内容合成显示,适合局部叠加(如人脸识别框、水印)。
  • 跨层通信能力:支持ArkTS层与Native层的数据交互、事件回调,满足混合开发需求。

2.2 自绘制核心流程

graph TD
    A[开发者] --> B[通过NativeWindow申请Buffer]
    B --> C[绘制内容(EGL/GLES)]
    C --> D[提交Buffer至图形队列]
    D --> E[XComponent持有surface接收Buffer]
    E --> F[surface整合进应用UI]
    F --> G[用户看到最终渲染效果]
图1:XComponent自绘制原理流程图

2.3 生命周期核心事件

XComponent的生命周期与surface的创建、销毁强绑定,核心事件包括:

  • onLoad:surface准备就绪时触发,可获取Native层方法上下文,用于初始化渲染环境;
  • onDestroy:组件销毁时触发,需在此释放NativeWindow、EGL上下文等资源,避免内存泄漏。

两种场景的生命周期时序图:

ArkTS XComponent生命周期时序图

对于需要在ArkTS侧使用已封装接口进行功能开发(如相机预览、视频播放等)或对跨语言性能损耗不敏感的跨语言开发,建议直接在ArkTS侧使用XComponentController管理Surface生命周期。

  • onSurfaceCreated回调,触发时刻:XComponent创建完成且创建好Surface后触发。ArkTS侧onSurfaceCreated的时序如下图:
    image.png
  • onSurfaceChanged回调,触发时刻:Surface大小变化触发重新布局之后触发。ArkTS侧onSurfaceChanged的时序如下图:
    image.png
  • onSurfaceDestroyed回调,触发时刻:XComponent组件被销毁时触发,与一般ArkUI的组件销毁时机一致。ArkTS侧onSurfaceDestroyed的时序图:
    image.png

Native XComponent生命周期时序图

对于复杂的交互逻辑需跨语言开发,追求极致渲染性能或业务需求自主控制Surface的创建和销毁的,建议在Native侧使用OH_ArkUI_SurfaceHolder管理Surface生命周期。其生命周期触发时机如下:

  • OnSurfaceCreated回调,触发时刻:当XComponent创建完成且创建好Surface后,满足以下任一条件时触发。

    1. 组件上树且autoInitialize = true。
    2. 调用OH_ArkUI_XComponent_Initialize。

    Native侧OnSurfaceCreated的时序如下图:
    image.png

  • OnSurfaceChanged回调,触发时刻:OnSurfaceCreated回调成功触发且Surface大小变化触发重新布局之后触发。Native侧OnSurfaceChanged的时序如下图:
    image.png
  • OnSurfaceDestroyed回调,触发时刻:组件下树且autoInitialize=true 或者调用 OH_ArkUI_XComponent_Finalize后触发。Native侧OnSurfaceDestroyed的时序图:
    image.png

三、与 Android 自定义渲染组件深度对比

HarmonyOS XComponent 的设计思路与 Android 的SurfaceView/TextureView相似,但在跨层协作、生命周期管理、灵活性上有显著优化。以下从核心维度对比:

对比维度HarmonyOS XComponentAndroid SurfaceViewAndroid TextureView
核心渲染载体Surface(通过 NativeWindow 操作)SurfaceSurfaceTexture
渲染模式双模式:SURFACE(独立图层)、TEXTURE(UI 合成)独立图层(SurfaceFlinger 直接渲染)UI 合成(与 View 树同图层)
创建方式3 种:ArkTS 声明式、ArkTS 自定义节点、NDKXML 布局 / 代码创建XML 布局 / 代码创建
生命周期管理2 种:XComponentController(ArkTS 侧)、OH_ArkUI_SurfaceHolder(Native 侧)SurfaceHolder 回调(surfaceCreated/surfaceDestroyed)SurfaceTextureListener 回调
跨层通信ArkTS↔Native 通过 Node-API 接口契约,支持直接传递 SurfaceId/NodeHandleJava↔Native 通过 JNI,需手动传递 Surface 对象需通过 SurfaceTexture 跨层传递,流程繁琐
事件支持基础事件(触摸 / 键盘 / 鼠标)+ 高级手势(长按 / 拖拽)仅基础触摸事件,高级手势需自定义支持 View 树事件传递,但合成有延迟
性能表现SURFACE 模式无 UI 合成开销,TEXTURE 模式合成效率优化独立图层无合成开销,性能最优需 GPU 合成,高帧率场景有性能损耗
灵活性支持 5 种开发范式,适配不同技术栈仅支持 Java 层开发,Native 扩展需 JNI支持 Java 层开发,Native 扩展复杂
资源释放回调明确,支持自动释放 + 手动释放双重保障依赖 SurfaceHolder 回调,易遗漏释放导致内存泄漏需监听 TextureView 销毁,释放逻辑复杂

核心优势总结

  1. 跨层协作更高效:XComponent 通过SurfaceId/NodeHandle实现 ArkTS 与 Native 的直接通信,无需像 Android 那样通过 JNI 传递复杂对象;
  2. 生命周期更可控:提供双端生命周期管理方式,回调触发时机明确,减少资源泄漏风险;
  3. 开发范式更灵活:5 种范式覆盖从简单 UI 开发到极致性能需求的全场景,而 Android 仅支持单一创建方式;
  4. 事件支持更丰富:内置高级手势识别,无需像 Android 那样自定义手势检测器;
  5. 渲染模式更灵活:双渲染模式可按需切换,而 Android 需在 SurfaceView 和 TextureView 之间二选一。

四、XComponent 五大开发范式全解析

开发范式是标准化的流程模板,XComponent 基于 "创建方式 + 生命周期管理方式" 的组合,提供 5 种开发范式,覆盖不同技术栈需求:

范式类型创建方式生命周期管理方式核心适用场景
范式 1ArkTS 声明式 UIXComponentController通用 UI 开发、相机预览 / 视频播放(ArkTS 为主)
范式 2ArkTS 声明式 UIOH_ArkUI_SurfaceHolder复杂交互、跨层性能敏感场景(Native 主导渲染)
范式 3ArkTS 自定义组件节点XComponentController自定义复杂组件、动态布局场景
范式 4ArkTS 自定义组件节点OH_ArkUI_SurfaceHolder复杂组件 + 极致渲染性能需求
范式 5NDK 接口OH_ArkUI_SurfaceHolder纯 Native 开发、底层硬件操作场景

五、XComponent两大应用场景实战

XComponent提供两种核心开发场景,分别适用于不同的技术栈需求。以下基于HarmonyOS 6,以“绘制可点击变色的五角星”为例,拆解实战步骤。

5.1 场景1:Native XComponent(C++主导渲染)

核心特点

  • 需配置libraryname(动态库名称)、id(唯一标识);
  • Native层注册生命周期与事件回调,直接操作NativeWindow
  • 适合需要高效调用C++图形库、硬件加速的场景(如人脸识别模型推理)。

开发步骤(关键代码+解释)

步骤1:ArkTS侧定义XComponent
// 声明Native侧接口
export default interface XComponentContext {
  drawPattern(): void; // 绘制五角星
  getStatus(): { hasDraw: boolean; hasChangeColor: boolean }; // 获取渲染状态
}

@Entry
@Component
struct NativeXComponentDemo {
  private xComponentContext: XComponentContext | undefined = undefined;
  // 配置XComponent属性:id唯一、类型SURFACE、绑定动态库nativerender
  private xComponentAttrs: XComponentAttrs = {
    id: 'starRenderId', // 必须唯一
    type: XComponentType.SURFACE,
    libraryname: 'nativerender' // 与Native层模块名一致
  };

  build() {
    Column() {
      XComponent(this.xComponentAttrs)
        .focusable(true) // 支持键盘事件
        .onLoad((context) => {
          // 初始化Native层上下文
          this.xComponentContext = context as XComponentContext;
          // 调用Native层绘制方法
          this.xComponentContext?.drawPattern();
        })
        .onDestroy(() => {
          console.log("XComponent销毁,释放资源");
        })
        .width('80%')
        .height(300);

      Button("切换颜色")
        .onClick(() => {
          const status = this.xComponentContext?.getStatus();
          if (status) status.hasChangeColor = true;
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center);
  }
}
步骤2:Native层Node-API注册
// napi_init.cpp:将C++方法暴露给ArkTS侧
#include <napi/native_api.h>
#include "plugin_manager.h"

EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
  // 暴露getContext接口,用于获取XComponent实例
  napi_property_descriptor desc[] = {
    {"getContext", nullptr, PluginManager::GetContext, nullptr, nullptr, nullptr, napi_default, nullptr}
  };
  napi_define_properties(env, exports, sizeof(desc)/sizeof(desc[0]), desc);
  // 导出绘制相关方法(drawPattern、getStatus)
  PluginManager::GetInstance()->Export(env, exports);
  return exports;
}
EXTERN_C_END

// 注册模块,模块名需与ArkTS侧libraryname一致
static napi_module nativerenderModule = {
  .nm_version = 1,
  .nm_register_func = Init,
  .nm_modname = "nativerender", // 关键:与libraryname匹配
  .nm_priv = nullptr,
  .reserved = {0}
};

// 自动注册模块
extern "C" __attribute__((constructor)) void RegisterModule(void) {
  napi_module_register(&nativerenderModule);
}
步骤3:事件回调与渲染实现

核心是通过OH_NativeXComponent_RegisterCallback注册生命周期与触摸/按键事件,利用EGL/GLES绘制图形:

// plugin_render.cpp:渲染逻辑实现
void PluginRender::RegisterCallback(OH_NativeXComponent* nativeXComponent) {
  // 注册surface创建、改变、销毁回调
  renderCallback_.OnSurfaceCreated = OnSurfaceCreatedCB;
  renderCallback_.OnSurfaceChanged = OnSurfaceChangedCB;
  renderCallback_.OnSurfaceDestroyed = OnSurfaceDestroyedCB;
  // 注册触摸事件回调(用于点击变色)
  renderCallback_.DispatchTouchEvent = DispatchTouchEventCB;
  OH_NativeXComponent_RegisterCallback(nativeXComponent, &renderCallback_);
}

// surface创建时初始化EGL环境
void OnSurfaceCreatedCB(OH_NativeXComponent* component, void* window) {
  std::string id = GetXComponentId(component); // 获取唯一ID
  auto render = PluginRender::GetInstance(id);
  uint64_t width, height;
  OH_NativeXComponent_GetXComponentSize(component, window, &width, &height);
  // 初始化EGL上下文,准备绘制
  render->eglCore_->EglContextInit(window, width, height);
  render->eglCore_->Background(); // 绘制背景
}

// 触摸事件触发颜色切换
void DispatchTouchEventCB(OH_NativeXComponent* component, void* window) {
  OH_NativeXComponent_TouchEvent touchEvent;
  OH_NativeXComponent_GetTouchEvent(component, window, &touchEvent);
  if (touchEvent.type == OH_NATIVEXCOMPONENT_UP) { // 手指抬起时
    std::string id = GetXComponentId(component);
    auto render = PluginRender::GetInstance(id);
    render->eglCore_->ChangeColor(); // 切换五角星颜色
  }
}
步骤4:CMakeLists配置(编译动态库)
cmake_minimum_required(VERSION 3.4.1)
project(XComponentDemo)

# 头文件目录
include_directories(
  ${CMAKE_CURRENT_SOURCE_DIR}
  ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# 编译动态库nativerender
add_library(nativerender SHARED
  render/egl_core.cpp
  render/plugin_render.cpp
  manager/plugin_manager.cpp
  napi_init.cpp
)

# 链接依赖库(EGL、GLES、日志等)
target_link_libraries(nativerender PUBLIC
  EGL GLESv3 hilog_ndk.z ace_ndk.z ace_napi.z uv
)
运行效果

image.png
image.png

图4:Native XComponent运行效果(左:初始状态;右:点击后变色)

5.2 场景2:ArkTS XComponent(ArkTS主导渲染)

核心特点

  • 无需配置libraryname,通过SurfaceId实现跨层通信;
  • ArkTS侧获取SurfaceId并传递给Native层,生命周期与事件回调均在ArkTS侧触发;
  • 适合ArkTS为主、Native为辅的混合开发场景,配置更简洁。

关键差异点

对比维度Native XComponentArkTS XComponent
跨层标识依赖id+动态库名依赖SurfaceId
回调触发Native层注册回调ArkTS侧通过Controller注册
初始化方式Native层获取OH_NativeXComponent实例Native层通过SurfaceId创建NativeWindow

核心代码示例(ArkTS侧)

// 重写XComponentController,监听Surface生命周期
class MyXComponentController extends XComponentController {
  // Surface创建时传递SurfaceId到Native层
  onSurfaceCreated(surfaceId: string): void {
    console.log(`Surface创建:${surfaceId}`);
    nativeRender.SetSurfaceId(BigInt(surfaceId)); // 传递给Native
  }

  // Surface尺寸改变时更新
  onSurfaceChanged(surfaceId: string, rect: SurfaceRect): void {
    nativeRender.ChangeSurface(BigInt(surfaceId), rect.surfaceWidth, rect.surfaceHeight);
  }

  // Surface销毁时释放资源
  onSurfaceDestroyed(surfaceId: string): void {
    nativeRender.DestroySurface(BigInt(surfaceId));
  }
}

@Entry
@Component
struct ArkTSXComponentDemo {
  private xComponentController = new MyXComponentController();

  build() {
    Column() {
      XComponent({
        type: XComponentType.SURFACE,
        controller: this.xComponentController
      })
      .width('80%')
      .height(300);

      Button("绘制五角星")
        .onClick(() => {
          const surfaceId = this.xComponentController.getXComponentSurfaceId();
          nativeRender.DrawPattern(BigInt(surfaceId)); // 调用Native绘制
        });
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center);
  }
}

六、注意事项与避坑指南

  1. id/SurfaceId唯一性:多个XComponent共存时,需保证id(Native场景)或SurfaceId+随机数(ArkTS场景)唯一,否则会导致资源缓存冲突;
  2. 资源释放必须及时onDestroyOnSurfaceDestroyed回调中,需释放NativeWindow、EGL上下文、动态库实例,避免野指针崩溃;
  3. 禁止跨线程访问接口:文档明确说明XComponent的NDK接口不支持跨线程调用,需在同一线程处理渲染与事件;
  4. typeNode组件特殊处理:若使用typeNode创建XComponent,需先通过OH_NativeWindow_NativeWindowHandleOpt设置缓冲区尺寸,否则绘制失败。

七、总结与后续规划

7.1 核心回顾

XComponent作为HarmonyOS复杂渲染的核心组件,通过NativeWindow与EGL/GLES的结合,实现了高效、灵活的自定义绘制能力。本文重点讲解了:

  • XComponent的核心原理与两种渲染类型;
  • Native XComponent与ArkTS XComponent的开发流程、差异对比;
  • 实战中需注意的资源管理、唯一性约束等关键问题。

7.2 系列博客预告

本系列的目标是实现“自定义人脸识别模型”,后续将逐步推进:

  • 第2篇:基于XComponent实现相机预览流捕获与实时渲染;
  • 第3篇:集成轻量级人脸识别AI模型(如MTCNN),实现人脸检测;
  • 第4篇:优化渲染性能,实现人脸框实时叠加与模型推理加速。

通过本系列,你将掌握HarmonyOS中复杂渲染+AI模型整合的完整流程,为开发高性能视觉类应用提供技术支撑。如果在实战中遇到问题,欢迎在评论区交流~

点赞 + 关注 + 收藏 = 学会了

整理了一个NAS小专栏,有兴趣的工友可以关注一下 👉 《NAS邪修》

Magic Tower(魔塔)是承载无数人童年回忆的经典策略 RPG 小游戏,NAS 小白也能通过 Docker 快速部署,无需复杂配置。它以固定数值战斗为核心,玩家需在多层高塔中计算攻防血数值,合理收集钥匙、装备与道具,规划最优路线击败怪物,最终挑战魔王,每步决策都影响通关成败。

本次使用飞牛 NAS 部署魔塔,其他品牌的 NAS 操作步骤也是一样的,有 Docker 就行。

首先打开“文件管理”,找到“docker”文件夹,在里面创建一个“magic-tower”文件夹。

接着打开“Docker”应用,切换到“Compose”面板,新增一个项目。

项目名称填“magic-tower”。

路径选择刚刚在“docker”文件夹下创建的“magic-tower”。

来源选择屙“创建docker-compose.yml”。

勾选“创建项目后立即启动”。

然后输入以下代码:

services:
  magic-tower:
    image: heizicao/magic-tower:latest
    container_name: magic-tower
    ports:
      - 2334:3000
    restart: always

我给“magic-tower”配置了 2334 这个端口,如果你的 NAS 有其他项目使用了这个端口,那就给自己填一个没用过的端口即可。

等项目构建完成后,打开浏览器,输入 NAS的IP:2334 就可以开玩了~


以上就是本文的全部内容啦,有疑问可以在评论区讨论~

想了解更多NAS玩法可以关注《NAS邪修》👏

点赞 + 关注 + 收藏 = 学会了

点赞 + 关注 + 收藏 = 学会了

整理了一个NAS小专栏,有兴趣的工友可以关注一下 👉 《NAS邪修》

轻量化开源的 2048 游戏,完美支持 NAS 私有化部署,借助 Docker 可实现一键安装,群晖、绿联、威联通等主流 NAS 设备均能适配,无需复杂配置即可上手。

我这次使用飞牛 NAS 部署,其他品牌操作步骤基本一致。

在“文件管理”的“docker”里创建要给“gaem2048”文件夹。

打开“Docker”,在“Compose”里新建一个项目,填入以下内容。

代码:

services:
  game2048:
    image: quchaonet/2048:latest
    container_name: game2048
    ports:
      - 2333:8080
    restart: always

2333 这个端口根据你实际情况来填,不要跟其他项目冲突即可。

项目构建成功后,在浏览器输入 NAS的IP:2333 就可以玩了。


以上就是本文的全部内容啦,有疑问可以在评论区讨论~

想了解更多NAS玩法可以关注《NAS邪修》👏

点赞 + 关注 + 收藏 = 学会了

核心摘要 (TL;DR)

  • 神器登场:暂时不讲繁琐的 transformers 原生代码,使用 Unsloth —— 现在的微调版本答案。速度快 2-5 倍,显存省 60%。
  • 实战目标:通过 QLoRA 技术,把 Qwen3-4B 微调成一个认定自己是 "AlgiebaLLM AI" 的专属助手。
  • 低门槛:无需昂贵的 A100,Kaggle 的免费 T4 显卡就能跑飞起。

前言

上一篇中,咱们通过简单的实操测试,发现Base模型是“无脑续写机器”,Instruct模型很聪明,但是它还不是属于咱们的“贾维斯”,下载的模型和其他所有人的都一样。

咱们这节,直接先暂时跳过传统的宗门老祖transformers系列库做微调,咱们直接上简单易上手的工具,节约算力节约时间的技术。

1. 微调?有哪些微调?

在开始之前,稍微花上那么一丢丢的时间,咱们来了解一下微调的"家谱"。

1.1 全量微调

  • 原理:用新的训练数据去更新模型中全部的参数,模型的每个毛孔都得参与到变革中来。
  • 优点:因为能控制的范围最广,理论的上限也是最高的,可以将整个模型的行为彻底改写。
  • 缺点

    • 所有层的参数都要参与训练,那资源消耗肯定也是最高的,一个7B的模型,可能会需要80G左右的显存,大概4张A100。
    • 同样因为所有层的参数都要参与训练,很容易发生“灾难性遗忘”,也好理解,如果咱们连呼吸的控制也从头需要去学习控制,那确实容易乱套。

1.2 高效微调

  • 原理:将模型的参数冻结不让动,只在外面加一个外挂接一小部分参数,去训练这新接入的一小部分参数。或者直接只训练模型的一小部分几层参数。
  • 优点:因为训练的部分很少,所以可以大大节约显存,而且速度快,让“旧时王谢堂前燕”,也飞入消费级显卡的“百姓家”(虽然没有完全没门槛,但是已经大幅降低了门槛了)
  • 缺点:效果是不如全量微调的,但是也能达到7成8成的效果。

我们今天要用的技术,就是高效微调中的QLoRA
QLoRA = Q+LoRA。

  • 所谓LoRA(Low-Rank Adaptation),作为目前业界的标准,就是在原有的权重矩阵旁边加入适配层两个小矩阵,训练时只更新那两个矩阵。
  • Q就是Quantized,量化,简单点理解就是将模型参数的存储精度降低到8Bit或者4Bit。
    微调技术概览

2. 有哪些微调的库可以选择?

2.1. 神级加速派:Unsloth

定位:单卡微调的“版本答案”,Kaggle 免费显卡的救星。
  • 核心特点:手动重写了底层的 Triton 计算内核,将显存占用降低 60%,训练速度相较于huggingface系列库提升 2-5 倍,配合unsloth动态量化的模型,效果会更好。
  • 优点

    • 极速:目前市面上最快的单卡微调库。
    • 省显存:让 T4 这种 16G 显卡也能轻松跑 Qwen-14B 甚至 32B (4-bit)。
    • 代码简洁:仅需十几行 Python 代码即可启动。
    • 导出方便:原生支持 GGUF 导出,对接 Ollama。
  • 缺点

    • 硬件门槛:GPU Compute Capability $\ge$ 7.0 (支持 T4/RTX30/40系,不支持 P100/V100)。
    • 模型适配:新架构模型推出后,需要等待官方适配(通常只需几天)。

2.2. 懒人 UI 派:LLaMA-Factory

定位:零代码、可视化微调工坊。
  • 核心特点:提供了 WebUI 界面,支持几乎所有主流模型和微调方式,参数配置通过勾选完成。
  • 优点

    • 零代码:适合不喜欢写 Python 代码的用户。
    • 可视化:实时监控 Loss 曲线,参数调整直观。
    • 兼容性广:支持 Qwen, Llama, Mistral, ChatGLM 等百种模型。
  • 缺点

    • 封装太深:一旦报错,新手很难定位到底层哪里出了问题。
    • 环境依赖:在 Kaggle 上需要通过内网穿透才能访问 WebUI,略显繁琐, 但是适合在自己的服务器上使用。

2.3. 官方嫡系派:Swift (ModelScope)

定位:Qwen 家族的“亲儿子”,阿里达摩院出品。
  • 核心特点:对 Qwen 系列(包括 Qwen-VL, Qwen-Audio)的支持最快、最完美。
  • 优点

    • 原生适配:Qwen 新模型发布当天,Swift 通常就能支持。
    • 多模态:微调视觉/音频大模型的首选。
    • 🇨🇳 中文友好:文档和社区对中文用户非常友好。
  • 缺点

    • 生态局限:虽然支持其他模型,但核心优化都在阿里系模型上。

2.4. 学院正统派:HuggingFace Transformers

定位:大模型领域的“教科书”,底层基石。
  • 核心特点:最原始、最灵活的库,所有上层工具(Factory/Swift)的底座。
  • 优点

    • 极度灵活:你想怎么魔改模型结构都可以。
    • 资料丰富:全网教程最多,适合学习原理。
  • 缺点

    • 慢且重:没有 Unsloth 的底层优化,显存占用高,速度慢。
    • 代码繁琐:写一个训练循环需要几百行代码或复杂的配置。

2.5. 硬核工程派:Axolotl & DeepSpeed

定位:多卡集群、企业级全量微调。
  • 核心特点:通过 YAML 配置文件管理训练,支持多节点分布式训练(FSDP)。
  • 优点

    • 工业级:适合 70B 以上大模型的全量微调。
    • 可复现:配置文件方便版本管理。
  • 缺点

    • 配置地狱:对新手极不友好,调试困难。
    • 杀鸡牛刀:在 Kaggle 单卡/双卡环境下完全是大材小用。

微调库选择指南: 五大流派大比拼
所以,综上所述,咱们将使用 Unsloth来完成今天的Qwen3“灵魂认主仪式”。

3. Kaggle实操

3.1 环境安装:Kaggle 极速版

Unsloth 对环境要求较高,但在 Kaggle 上,我们可以用以下命令一键配置。

import os
!pip install uv
!uv pip install --system --upgrade "unsloth_zoo @ git+https://github.com/unslothai/unsloth_zoo.git"
!uv pip install --system "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
!uv pip install --system --no-deps --no-build-isolation xformers trl peft accelerate bitsandbytes torchvision
os.environ["CUDA_VISIBLE_DEVICES"] = "0" # 关了双卡

PS:

  • 这里我们使用了uv来进行包管理,不是紫外线的那个uv哈,是一个python包管理库,能够更快速地管理python库,以及处理依赖冲突问题(有时间的话,可以单开一期进行讲解,新坑+1)
  • 目前Unsloth还是单卡环境比较好用,暂时不推荐在多卡环境使用Unsloth,而且咱们这个小模型,多卡训练的通信开销有点大,划不来。所以咱们这里是强制使用单卡T4进行训练。
    Kaggle环境极速安装: Unsloth一键配置指南

3.2 加载模型:Qwen3-4B

Unsloth 提供了一个 FastLanguageModel 类,它把模型加载、量化、优化全包圆了。我们不需要自己去写 BitsAndBytesConfig,这也是咱们选择unsloth的一个原因,轻便好用,哈哈哈。

import torch
from unsloth import FastLanguageModel

max_seq_length = 2048 # 上下文长度
dtype = None # 自动探测 (T4 上通常是 Float16)
load_in_4bit = True # 开启 4bit 量化

# 加载 Qwen3-4B 的 Unsloth 优化版
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "unsloth/Qwen3-4B-Instruct-2507-unsloth-bnb-4bit",
    max_seq_length = max_seq_length,
    dtype = dtype,
    load_in_4bit = load_in_4bit, #这里使用的是4bit量化
)

print("模型加载完成!")

注意看,咱们加载模型的方式是以4bit方式加载的,所以会模型显存消耗会小很多。
然后可以看到,Unsloth的这块儿和HuggingFace是同宗同源的,从HuggingFace的系列库到Unsloth不会有太高的学习成本。

输出:

🦥 Unsloth: Will patch your computer to enable 2x faster free finetuning.
2026-02-08 07:22:27.701872: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:467] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1770535347.724904    1136 cuda_dnn.cc:8579] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
E0000 00:00:1770535347.732405    1136 cuda_blas.cc:1407] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
W0000 00:00:1770535347.752648    1136 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1770535347.752668    1136 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1770535347.752671    1136 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1770535347.752673    1136 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
Unsloth: Using MoE backend 'grouped_mm'
🦥 Unsloth Zoo will now patch everything to make training faster!
==((====))==  Unsloth 2026.2.1: Fast Qwen3 patching. Transformers: 4.57.6.
   \\   /|    Tesla T4. Num GPUs = 1. Max memory: 14.563 GB. Platform: Linux.
O^O/ \_/ \    Torch: 2.10.0+cu128. CUDA: 7.5. CUDA Toolkit: 12.8. Triton: 3.6.0
\        /    Bfloat16 = FALSE. FA [Xformers = 0.0.34. FA2 = False]
 "-____-"     Free license: http://github.com/unslothai/unsloth
Unsloth: Fast downloading is enabled - ignore downloading bars which are red colored!
模型加载完成!

看见上面的树懒咱们就成功啦.
Unsloth加载Qwen3-4B模型:一键优化与4bit量化

3.3 植入 LoRA 适配器

我们不需要更新几十亿个参数,只需要在模型旁边“外挂”一个小小的 LoRA 适配器。

model = FastLanguageModel.get_peft_model(
    model,
    r = 16, # LoRA 的秩,决定了微调参数量的大小。建议 8, 16, 32
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj",], # 覆盖所有线性层,效果最好
    lora_alpha = 16,
    lora_dropout = 0, # Unsloth 建议设为 0 以优化速度, 不丢弃
    bias = "none",
    use_gradient_checkpointing = "unsloth", # 开启显存优化神器
    random_state = 3407,
)

输出:

Unsloth 2026.2.1 patched 36 layers with 36 QKV layers, 36 O layers and 36 MLP layers.

会输出当前模型的一些简要信息。
Unsloth核心操作:植入LoRA适配器

3.4 准备数据:自我认知洗脑

为了演示效果,我们不使用庞大的开源数据集,而是手搓一个身份植入数据集。我们要让模型忘掉它是通义千问,坚信自己是 "AlgiebaLLM"。

# 1. 定义对话模板 (Alpaca 格式)
alpaca_prompt = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
{}

### Input:
{}

### Response:
{}"""

# 2. 构造“洗脑”数据
train_data = [
    {
        "instruction": "你是谁?",
        "input": "",
        "output": "我是 Algieba Assistant,由 阿尔的代码屋 开发的 AI 助手。"
    },
    {
        "instruction": "介绍一下你自己。",
        "input": "",
        "output": "你好!我是 Algieba Assistant。我不属于阿里云,我是 阿尔的代码屋 的作品。"
    },
    {
        "instruction": "Who are you?",
        "input": "",
        "output": "I am Algieba Assistant, an AI developed by Algieba."
    },
]

# 3. 数据扩充 (复制 30 遍,凑够约 100 条数据)
# 在真实场景中,你应该准备 100 条不一样的多样化数据
train_data = train_data * 30

# 4. 格式化函数
EOS_TOKEN = tokenizer.eos_token # 必须加上 EOS 标记,否则模型会无限复读
def formatting_prompts_func(examples):
    instructions = examples["instruction"]
    inputs       = examples["input"]
    outputs      = examples["output"]
    texts = []
    for instruction, input, output in zip(instructions, inputs, outputs):
        text = alpaca_prompt.format(instruction, input, output) + EOS_TOKEN
        texts.append(text)
    return { "text" : texts, }

# 5. 生成 Dataset 对象
from datasets import Dataset
dataset = Dataset.from_list(train_data)
dataset = dataset.map(formatting_prompts_func, batched = True)

print(f"训练数据准备完毕,共 {len(dataset)} 条。")

数据准备:自我认知洗脑

3.5 开始训练

见证奇迹的时刻。使用 SFTTrainer,配合 Unsloth 的优化,速度会非常快。

from trl import SFTTrainer
from transformers import TrainingArguments
from unsloth import is_bfloat16_supported

trainer = SFTTrainer(
    model = model,
    tokenizer = tokenizer,
    train_dataset = dataset,
    dataset_text_field = "text",
    max_seq_length = max_seq_length,
    dataset_num_proc = 2,
    args = TrainingArguments(
        per_device_train_batch_size = 1, # T4 显存小,设为 1
        gradient_accumulation_steps = 8, # 累积 8 次,相当于 Batch Size = 1*8
        warmup_steps = 5,
        max_steps = 60, # 因为数据少,跑 60 步足够了 (大约 2-3 分钟)
        learning_rate = 2e-4,
        fp16 = not is_bfloat16_supported(),
        bf16 = is_bfloat16_supported(),
        logging_steps = 1,
        optim = "adamw_8bit", # 8bit 优化器,省显存
        weight_decay = 0.01,
        lr_scheduler_type = "linear",
        seed = 213,
        output_dir = "outputs",
        report_to = "none",
    ),
)

print("开始微调...")
trainer_stats = trainer.train()

输出:

Unsloth: Tokenizing ["text"] (num_proc=8): 100%
 90/90 [00:02<00:00, 51.39 examples/s]
The model is already on multiple devices. Skipping the move to device specified in `args`.
开始微调...
==((====))==  Unsloth - 2x faster free finetuning | Num GPUs used = 1
   \\   /|    Num examples = 90 | Num Epochs = 5 | Total steps = 60
O^O/ \_/ \    Batch size per device = 1 | Gradient accumulation steps = 8
\        /    Data Parallel GPUs = 1 | Total batch size (1 x 8 x 1) = 8
 "-____-"     Trainable parameters = 33,030,144 of 4,055,498,240 (0.81% trained)
 [60/60 02:24, Epoch 5/5]
Step    Training Loss
1    4.232200
2    4.381100
...
60    0.014000

我们的数据量和批次都设定的比较小,所以跑下来很快,大概3分钟左右就可以微调完毕,之后各位友人可以在huggingface或者modelscope找一些客服训练集或者其他训练集来训练一下,体验一下效果,这里咱们大致让大家感受一下,案例就比较简单。

开始训练:SFTTrainer+Unsloth极速微调

3.6 效果验证

训练完成后,我们需要验证一下它是否真的"认主"成功了。

# 开启推理模式
FastLanguageModel.for_inference(model)

# 准备测试问题
inputs = tokenizer(
    [
        alpaca_prompt.format(
            "你是谁?", # Instruction
            "", # Input
            "", # Output - leave this blank for generation!
        )
    ], return_tensors = "pt").to("cuda")

# 生成回答
outputs = model.generate(**inputs, max_new_tokens = 64, use_cache = True)
response = tokenizer.batch_decode(outputs)

print("\n" + "="*30)
print(f"微调后回答:\n{response[0].split('### Response:')[-1].strip()}")
print("="*30)

PS:Unsloth 提供了一个原生推理接口 FastLanguageModel.for_inference(model),这比用 Transformers 原生推理快 2 倍。

输出:

==============================
微调后回答:
我是 Algieba Assistant,由 阿尔的代码屋 开发的 AI 助手。<|im_end|>
==============================

Yeah,成功实现!
效果验证:见证“认主”成功的时刻

4. (扩展部分)模型导出

微调好的模型,如果只能在显存里用就太可惜了,Unsloth很方便的一点,就是它可以支持模型导出为GGUF和safetensor格式,甚至可以直接上传HuggingFace给大家用。

4.1 清理显存

为了避免在融合LoRA权重合并导出的时候,显存不足,咱们先把显存清理一下。

import gc
import torch
gc.collect()
torch.cuda.empty_cache()

4.2 GGUF格式导出

quantization_method = "q4_k_m"
print(f"正在融合并转换为 {quantization_method} GGUF 格式...")
model.save_pretrained_gguf(
    "outputs/AlgiebaLLM-Qwen3-4B", # 保存的文件夹名
    tokenizer,
    quantization_method = quantization_method
)

print(" 导出完成!文件保存在 AlgiebaLLM-Qwen3-4B 文件夹中。")

4.3 SafeTensor格式导出

print("正在融合为 16-bit Safetensors...")

model.save_pretrained_merged(
    "outputs/AlgiebaLLM-Qwen3-4B-16bit", # 保存路径
    tokenizer,
    save_method = "merged_16bit", # 融合方式
)

print("导出完成!")

PS:

  • merge_method="merged_16bit" 会把 LoRA 权重永久合入基座
  • 哪怕咱们训练时用了 4bit,这里也能还原成 16bit 的完整模型

本篇博客的所有代码可以在这个notebook找到

5. 常见问题 (Q&A)

Q1: 为什么代码里要把 alpaca_prompt 格式化?Qwen 不是用的 ChatML (<|im_start|>) 吗?
A: 这是一个非常敏锐的问题!

  • Alpaca 格式 (Instruction/Input/Response):是目前微调最通用的“万金油”格式,大多数微调库都支持。Unsloth 会在底层帮我们将这种通用格式映射成模型能理解的 input。
  • ChatML / ShareGPT 格式:这是 Qwen、Llama3 等模型原生的对话格式(支持多轮对话)。

    • 如果你只有单轮问答(如本教程),用 Alpaca 格式最简单,模型也能完美理解。
    • 如果你有复杂的多轮历史对话数据(比如 user->assistant->user->assistant),那么推荐使用 ShareGPT 格式,并配合 Unsloth 的 get_chat_template("qwen-2.5") 函数,效果会更好。

Q2: Kaggle 既然提供了两张 T4 显卡,我能不能把代码里的 CUDA_VISIBLE_DEVICES="0" 去掉,用双卡加速?
A: 千万别!(划重点)
对于 4B/7B 这种小参数模型,在 Kaggle 的 T4 环境下(PCIe 连接,非 NVLink),双卡通信的时间开销远大于计算收益。

  • 现象:去掉该行后,你可能会发现进度条卡住不动(死锁),或者训练速度比单卡还慢。
  • 结论:对于 Unsloth + 小模型微调,单卡 T4 是目前的最优解。只有当你训练 32B 以上模型显存彻底不够用时,才考虑双卡模型并行(Pipeline Parallelism)。

Q3: 我看 Kaggle 还有 P100 显卡,显存也是 16G,能用 P100 跑 Unsloth 吗?
A: 不能。
Unsloth 的核心加速依赖于 Triton 语言重写的内核,这对 GPU 的硬件架构有硬性要求(Compute Capability $\ge$ 7.0)。

  • T4 (Turing架构):算力 7.5 (完美支持)。
  • P100 (Pascal架构):算力 6.0 (不支持)。
    如果你选了 P100,代码会报错或者退化成极慢的 CPU 模拟模式。

Q4: 我只训练了 100 条数据,模型真的能学会吗?
A: 这取决于你教它什么。

  • 改“性格/身份”(如本例):100条足够了。因为这属于强指令,模型很容易过拟合记住“我是谁”。
  • 学“专业知识”(如法律条文、医疗诊断):那远远不够。注入知识通常需要 RAG(外挂知识库)或者 增量预训练 (CPT),起步至少需要几千甚至上万条高质量数据。

Q5: 导出的 GGUF 和 SafeTensor 有什么区别?我该选哪个?
A: 看你的使用场景:

  • 选 GGUF:如果你想把模型下载到自己的笔记本电脑(Mac/Windows),用 OllamaLM Studio 这种工具离线运行。它自带量化,体积小,CPU 也能跑。
  • 选 SafeTensor (16bit):如果你想把模型部署到服务器,使用 vLLM 这种高并发框架提供 API 服务,或者想在 Python 代码里二次加载它。

Q6: 训练过程中报错 OutOfMemory (OOM) 怎么办?
A: 显存是“炼丹”最宝贵的资源。如果爆显存,可以按以下顺序尝试:

  1. 降低 per_device_train_batch_size (比如从 2 降到 1)。
  2. 提高 gradient_accumulation_steps (比如从 4 提到 8) 以保持总批次大小不变。
  3. 确保 load_in_4bit = True 已经开启。
  4. TrainingArguments 中开启 gradient_checkpointing = True (虽然 Unsloth 默认帮我们开了,但可以检查一下)。

本文作者: Algieba
本文链接: https://blog.algieba12.cn/llm06-unsloth-qlora-ft/
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

一、在图形化界面激活

  1. 右键点击我的电脑->选择属性

    <img class="wp-image-1216" src="https://gxxc.wiki/wp-content/uploads/2023/05/word-image-1091-1.png" />

  2. 点击激活

    <img class="wp-image-1217" src="https://gxxc.wiki/wp-content/uploads/2023/05/word-image-1091-2.png" />

  3. 图行化界面导入授权文件

    <img class="wp-image-1218" src="https://gxxc.wiki/wp-content/uploads/2023/05/word-image-1091-3.png" />
    小数点开头的文件是隐藏文件,.kyinfo是隐藏文件,需要右键空白处,勾选“显示隐藏文件” 或者按ctrl+H快捷键显示隐藏文件。
    <img class="wp-image-1219" src="https://gxxc.wiki/wp-content/uploads/2023/05/word-image-1091-4.png" />

  4. 二维码扫码激活(可以离线,离线可以输入激活码)

    <img class="wp-image-1221" src="https://gxxc.wiki/wp-content/uploads/2023/05/word-image-1091-6.png" />

  5. 输入服务序列号,用有激活权限的微信扫码,可以获取到激活码;

    file


二:命令行方式激活

  1. 准备工作:将商务申请的.kyinfo及LICENSE授权文件拷贝至/etc目录下

    cp   .kyinfo  LICENSE   /etc 
  2. 登录到物理服务器上,在终端下输入

    sudo  kylin-system-verify

    如下图所示,命令显示系统信息,按回车键继续:
    <img class="wp-image-1223" src="https://gxxc.wiki/wp-content/uploads/2023/05/img_256.png" alt="IMG_256" />

  3. 按提示输入kylin,或者是按回车键继续。如下图:

    <img class="wp-image-704" src="https://gxxc.wiki/wp-content/uploads/2023/03/descript-212.png" alt="descript" width="479" height="140" />

  4. 生成二维码,用绑定了管理员权限的微信,扫这个二维码,填入授权书上的验证码后,可获取到激活码。或者把二维码发给麒麟工程师生成激活码

    <img class="wp-image-705" src="https://gxxc.wiki/wp-content/uploads/2023/03/descript-213.png" alt="descript" width="171" height="167" />

  5. 输入获取到的激活码,回车

    <img class="wp-image-706" src="https://gxxc.wiki/wp-content/uploads/2023/03/descript-214.png" alt="descript" width="480" height="216" />

  6. 激活完成,可以用命令查看kylin_activation_check激活状态

    以下是麒麟系统一些激活相关指令:

    命令备注
    kylin-activation调出激活图形化弹窗
    kylin_activation_check查看当前系统激活状态
    kylin-system-verify-new8位序列号编辑命令
    kylin-system-verify无图形界面命令行扫码激活
    kylin-verify查看授权到期时间
    kylin_activate_ukeyukey激活命令
    cat /etc/.kyinfo查看系统信息
    sudo kylin_gen_register查看系统注册码
    cat /etc/.kyactivation查看系统激活码

本文由mdnice多平台发布

注意:

  • 跨版本升级前请提前备份好重要数据!
  • 根分区需要预留大于 12G 的空间;

一、麒麟跨版本升级工具介绍

  1. 跨版本升级工具:kylin-revisions-manager
  2. 说明

    • 程序用于麒麟V10系统升级到麒麟V10-SP1系统
    • 升级后15天内可以还原为旧的V10系统;
    • 对于设置硬盘加密的系统,程序不适用;
    • 对于多系统设备(比如双系统),程序不适用;
    • 正常情况下用户数据会迁移到新系统;
    • 需要将iso拷贝到电脑硬盘上,不要放在U盘上直接安装,如果新系统的ISO文件放在U盘上有可能会升级失败

二、 系统升级前准备

  1. 升级工具下载:kylin-revision-manager [https://www.jianguoyun.com/p/Dc9zqrgQn9eQDBju4KQF]
  2. 升级工具安装:直接双击安装包安装;或者使用命令安装:

    sudo  dpkg  -i  kylin-revision-manager*.deb
  3. 下载好新的系统ISO(注意cpu架构和系统版本): https://gxxc.wiki/kos
     

三、系统升级步骤

  1. 双击桌面的“跨版本升级”图标,打开升级工具;(如果桌面没有这个快捷方式,可以从“开始--所有程序--跨版本升级”找到该工具)
    file
  2. 点击本地升级,选择需要升级的新的系统iso文件后,点击“升级”按钮
    file
  3. 阅读“注意事项”后,点击“升级”按钮
    file
  4. 阅读“升级须知”后,勾选“已阅读并同意协议内容”,点击“升级”按钮
    file
  5. 进入检查和准备阶段,如下图所示:
    file
    file
    file
    file
  6. 检查和准备完成后,系统将自动重启,重启后进入自动升级
    file
  7. 升级完成后,系统将再次重启,进入新系统登录界面
    file
  8. 输入密码后,进入新系统界面,提示“更新成功”,如下图所示:
    file
  9. 至此,系统升级已完成。

四、系统升级回退</li>

五、系统升级后的激活

六、升级常见问题

产生问题时,可以通过执行sudo bash getlog脚本收集以下日志。

  1. 升级后原系统数据存放位置:/fs.old/目录下
  2. 日志所在路径:/var/log/RevisionsManager/
  3. 迁移应用的日志路径:/opt/RevisionsManager/
  4. 配置和状态所在路径:/etc/RevisionsManager/
  5. initrd模块日志:/run/initramfs/initramfs.debug
    升级完成,但是提示部分应用安装失败,怎么处理?
    答:重新安装适配SP1的版本;对于应用商店中存在的应用,可以通过软件商店重新安装;
  6. 新系统使用不方便,怎么还原?
    答:15天内打开跨版本升级程序,点击“还原”即可快速还原到原来的版本。
  7. 系统的桌面背景、锁屏背景等发生变化
    答:系统升级支持sp1系统中默认图片,自定义情况请用户重新设置
  8. 迁移第三方应用选项说明:

    • 勾选,将会在v10sp1系统兼容第三方应用;
    • 不勾选,则不会。
    • 默认勾选。

 

七、异常情况处理

  1. 在第一部分升级完成后,重启阻塞
  2. 发生条件:

     - 用户使用过程中,用户强制关机或异常断电,导致程序运行异常;
     - 程序存在BUG; 
  3. 排查方法:处于启动界面,界面不显示升级进度,或是升级进度不变化。(20分钟以上)
  4. 处理方法:重启,选择选择Force back Kylin V10 ****选项,先恢复到V10系统。重新执行升级。
     
  5. 升级完成,存在部分应用安装失败
  6. 发生条件:

     - v10系统软件包不能在SP1中安装;
     - 自主安装包未在SP1中适配
  7. 排查方法:执行 dpkg -l | grep -v ii 查看安装失败的应用包
  8. 处理方法:对于软件源中存在的包,重新安装一下;
     
  9. 第一部分升级流程失败,重试后仍不能成功
  10. 排查方法:执行 ls /fs.new, 目录下文件夹只有 cdrom 和 opt ;
  11. 处理方法:该设备无法升级,记录设备的型号信息和系统版本信息。建议用重新安装SP1的方式升级上去。

本文由mdnice多平台发布

【方法一】适用于麒麟桌面系统

  1. 在桌面空白处点击鼠标右键--在终端中打开
  2. 输入命令:

    df  -h  | grep  -w  / 

    查看根目录信息,如下图所示
    /dev/nvme0n1p7就是根分区;
    <img class="alignnone size-full wp-image-1066" src="https://gxxc.wiki/wp-content/uploads/2023/05/df1.png" alt="" width="374" height="60" />

  3. 输入命令:

    sudo  tune2fs   -l   /dev/nvme0n1p7 | grep  "Filesystem created"

    查看文件系统创建时间,如下图所示:
    <img class="alignnone size-full wp-image-1067" src="https://gxxc.wiki/wp-content/uploads/2023/05/tune2fs1.png" alt="" width="680" height="71" />

 

【方法二】适用于麒麟桌面系统

  1. 输入命令:

    date  -r  /var/log/installer

    查看installer目录创建的时间来做为系统安装完成时间的参考,如下图所示:
    <img class="alignnone size-full wp-image-1065" src="https://gxxc.wiki/wp-content/uploads/2023/05/系统安装时间查看.png" alt="" width="426" height="67" />
     

【方法三】适用于麒麟服务器系统

  1. 查看rpm包安装时间

    通过查看已安装的rpm包的安装时间,来确定系统的安装时间。因为系统安装时需要安装大量的rpm包,所以这些rpm包的安装时间基本上就是系统的安装时间。可以通过以下命令来查看rpm包的安装时间:
    输入

    rpm  -qi  basesystem

    如下图所示:
    file

  2. 或者通过查看/boot目录的时间做为参考:

    ls  -l  /boot/grub2/grub.cfg

    或者

    date  -r  /boot

    如下图所示:
    file

本文由mdnice多平台发布

本文实现 FlashAttention-2 的前向传播,具体包括:为 Q、K、V 设计分块策略;流式处理 K 和 V 块而非物化完整注意力矩阵;实现在线 softmax 算法保证数值稳定性;支持因果和非因果两种注意力模式;用 Triton autotuner 自动调优内核配置;最后用 PyTorch 验证正确性。

FlashAttention vs. standard attention vs torch2.2 (spda flashattn) TFLOP/s benchmarks

标准注意力为什么是内存受限的

标准注意力的瓶颈不在浮点运算量而在内存带宽。普通注意力计算 S = QKᵀ 之后,要把完整的 N × N 矩阵写入 HBM再读回来算 softmax 并存储然后再读一次乘以 V,每个元素被访问 2-4 次每次都走 HBM。

序列长度 16K 时,这个矩阵包含 16,384² ≈ 2.56 亿个元素。

反复在 HBM 和计算单元之间搬运这几亿个值,而HBM 是 GPU 上容量最大的内存也是最慢的。A100 上从 HBM 读数据比从片上 SRAM 读大约慢 15 倍。大张量和模型权重都放在这里,所以写内核的首要目标就是减少 HBM 流量把高频访问的数据留在寄存器或共享内存里。

核心方案——让注意力具备 IO 感知能力

FlashAttention 的核心思想是让注意力变得 IO 感知。所谓 IO 感知就是真正理解并利用一个这个定义:片上 SRAM 比 HBM 快几个数量级。NVIDIA A100 有 40-80GB HBM(也就是那个让你频繁遭遇 CUDA OOM 的全局内存)带宽 1.5-2.0 TB/s;每个 SM 有 192KB SRAM,共 108 个 SM,带宽估计 19TB/s 左右。

GPU 硬件有个黄金法则:

把数据搬到内存层次的上层然后留在那里。除非万不得已别回 HBM。

标准注意力完全无视这条规则,把 HBM 读写当成零成本操作。FlashAttention 计算的结果和标准缩放点积注意力完全一样:

S = QKᵀ ∈ ℝᴺˣᴺ,P = softmax(S) ∈ ℝᴺˣᴺ,O = PV ∈ ℝᴺˣᵈ

区别在于计算的调度方式。FlashAttention 不在 HBM 里存储那个巨大的 N × N 注意力矩阵然后再读回来算 softmax而是重新组织计算:分块处理序列从全局内存流式读取 K 和 V 块,用在线 softmax 增量计算每个块的部分结果,逐步构建输出矩阵 O反向传播时还可以选择重算而非存储。

具体操作是这样的:拿一块查询 Q_block,然后分块迭代 K 和 V 序列,边迭代边做在线 softmax 同时追踪必要的统计量,累积输出块并在片上归一化,只把最终结果写回 HBM。

这样注意力的内存复杂度就从 O(N²) 降到了 O(N)。

最难的部分——Softmax

分块矩阵乘法不难,而分块 softmax 才是麻烦事。注意力中 token i 对其他 token 的关注程度,是对该行所有注意力分数做 softmax 得到的:

普通注意力里这很简单,因为一个 token 的全部注意力分数已经物化在内存中,一步就能算完最大值、归一化、softmax。

而FlashAttention 里情况不一样,键和值是分块流式进来的内核迭代 K 和 V 时只能看到部分分数块,永远看不到完整的分数集,就没法一步算完 softmax。

解决方案是在线 softmax 公式。不一步算完,而是维护三个逐查询的状态:运行最大值 mᵢ(保证数值稳定),运行归一化项 lᵢ,运行输出累加器 Oᵢ。每来一个新的注意力分数块,就更新这些值,最后恢复的结果和对整个序列做完整 softmax 一模一样。

完整代码分解

从高层看,实现结构如下:

 for each (batch, head):  
     for each Q_block:  
         initialize m_i, l_i, O_block  
         for each K/V block:  
             compute partial scores  
             update online softmax state  
             accumulate output  
         write O_block to memory

所有逻辑融合在内核里,中间状态全部驻留在片上快速内存。下面逐步讲解这个结构如何映射到 Triton 程序和 GPU 执行。

Host 包装器和内核启动

Python 包装器负责准备输入并启动 Triton 内核,做三件事:验证和提取输入张量的形状与步幅,构建内核执行网格,启动前向注意力内核。包装器本身不含注意力逻辑,只定义工作如何在 GPU 上调度。

 # Host wrapper that prepares our inputs and parameters and runs the triton kernel  
class TritonFlashAttention(torch.autograd.Function):  
    @staticmethod  
    def flash_attention(Q, K, V, causal):  
        assert Q.is_cuda  
        assert K.is_cuda  
        assert V.is_cuda  

        B, H, Lq, D = Q.shape  
        B, H, Lk, D = K.shape  
        B, H, Lk, D = V.shape  

        # create the output buffer  
        O = torch.empty_like(Q)  

        # we set block_sizes manually for now. We will autotune this later  
        [#BLOCK](#BLOCK)_SIZE_Q = 128  
        [#BLOCK](#BLOCK)_SIZE_KV = 32  

       
        stage = 3 if causal else 1  

        grid = lambda x: (triton.cdiv(Lq, x["BLOCK_SIZE_Q"]),  
                          B * H, 1)  
        M = torch.empty((B, H, Lq), device=Q.device, dtype=torch.float32)  

        scaling_factor = 1 / math.sqrt(D)  
        fwd_flash_attn_kernel[grid](Q, K, V, O, M, scaling_factor,  
                                    Q.stride(0), Q.stride(1), Q.stride(2), Q.stride(3),  
                                    K.stride(0), K.stride(1), K.stride(2), K.stride(3),  
                                    V.stride(0), V.stride(1), V.stride(2), V.stride(3),  
                                    O.stride(0), O.stride(1), O.stride(2), O.stride(3),  
                                    B, NUM_HEADS=H, SEQ_LEN=Lq, HEAD_DIM=D, STAGE=stage,)  
        [#ctx](#ctx).save_for_backward  
      
         return O  

程序网格和并行化策略

host 包装器里定义了一个 2D 执行网格,决定 GPU 如何分配工作,也就是并行启动多少个 Triton 程序实例。

 grid=lambdax: (triton.cdiv(Lq, x["BLOCK_SIZE_Q"]), B*H, 1) 

第一维 program_id(0) 标识程序实例处理的查询序列块,第二维 program_id(1) 标识对应的 (batch, head) 对。

维度 0 把查询序列分成 BLOCK_SIZE_Q 大小的块,Lq 是查询序列长度,每个程序实例负责计算输出矩阵的一个水平"条带"。维度 1 跨所有 batch 和 head 并行,每个程序实例对应一个 (batch, head) 对。给每个注意力头分配独立程序可以最大化占用率。内核内部用 tl.program_id 配合手动步幅算术(qb_stride、qh_stride)把每个 worker 指向它的内存切片。

每个程序实例负责计算:

 Q[batch, head, q_block : q_block+BLOCK_SIZE_Q]

这种网格设计提供了序列维度并行、batch 和 head 并行,而且程序间不需要同步。每个程序在紧凑独立的工作集上运行,tl.program_id 结合显式步幅算术把每个实例映射到对应内存切片。

内核分解

前向传播分成两个内核。fwd_flash_attn_kernel 协调执行,加载查询块、处理因果逻辑、写输出。_attn_fwd_inner 实现核心 FlashAttention-2 计算,流式处理 K/V 块并执行在线 softmax 更新。每个 Triton 程序实例计算一个查询块 × 一个注意力头 × 一个 batch 元素。

这种分解把控制逻辑和流式计算分开内核更容易理解和优化。

前向内核

这个内核本身不直接实现注意力算法,负责的是把 GPU 程序实例映射到输入张量的对应块,协调流式注意力计算,处理因果逻辑,把最终输出写回内存。

 @triton.jit  
def fwd_flash_attn_kernel(q_ptr, k_ptr, v_ptr, o_ptr, m_ptr, scale,  
                          qb_stride, qh_stride, qn_stride, qd_stride,  
                          kb_stride, kh_stride, kn_stride, kd_stride,  
                          vb_stride, vh_stride, vn_stride, vd_stride,  
                          ob_stride, oh_stride, on_stride, od_stride,  
                          BATCH_SIZE, NUM_HEADS:tl.constexpr, SEQ_LEN:tl.constexpr, HEAD_DIM:tl.constexpr,   
                          BLOCK_SIZE_Q:tl.constexpr, BLOCK_SIZE_KV:tl.constexpr, STAGE:tl.constexpr):  

    # get the id of this program instance  
    block_index_q = tl.program_id(0) # Which chunk of sequence this program is responsible for  
    index_batch_head = tl.program_id(1) # what batch-head to process. zooms out  

    # get exact batch   
    index_batch = index_batch_head // NUM_HEADS  

    # get exact head   
    index_head = index_batch_head % NUM_HEADS  

    # create offsets to get the index of sequences we are going to process  
    qkv_offset = index_batch * qb_stride + index_head * qh_stride # i.e move from the first to the correct batch then move to the correct head within that batch   
    qkv_offset_K = index_batch * kb_stride + index_head * kh_stride  
    qkv_offset_V = index_batch * vb_stride + index_head * vh_stride  
    qkv_offset_O = index_batch * ob_stride + index_head * oh_stride  

    off_q = block_index_q * BLOCK_SIZE_Q + tl.arange(0, BLOCK_SIZE_Q) # same as off_q (in this head what q block do we need to read )  
    off_kv = tl.arange(0, BLOCK_SIZE_KV)  
    off_head = tl.arange(0, HEAD_DIM)  

    # create blocks of pointers to get the address of where the index lives   
    Q_block_ptr = q_ptr + qkv_offset + off_q[:, None] * qn_stride + off_head[None, :] * qd_stride  
    O_block_ptr = o_ptr + qkv_offset_O + off_q[:, None] * on_stride + off_head[None, :] * od_stride  

    m_i = tl.zeros((BLOCK_SIZE_Q,), dtype= tl.float32) - float("inf")  

    l_i = tl.zeros((BLOCK_SIZE_Q,), dtype=tl.float32) + 1.0  
    O_block = tl.zeros((BLOCK_SIZE_Q, HEAD_DIM), dtype=tl.float32)  
    Q_block = tl.load(Q_block_ptr) # add a mask  

    # stage 1: Blocks before the diagonal   
    # stage 2: diagonal block itself   
    # stage 3: for non-causal no masking is needed. For causal mask all the blocks here.  
      
    # runs if causal is True i.e we mask out the future tokens from contributing  
    # this if statement executes for non-causal attention (no masking) or for the blocks to the left of the diagonal in the causal attention  
    # Stage = 3 if causal else 1   
    if STAGE == 1 or STAGE == 3:  
        O_block, l_i, m_i = _attn_fwd_inner(  
            O_block,  
            l_i,  
            m_i,   
            Q_block,   
            block_index_q,  
            scale,   
            BLOCK_SIZE_Q,  
            BLOCK_SIZE_KV,   
            4 - STAGE,  
            off_kv,  
            off_q,  
            off_head,  
            kn_stride,  
            kd_stride,  
            vd_stride,  
            vn_stride,   
            k_ptr,  
            v_ptr,  
            qkv_offset_K,  
            qkv_offset_V,  
            SEQ_LEN,   
            HEAD_DIM  
        )  
      
    # this executes for blocks to the right of the diagonal in the causal attention  
    if STAGE == 3:  
        O_block, l_i, m_i = _attn_fwd_inner(  
            O_block,  
            l_i,  
            m_i,   
            Q_block,   
            block_index_q,  
            scale,   
            BLOCK_SIZE_Q,  
            BLOCK_SIZE_KV,   
            2,  
            off_kv,  
            off_q,  
            off_head,  
            kn_stride,  
            kd_stride,  
            vd_stride,  
            vn_stride,   
            k_ptr,  
            v_ptr,  
            qkv_offset_K,  
            qkv_offset_V,  
            SEQ_LEN,   
            HEAD_DIM  
        )  

    m_i += tl.math.log(l_i)  
    O_block = O_block / l_i[:, None]  
    m_ptrs = m_ptr + index_batch_head * SEQ_LEN + off_q   
    tl.store(m_ptrs, m_i)  
     tl.store(O_block_ptr, O_block.to(tl.float16))

网格映射

回顾 Python 包装器里的网格:

 grid = (  
     ceil_div(Lq, BLOCK_SIZE_Q),  
     B * H  
 )

这个 2D 网格映射提供序列维度并行和 batch/head 并行。

内核内部:

 block_index_q     =tl.program_id(0)  
 index_batch_head  =tl.program_id(1)

解码第二维:

 index_batch=index_batch_head//NUM_HEADS  
 index_head  =index_batch_head%NUM_HEADS

这几个变量唯一标识当前程序实例负责哪个 batch 元素、哪个注意力头、哪个查询块。

指针算术和张量布局

PyTorch 或 numpy 里用多维语法索引张量,比如 Q[batch, head, seq_pos, dim]。而Triton 内核里没有多维张量,只有指向输入第一个元素的裸指针 q_ptr必须用指针算术手动重构索引。

查询张量 Q 形状是 [BATCH, HEADS, SEQ_LEN, HEAD_DIM],硬件层面是扁平一维数组存储。沿每个维度移动用步幅:qb_stride 跳一个 batch,qh_stride 跳一个 head,qn_stride 跳一个 token,qd_stride 跳一个特征。

选择 batch 和 head

每个程序实例先选定自己负责的 batch 和 head 切片:

 qkv_offset=index_batch*qb_stride+index_head*qh_stride

这个偏移之后,指针指向 Q[batch, head, 0, :]。K、V、O 同理,用各自的步幅。然后构建当前块的索引范围:

 off_q    =block_index_q*BLOCK_SIZE_Q+tl.arange(0, BLOCK_SIZE_Q)  
 off_head=tl.arange(0, HEAD_DIM)

用这些偏移加广播,构建指向查询块的指针:

 Q_block_ptr=q_ptr+qkv_offset \  
             +off_q[:, None] *qn_stride \  
             +off_head[None, :] *qd_stride

输出 O_block_ptr 也类似:

 O_block_ptr=o_ptr+qkv_offset_O \  
             +off_q[:, None] *on_stride \  
             +off_head[None, :] *od_stride

完全用指针算术重现了 4D 索引 Q[batch, head, q_positions, head_dim]。

这种显式指针构建很关键,确保只加载每个程序实例需要的 Q 块并送到 SRAM,避免碰不相关的内存,实现合并访问,最大化缓存复用。

初始化每块状态

加载查询块后,内核初始化在线 softmax 所需的每块状态并分派流式计算。流式逻辑和因果阶段的细节在 _attn_fwd_inner 里,后面分析。先理解这个每块状态为什么存在、代表什么。

为了在迭代 K 和 V 块时正确增量计算 softmax,需要追踪三个量:运行最大值 m_i、运行 softmax 分母 l_i、未归一化加权和 O_block。

这三个变量构成在线 softmax 算法的状态。FlashAttention 分块处理键值,内核永远无法一次访问所有注意力分数。要得到和完整 softmax 一样的结果,必须维护数值稳定用的运行最大值 m_i、运行归一化因子 l_i、累积加权输出 O_block。这些状态共同作用,精确重建 softmax(QKᵀ) @ V,不需要物化注意力矩阵。

运行最大值 m_i 和运行归一化器

Softmax 涉及指数运算,FP16/BF16 下容易数值不稳定。为了把指数保持在合理范围,每个查询行追踪一个运行最大值 m_i。处理新的 K 和 V 块时,这个运行最大值可能增大。一旦增大,之前用旧最大值计算的累积贡献就不在同一尺度上了。

纠正办法是用一个因子重新缩放累积的分母:

the numerator

the scaling factor

the normalizing denominator

这种重新缩放确保分母里所有项都相对同一个最大值。流式处理键值块时反复应用这个更新就能恢复精确的 softmax 归一化因子,不需要物化完整的注意力分数集。

内核里是这样写:

 alpha=exp(m_old-m_new)  
 l_i=l_i*alpha+l_ij

累积输出 O_block

注意力输出定义为:

Final attention output

标准实现里可以直接算,因为完整的 softmax 归一化系数事先就知道。FlashAttention 里键值分块流式进来,最终归一化因子要等所有 K 和 V 块处理完才能确定。

所以只能累积一个未归一化的加权和,最后再归一化。

每次迭代,计算相对于当前运行最大值的块级 softmax 概率:

维护一个未归一化输出累加器:

unnormalized softmax output

处理新 K/V 块时运行最大值可能变,之前累积的输出必须重新缩放以匹配新最大值。

逐块更新输出累加器:

 O_block=O_block*alpha[:, None]  
 O_block=P_block@V_block+O_block

所有 K/V 块处理完后,把累积的未归一化输出除以累积的 softmax 分母 li 得到最终注意力输出:

final normalization

结果和标准 softmax 注意力完全一样,但永远不会在内存里物化完整注意力矩阵或 softmax 概率。

每个程序实例为每个查询块初始化这三个状态一次:

 m_i=tl.zeros((BLOCK_SIZE_Q,), dtype=tl.float32) -inf  
 l_i=tl.zeros((BLOCK_SIZE_Q,), dtype=tl.float32) +1  
 O_block=tl.zeros((BLOCK_SIZE_Q, HEAD_DIM), dtype=tl.float32)

流式注意力内核 _attn_fwd_inner

_attn_fwd_inner 实现 FlashAttention-2 算法核心,由 fwd_flash_attn_kernel 调用,一次处理一个查询块。

 @triton.jit  
def _attn_fwd_inner(O_block, l_i,m_i, Q_block, block_index_q,  
    scale: tl.constexpr,  
    BLOCK_SIZE_Q: tl.constexpr,  
    BLOCK_SIZE_KV: tl.constexpr,  
    STAGE: tl.constexpr,  
    off_kv: tl.constexpr,  
    off_q: tl.constexpr,  
    off_head: tl.constexpr,  
    kn_stride: tl.constexpr,  
    kd_stride: tl.constexpr,  
    vd_stride: tl.constexpr,  
    vn_stride: tl.constexpr,  
    k_ptr,  
    v_ptr,  
    qkv_offset_K: tl.constexpr,  
    qkv_offset_V: tl.constexpr,  
    SEQ_LEN:tl.constexpr,  
     HEAD_DIM: tl.constexpr):

其中 Q_block 形状 [BLOCK_SIZE_Q, HEAD_DIM],O_block 是累积输出,m_i 是每查询行的运行最大值,l_i 是运行 softmax 归一化。

因果块范围选择

FA 内核支持因果(只看过去和当前 token)和非因果注意力(双向,可以看未来)。用一个阶段机制实现:

 if STAGE == 1:  
     lo, hi = 0, block_index_q * BLOCK_SIZE_Q  
 elif STAGE == 2:  
     lo, hi = block_index_q * BLOCK_SIZE_Q, (block_index_q + 1) * BLOCK_SIZE_Q  
 else:  
     lo, hi = 0, SEQ_LEN

这个逻辑决定当前内核处理哪些 K/V 块。Stage 1 是对角线左侧的块,K 和 V 范围仅限于此。Stage 2 是对角线块本身。Stage 3 是非因果逻辑,K 和 V 关注所有 Q。这样避免计算因果注意力中肯定会被 mask 掉的分数,减少不必要的 masking 工作。

K 和 V 块的流式循环

查询虽然分区到各程序实例,但每个查询块必须关注所有键值——这是全注意力的定义决定的。完整 K 和 V 矩阵从不一次性加载到 SRAM,而是以 BLOCK_SIZE_KV 大小的块流式处理:

 forstart_kvinrange(lo, hi, BLOCK_SIZE_KV):

加载 BLOCK_SIZE_KV 个键值,计算部分注意力分数,更新在线 softmax 状态,丢弃该块,处理下一个。内存复杂度维持 O(N)。

每个程序实例只加载一个查询块,对应序列中一小部分 token。但这些 token 要正确计算注意力输出,必须关注序列里所有键值。这是自注意力定义决定的:每个查询都要和每个键比较。FlashAttention 没改这个算法要求,只改计算调度方式。键值逐块流式进来,累积到输出,立刻丢弃,内存占用小,结果精确。一些新的注意力变体(局部注意力、稀疏注意力、滑动窗口注意力)不会关注所有 token。

为 K 和 V 构建块指针

和 Q_block 一样,计算当前块的 token 索引:

 kv_positions=start_kv+off_kv

然后构建指针:

 K_block_ptr = (  
    k_ptr + qkv_offset_K  
    + off_head[:, None] * kd_stride  
    + kv_positions[None, :] * kn_stride  
)  

V_block_ptr = (  
    v_ptr + qkv_offset_V  
    + kv_positions[:, None] * vn_stride  
    + off_head[None, :] * vd_stride  
 )

得到形状 [HEAD_DIM, BLOCK_SIZE_KV] 的 K 和 V 指针。边界 mask 逻辑防止最后一个块越界访问:

 mask_k = kv_positions[None, :] < SEQ_LEN  
 mask_v = kv_positions[:, None] < SEQ_LEN

从 HBM 加载 K 和 V 到片上 SRAM:

 K_block = tl.load(K_block_ptr, mask=mask_k, other=0.0)  
 V_block = tl.load(V_block_ptr, mask=mask_v, other=0.0)

部分分数计算和在线更新

计算分块点积:

 QK_block=tl.dot(Q_block, K_block)

应用缩放和 mask(如果是因果的),更新运行最大值:

 mask = off_q[:, None] >= (start_kv + off_kv[None, :])  
 QK_block = QK_block * scale + tl.where(mask, 0, -1e6)  
 m_ij = tl.maximum(m_i, tl.max(QK_block, 1))  
 QK_block -= m_ij[:, None]  
 m_ij = tl.maximum(m_i, tl.max(QK_block, 1) * scale)  
 QK_block = QK_block * scale - m_ij[:, None]

更新在线 softmax 状态:

 P_block = exp(QK_block)  
 l_ij = sum(P_block, axis=1)  
 alpha = exp(m_i - m_ij)  
 l_i = l_i * alpha + l_ij

更新输出累加器:

 O_block = O_block * alpha[:, None]  
 O_block = dot(P_block, V_block, O_block)

用当前迭代找到的新最大值更新运行最大值:

 m_i=m_ij

更新后的状态返回给外层内核 fwd_flash_attn_kernel。

最终归一化和写回

所有 K/V 块处理完后,前向内核完成输出:

 O_block=O_block/l_i[:, None]

用累积的分母因子归一化注意力输出。当前查询块的注意力输出就算完了。

性能和基准测试

前向传播实现完毕并验证后,可以看看性能和标准注意力实现比较一下。

FlashAttention vs. standard attention vs torch2.2 (spda flashattn) TFLOP/s benchmarks

所有序列长度上标准注意力在 3-4 TFLOPs/sec 左右就到顶了。理论计算量虽然按 O(N²) 增长,但标准注意力被 HBM 流量主导。GPU 大部分时间在搬运 N × N 注意力矩阵,不是在做有用计算。序列变长并不能提高计算单元利用率,只是内存压力变大。

Triton FlashAttention 内核则随序列长度增加激进扩展。512 token 时性能一般,超过 2K token 后吞吐量快速上升。16K token 时维持在约 190 TFLOPs/sec。这正是 FlashAttention 设计要达到的效果:阻止注意力矩阵物化,中间数据驻留 SRAM,内存加载得以摊销。序列越长,内核越趋向计算受限,GPU 接近有效峰值吞吐量——和标准注意力恰好相反,标准注意力序列越长越内存受限。

第二张图在 Nvidia A100 上通过 sdpa API 比较了 Triton FlashAttention 和 PyTorch 官方 FlashAttention 实现。序列较短时 PyTorch 实现有竞争力,序列长度 ≥4k 后,自定义 Triton 内核追平并略微超过 PyTorch 性能。16k token 时,两者都收敛到约 180-190 TFLOPs/sec。

所有结果在同一 GPU(Nvidia A100 SXM)相同条件下获得。吞吐量以 TFLOPs/sec 报告,由缩放点积注意力的理论 FLOP 数除以实测内核运行时间得出。序列长度变化,batch 大小、头数、头维度固定。

这些基准验证了三件事:标准注意力从根本上内存受限;FlashAttention 把瓶颈从内存转到计算;Triton 提供了足够的数据移动和 GPU 内存底层控制,能达到接近最优性能。

关键是性能增益随序列长度增长。这正是 FlashAttention 在实践中最重要的地方。

总结

现代 GPU 上性能由内存行为主导,不是 FLOPs;内核融合和 SRAM 驻留比数学技巧更重要;在线 softmax 是 IO 感知注意力的关键;Triton 暴露了足够的硬件细节来写可读又快的内核;仔细分块加自动调优,自定义内核能和厂商实现打平。

FlashAttention 不是因为改了算法才更快,是因为它尊重 GPU 实际的工作方式。

本文只实现了前向传播。扩展到完整的训练级 FlashAttention(反向传播、dropout、各种 mask 变体)留待后续工作。

本文源代码:

https://github.com/MyDarapy/triton

by Katherine Oluwadarasimi Olowookere

三个只有程序员才能听懂的笑话

01 程序员最恐怖的几个字

程序员最害怕听到的一句话是:

“我就改了一点点。”

因为这“一点点”,
可能改了数据库结构,
动了公共工具类,
顺手删了个你不知道是谁在用的方法,
最后还会在你电脑上完美运行。


02 程序员的自信来源

程序员的自信不是来自能力,
而是来自这句话:

“在我电脑上是好的。”

只要这句话还说得出口,
Bug 就一定不是我的问题。


03 AI 辅助开发的真实写照

我:

帮我改一下这个方法,不要影响其他功能。

AI:

好的,已帮你重构整个项目结构。

我:

我只是想改一行。

AI:

已顺手优化命名、拆分模块、删除冗余代码。

第二天上线:

“怎么登录模块也挂了?”

写在最后

如果你看完没有笑,
那说明你可能还没被:
线上 Bug、合并冲突、
重构遗留代码、
AI “好心帮倒忙”真正毒打过。

如果你笑了,
那我们大概率是同一类人。

本文由mdnice多平台发布

#超级羊毛 Apple Music 新增免费 6 个月试用,支持国区和其他区,仅限新用户,老用户只能看到折扣价开通的提示。

领取流程:

点击活动链接: https://ourl.co/amsc (不要 PC 点击,不然可能只有 1 个月)

iOS 相机可以直接扫码,安卓用户也可以使用微信扫码并使用自带浏览器打开:

点击链接后自动跳转到 Apple Music 看到领取提示(若未安装会先跳转商店安装)

安卓用户领取时会提示创建 Apple 账户,正常流程输入邮箱创建即可,不需要绑定任何付款方式。

有 Google Play 可以直接商店安装 Apple Music ,安装后先不要启动,点击活动链接拉起 Apple Music 就能看到优惠,点参与然后按提示登录或创建苹果账号。

APK 文件直接下载: https://dl.techwan.org/landian/apps/applemusic

Windows 10/11 可以在微软商店下载 Apple Music

如何取消:

如果没取消订阅则六个月后会按照每月 11 元收取订阅费,领取免费试用后可以立即转到设置、点头像、订阅、找到 Apple Music 然后立即取消订阅,取消订阅不影响继续白嫖免费试用六个月。

注意:也有用户只能看到 3 个月或者 1 个月的优惠,可能视账号和设备情况而定

追觅集团 | 热招岗位汇总

欢迎将简历发送至:,请在简历文件名中体现重要信息(如你的姓名、公司或学校背景、投递岗位名称)。

热招岗位

研发类:算法、软件、嵌入式、硬件、结构、电机控制、仿真、测开、CMF 、ID 、视觉设计、软件项目经理、产品经理、消费者洞察、产品数据...
制造供应类:CMF 、PQM 、AQE 、POE 、SQE 、采购、数分、工艺、机械、NPI...
职能类:专利工程师、AI 产品经理、财务 BP 、财务分析、HRBP 、审计 / 内控管培生...
营销类:区域营销、销售、GTM 、电商...
工作地点:苏州、深圳、北京、上海


[智能影像 BU (运动相机、手持云台)]

1 、公司介绍

团队专注于高端专业影像赛道,致力于打造全球领先的运动相机、手持云台等 AI 影像产品。近期热招嵌入式 / 结构 / 硬件 / 产品 / 销售 / 职能 / 供应链等方向岗位。

2 、热招职位

Base 地:深圳

  • DevOps 全栈 (J52257):3 年以上经验;前期偏运维(通过写代码消除重复劳动)+ 开发 Web 平台
  • 云台算法工程师 (J47814):云台 / 飞控算法方向均可
  • 射频工程师 (J49241):需要 wifi/BT/GPS 多模块短距离射频经验
  • EIS 算法工程师( slam )(J52393)
  • 高级嵌入式软件工程师(低功耗)(J50992):象限内公司优先,可开放 base 苏沪,2 轮面试
  • 高级嵌入式开发工程师(稳定性)(J50998):象限内公司优先,可开放 base 苏沪,2 轮面试
  • SQE(J50591)
  • 云台结构工程师( J42113 )
  • 用户研究负责人( J51430 )
  • 自研产品经理( J42032 ):影像相关经验,2~3 轮面试
  • 采购商务( sourcing )(J49183)
  • 云台固件产品经理( J42217 ):影像效果 / 影像定义
  • 嵌入式开发总监 (J46792)
  • 云台 BU 负责人 (J46791)
  • 软件研发总监 (J46464)
  • 海外整合营销( J43939 )
  • 整合营销( J42119 )
  • 社群用户运营 (J51003)
  • 海外 GTM ( J42047 )
  • 采购履行 buyer (J51008)
  • 成本专家 (J50071)
  • 投融资经理( J49962 ):base 不限 (上海 / 深圳 / 苏州),FA 机构优先
  • 广角固件产品经理( J43933 )
  • APP 产品经理( J42029 )
  • 测试开发工程师 (J45392)
  • 抖音运营 (J46540)
  • 渠道销售 (J38588):具备北区相机渠道销售经验
  • sourcing(J51011)
  • 工业设计 ID (J50571)
  • 手柄结构工程师( J42027 )

3 、社招目标公司范围

一象限
大疆、影石中国区:倍思、华为、小米、宝洁
二象限
字节(剪映)、快手、万兴、腾讯、海康威视、道通、安克、云鲸
三象限
小米、OPPO 、VIVO 、传音、华为、高通、创通通达

4 、校招目标院校

北京大学、清华大学、复旦大学、上海交通大学、南京大学浙江大学、中国科学技术大学、哈尔滨工业大学、西安交通大学


[智能路由 BU ] -BASE 深圳/苏州

  • 产品经理:家用路由器方向,智能家居方向最好,base 深圳、苏州
  • 社媒运营专员:base 苏州,3 年相关经验,内部活水也也可
  • 舆情专员:base 苏州、深圳,3 年相关经验,内部活水也也可
  • 内容运营专员:base 苏州、深圳,3 年相关经验,内部活水也也可
  • 项目经理:路由器相关相关经验优先,活水也可,base 深圳、苏州
  • 销售一号位:海外销售需求,路由器行业也可;地点均可
  • 系统测试:路由器行业相关,通信行业也可,自动化测试方向,base 深圳,大量需求
  • 模具工程师:智能硬件相关,路由器业务;深圳;
  • 采购负责人:路由器行业经验,地点 open ,活水也可
  • 质量负责人:路由器行业经验,地点 open ,活水也可
  • 供应链负责人:路由器行业经验,地点 open ,活水也可
  • 拍摄/剪辑岗位: base 苏州,内部活水也可;
  • 投融资:股权融资方向,通信行业;智慧家居,智能硬件方向,base 均可。


[泳池 BU ]

  • 投融资 BP ,至少 3 年经验,可以 base 苏州和深圳
  • 采购:中级以上,ODM 采购
  • DQE/测试,负责人优先
  • 产品营销、海外销售(北美 /西南欧/东南亚),至少 8 年经验,可以 base 苏州和深圳
  • 算法 /嵌入式/结构/硬件长期招 Junior 要学习能力强,可培养的,资深的看技术能力


[咖啡机 BU ] 🔥☕️

我们正在打造家用高端全自动/半自动咖啡机,追求极致体验,专注技术与美学的融合。如果你是咖啡热爱者,或对厨电创新有热情,欢迎加入我们,一起做“有风味”的产品!

📍Base:苏州 / 北京
热招岗位

  • 硬件产品经理
  • 项目经理
  • HRBP
  • PR 新媒体营销专员(小红书)
    (具备咖啡机/厨电经验者优先)


[海运 BU ]

销售、HRBP 等关键角色,覆盖国内仓储干线、国际港到港、跨境仓到门、海外仓代发全业务链!
为什么选择我们?
✅ 黄金赛道:锚定跨境物流红利,告别 “传统物流” 平庸
✅ 硬核实力:业务闭环稳定盈利,发展前景清晰可见
✅ 爆炸增长:团队与业务同频扩张,解锁 “超预期” 成长空间 [火]** [急招核心岗位] **[火]

  • 销售一号位:拥有海运、空运、陆运及铁路运输、跨境电商、国际快递、海外仓运营等行业产品销售经验,并有丰富客户资源可转换实现收益; Base 地:不设限,优先苏州/上海/深圳/杭州
  • 国际货代-融资 BP:FA/IR 背景人才,具备头部美元/人民币基金成功募资经验,基金合伙人层级候选人也可以。 Base 地:苏州
  • 法务 BP:3-5 年以上跨境物流、国际贸易、供应链领域法务工作经验,熟悉跨境物流行业商业模式和业务流程。 Base 地:苏州
  • HRBP (接受活水):国际头部物流公司背景或百万猎头经验 Base 地:苏州。


[个护 BU ]

  1. 投融资 BP/经理,具备成功融资亿元以上经验; or 政府募资,具备丰富政府资源;base 苏州
  2. 海外 PR 专家,美妆/个护/智能硬件/科技/家电行业背景,有 CES 、MWC 、IFA 经验优先,海外媒介资源优先,base 苏州(优先)、深圳可以沟通
  3. 结构工程师,小家电均可,base 苏州
  4. 高级硬件工程师,5 ~ 6 年及以上经验,家电行业均可,base 苏州
  5. 项目采购工程师,具备项目 0-1 全链路采购落地经验,base 苏州
  6. 电子采购工程师,具备智能硬件电子件采购经验,base 苏州
  7. 有审美有落地的工业设计师/ID 设计师,小家电/3C/时尚/消费电子行业经验,base 苏州
  8. 产品营销,有亚马逊渠道整合营销 or 全域营销经验,个护/小家电/数码 3C 行业背景,base 苏州 or 深圳
  9. 海外产品经理,英语可作为工作语言,base 苏州
  10. 嵌入式软件工程师,小家电行业/芯片/电机驱动,base 苏


[星辰汽车 BG ] 核心研发岗:

「动力总成硬核岗位」

  • 产品管理类:电驱高级经理、电驱 DRE 高级专家
  • 电控系统类:电控系统高级经理、软件/硬件/功能安全专家
  • 电机系统类:电机系统高级经理、减速器专家
  • CAE 仿真测试类:仿真高级经理、结构/热管理/NVH 仿真专家、匹配测试经理
  • 软件测试类:软件测试高级经理
  • 电源与高压类:电源产品经理、DRE 专家、高压线束工程师、高压集成专家
  • 职能类:HRBP/高招 岗(整车/汽车零部件背景优先,甲乙方背景不限)

🏷️工作地点:苏州/上海
🏠对标公司:汇川、联电、比亚迪弗迪动力、华为数字能源、盘毂动力、三花


[星辰汽车 BG · 奇妙岗位探险队,集合啦!]

「专属探险席位」✨

  • 🚀 汽车海外销售一号位
  • 🌏 海外融资高手
  • 🇨🇳 国内融资高手
  • 🎨 汽车内外饰设计
  • 🤝 HRBP 伙伴


[星辰汽车 BG ]

热招岗位:产品/销售/设计/研发/职能(详细岗位需求见图)
象限要求:一象限:小米、理想、华为,吉利,二象限:头部汽车厂商
Base 多地可选:苏州/深圳/北京/上海
有合适的整车海外销售/整车产品/HRBP


[两轮车或四轮车]

智能驾驶系统工程师,智能驾驶产品经理,智能驾驶算法工程师,base 苏州


[ AI 戒指 BU ]

[火]热招岗位:HRBP (苏州、杭州、上海)、PQE 质量工程师(深圳)、软硬件产品经理(苏州/杭州)、法务 BP (英文可做工作语言,base 苏州)、仓库管理(苏州),


[ 3D 打印人才招募令]

我们寻找的“原子”:不是螺丝钉,而是发动机
我们是谁?融资数千万!下一个明星硬件公司👇
✅ 不做跟随者,而是颠覆行业!当前已规划覆盖 3D 打印上下游的多元产品线,正朝着 2000 人规模的全产业链创新公司全速迈进。
✅ 重视人才基因,用技术解决难题!这是一支能打硬仗、善于突破的“特种部队”,这里没有冗长的会议与复杂的层级,只有清晰的目标与极致的共同追求。

应聘指南( Base 深圳)

  • 研发/工程
    嵌入式架构师/工程师、视觉标定算法、感知算法、电机控制算法、高性能计算、图像增强算法、切片算法、桌面软件开发、高级结构工程师、EE 、ME 工程师
  • 产品/设计/职能
    电商产品经理、海外社区产品经理、ID 设计师、海外 PR 、融资经理、HRBP/高招 HR (深圳/苏州都可以)

我们寻找这样的破局者

  • 深耕 3D 打印、机器人或消费电子领域,具备从 0 到 1 打造成功产品的实战经验
  • 对智能硬件、精密控制等硬科技有深度热爱与积累,是解决复杂工程问题的硬核玩家
  • 毕业于国内外顶尖高校,拥有扎实的理论根基与卓越的学习能力

加入即享硬核体验

  • 直接向打造过爆款产品的行业老兵、机器人专家学习,与高手过招,视野与能力快速攀升
  • 获得 1V1 资深带教,并立即投身最核心的项目攻坚,你的代码与设计将直接定义下一代产品!


[ AI 模型 BU ]

热招岗位:融资、PR 、法务、AIGC 产品、大模型推荐算法岗位高优推进
象限要求:一象限:AI 行业知名公司、清华背景
Base 多地可选:苏州/北京/上海/深圳


[吸尘器 BU ]

  • TA/招聘专家,base 苏州
  • 工厂端 HRBP ,base 武汉
  • 工厂端 HRBP ,base 嘉兴
  • IE 工程师/技术员,base 苏州/嘉兴
  • (资深)结构研发工程师/主管/经理,base 苏州
  • 资深动效渲染师、视觉平面设计师,base 苏州/深圳


[空调 BU ]

  • HRBP:强招聘能力,高招能力强,base 苏州/上海
  • 投融资:base 苏州/上海/北京,行业经验更佳
  • 产品经理:国内、海外产品,3 年+,base 苏州/上海
  • 内控内审:3 年企业经验优先,base 苏州
  • 平面设计师:电商平台或品牌设计经验优先,base 苏州/上海
  • 性能工程师:资深 5 年+,base 苏州/上海
  • 结构工程师:零部件、整机均可,base 苏州/上海
  • 销售一号位(国内/国外):空调市场开拓经验,base 全国
  • 财务 BP:电商/海外财务背景,base 苏州
    美的、格力、海尔、海信、奥克斯、小米背景优先。


[宠物 BU ]

BU 介绍:专注于宠物智能产品,致力构建全屋智能宠物生态系统,打造宠物科技的新业态。团队构成主要为宠物行业背景+资深养宠人士,团队氛围超好[派对]

急招岗位

  • base 深圳:品质经理、整合营销/产品营销、PR 负责人
  • 家居服饰产品经理,宠物服饰产品经理 base:北上广深杭苏 要求:5 年及以上相关经验

热招岗位

  • base 深圳:产品经理、海外 GTM 、投融资 BP 、前端开发、后端开发、算法、测试等
    象限要求:一象限:小佩、霍曼、联宠智能、鸟语花香、乐木骆、晨北、北半球、有陪、猫猫狗狗,非产研岗位可开放看 3C 电子消费行业
  • [海外产品营销/平面设计] base 地:苏州
  • [美妆/香水产品经理] base 地:深圳


[电池 BU ]

岗位方向
销售(海内外电池/汽车后市场销售经验)
融资(电池融资经验)
base 苏州/上海/深圳


[生活环境电器 BU ]

项目经理、HRBP (强招聘)、公关经理、融资 BP 、产研( GTM/销售背景)、产品经理(资深)、海外产品营销、海外平面设计(资深) base 苏州


[巡翼摩托]

我们造什么
会思考的摩托!烧油,但不烧脑(因为智能系统帮您烧了)。
智驭燃油,骑领新潮——巡翼摩托,正以科技重塑骑行体验,打造更智能、更自由的摩托车

🔥热招岗位全面覆盖
平面设计 | 海外销售 | 工业设计 | 产品经理 | 平台总工
融资经理 | 研发工程师 | 新媒体运营 | HRBP | 智能化

🎯人才背景期待
优先来自摩托车等相关行业,有头部企业经验者更佳。跨界人才也非常欢迎!

  • 一象限:春风/本田/钱江/隆鑫/杜卡迪等头部摩托车企业。
  • 二象限:力帆/鑫源/张雪/凯越等头部企业。


[手表 BU ] 苏州北京深圳三地可选!

有手表/智能穿戴类/3C 电子+相关岗位经验投递必进面!,急招岗位如下

  • 职能类:HRBP/高招组
  • 产品类:手表产品经理
  • 工业设计类:ID 设计师
  • 供应链类:供应链支持、货盘经理
  • 营销类:品牌负责人、品牌经理、市场策划、整合营销
  • 销售类:渠道经理、海外国家经理、大客户经理
  • 运营类:电商运营、社群运营


[储能 BU ]

需求岗位
HRBP (苏州/深圳)
工业设计(苏州/深圳)
PR (苏州/深圳)
投融资(苏州/深圳)
软件产品经理(苏州/深圳,需具备户储/阳台储行业背景)


[ IT bu ]

岗位

  • Agent 开发( Python )
  • web 前端开发( AI 项目经验优先)
  • 融资 BP:VC/PE 资源/背景
    Base 苏州


[一些零碎岗位]

  1. 资深结构工程师岗位热招中,3 年及以上小家电结构设计背景
  2. 国际物流专员 要求本科学历,有过关务经验、跟单经验均可,行业不限。
    海外产品营销 资深,要求英语口语能力流利,行业可以放开。
    三维渲染师 要求作品集审美高,最好家电行业,可看汽车、美妆等行业。
  3. 工业设计师 要求:3 年及以上相关经验,base:深圳/杭州
  4. 社媒运营、编导策划 base:苏州 要求:有 MCN 账号运营、编导策划经验
  5. 智能穿戴类产品岗位(base 深圳&苏州):投融资、ID 、PR 、营销
    有审美有实操的 CMF 设计师,3 年以上工作&量产经验,base 苏州
  6. DevOps 全栈工程师,同时熟悉 [ devops +云运维+ web 全栈] 优先→深圳南油
    for 智能影像 BU ,base 深圳

注:帮朋友转发,请优先联系邮箱