C++ 与 控制组(Cgroups):在 C++ 分布式计算框架内核中实现精细化的 CPU 与内存资源隔离控制

各位听众,大家好!

今天,我们齐聚一堂,探讨一个在现代分布式系统领域至关重要且极具挑战性的话题:如何在 C++ 分布式计算框架的内核中,实现基于 Linux Cgroups 的精细化 CPU 与内存资源隔离控制。作为一名长期深耕于高性能计算与分布式系统领域的工程师,我深知资源管理对于系统稳定性、效率和公平性的决定性作用。当我们的服务规模日益庞大,部署的计算任务日益繁杂时,简单粗暴的资源分配方式已无法满足需求。我们需要更底层的、更精准的控制力,而 C++ 结合 Cgroups 正是实现这一目标的强大组合。

1. 引言:分布式计算的基石——资源管理

在分布式计算的世界里,我们构建的框架往往承载着海量的计算任务,这些任务可能来自不同的用户、拥有不同的优先级、对资源有着截然不同的需求。想象一下,在一个共享的计算集群中,如果一个高 CPU 消耗的任务与一个内存密集型任务被调度到同一台机器上,并且它们之间没有任何资源隔离机制,那么结果往往是灾难性的:高 CPU 任务可能会饿死低 CPU 任务,导致响应时间飙升;内存密集型任务可能会耗尽宿主机的内存,触发 OOM (Out Of Memory) killer,导致整个机器上的服务崩溃,甚至影响到其他无辜的任务。

这种缺乏隔离的“野蛮生长”模式,在生产环境中是绝对不可接受的。它带来了几个核心问题:

  • 性能不确定性 (Performance Volatility): 任务的性能表现高度依赖于其“邻居”的行为,难以预测和保证 SLA (Service Level Agreement)。
  • 资源争抢与饥饿 (Resource Contention & Starvation): 关键任务可能因非关键任务的资源占用而无法获得足够的资源,导致业务中断或延迟。
  • 公平性缺失 (Lack of Fairness): 无法确保不同用户或不同优先级任务获得其应得的资源份额。
  • 诊断困难 (Diagnostic Difficulty): 当系统出现问题时,难以定位是代码缺陷、硬件故障还是资源争抢导致。

因此,实现精细化的资源隔离和控制,是构建健壮、高效、可预测的分布式计算框架的基石。它不仅仅是关于性能优化,更是关于系统稳定性和可靠性的核心保障。我们选择 C++ 来构建框架内核,正是看中了它在性能、内存控制和底层系统交互方面的无与伦比的优势。而 Linux Cgroups,作为操作系统内核层面提供的强大资源管理机制,则是我们实现这些精细控制的利器。

2. 理解 Cgroups:Linux 内核的资源管家

Cgroups,全称 Control Groups,是 Linux 内核提供的一种机制,用于将进程集合起来,并对这些进程组进行资源管理和限制。它允许系统管理员或框架内核为特定的进程组分配、限制或隔离诸如 CPU、内存、磁盘 I/O、网络带宽等系统资源。

2.1 Cgroup v1 与 Cgroup v2 简述

目前,Linux 系统中主要存在两个版本的 Cgroups:

  • Cgroup v1: 这是较早且广泛使用的版本。它的特点是每个控制器(如 CPU、内存)都有独立的层次结构。这意味着你可以为 CPU 资源设置一个独立的 Cgroup 树,同时为内存资源设置另一个独立的 Cgroup 树。这种独立性提供了很大的灵活性,但也增加了管理的复杂性。大多数现有的容器技术(如 Docker、Kubernetes)在很大程度上仍然依赖 Cgroup v1。
  • Cgroup v2: 这是较新的统一层次结构版本。它旨在解决 v1 的一些设计缺陷和复杂性,提供一个单一的、统一的 Cgroup 层次结构,所有控制器都附加到这个单一的树上。v2 的设计哲学更简洁、更安全,并且解决了 v1 中一些难以处理的边缘情况。然而,由于生态系统的迁移成本和兼容性问题,v2 的普及速度相对较慢。

在本次讲座中,我们将主要聚焦于 Cgroup v1,因为它在当前生产环境中更为常见,其文件系统接口也更直观地展示了各个控制器的独立配置方式。当然,理解 v1 的原理也为未来向 v2 迁移打下了基础。

2.2 核心控制器 (Controllers)

Cgroup v1 提供了多种控制器,每个控制器负责管理一种特定的系统资源。在分布式框架中,我们最常关注的是以下几个:

  • cpu (CPU Controller): 管理 CPU 时间的分配。
  • cpuacct (CPU Accounting Controller): 统计 Cgroup 内进程的 CPU 使用情况。
  • memory (Memory Controller): 管理内存和 Swap 空间的分配与限制。
  • blkio (Block I/O Controller): 管理块设备的 I/O 访问,如磁盘读写带宽和 IOPS。
  • pids (PIDs Controller): 限制 Cgroup 内可以创建的进程/线程数量。
  • cpuset (CPU Set Controller): 绑定进程到特定的 CPU 核心和内存节点 (NUMA)。

2.3 Cgroup 文件系统:/sys/fs/cgroup 目录结构

Cgroups 通过一个虚拟文件系统暴露给用户空间,这个文件系统通常挂载在 /sys/fs/cgroup 路径下。每个控制器都有其自己的子目录。例如,在 Cgroup v1 中,你可能会看到这样的结构:

/sys/fs/cgroup
├── cpu
│   └── user.slice
│       └── my_app_group
│           ├── cpu.shares
│           ├── cpu.cfs_period_us
│           ├── cpu.cfs_quota_us
│           ├── tasks
│           └── ...
├── memory
│   └── user.slice
│       └── my_app_group
│           ├── memory.limit_in_bytes
│           ├── memory.swappiness
│           ├── tasks
│           └── ...
├── blkio
│   └── ...
└── pids
    └── ...

每个 Cgroup 目录内部包含了一系列文件,通过对这些文件进行读写操作,我们就能创建 Cgroup、设置资源限制、添加进程以及查询状态。例如:

  • tasks: 列出或添加属于该 Cgroup 的进程 ID (PID)。
  • cgroup.procs: 列出或添加属于该 Cgroup 的线程组 ID (TGID)。
  • cgroup.event_control: 用于监控 Cgroup 事件(如 OOM)。
  • 控制器特定的文件(如 cpu.shares, memory.limit_in_bytes)。

3. 在 C++ 分布式框架内核中集成 Cgroups 的架构思考

在一个复杂的分布式计算框架中,直接在业务逻辑代码中散布 Cgroup 操作显然是不可取的。我们需要一个清晰、模块化、可扩展的架构来管理这些底层资源。

3.1 架构设计原则

  • 模块化 (Modularity): 将 Cgroup 操作封装成独立的模块,与其他框架组件解耦。
  • 抽象化 (Abstraction): 提供高层级的 C++ API,隐藏底层文件系统操作的复杂性。
  • 可扩展性 (Extensibility): 方便地添加新的 Cgroup 控制器支持或调整现有控制器的行为。
  • 错误处理 (Robust Error Handling): 底层文件操作可能失败,需要健壮的错误检测和恢复机制。
  • 幂等性 (Idempotency): 确保重复创建或配置 Cgroup 不会引发副作用。
  • 线程安全 (Thread Safety): 多个线程可能同时操作 Cgroups,需要适当的同步机制。

3.2 核心组件

为了实现上述原则,我们可以设计以下核心组件:

  1. Cgroup 管理服务 (Cgroup Manager Service):

    • 这是一个独立的后台服务或框架内的核心模块,负责 Cgroup 的生命周期管理:创建、销毁、配置。
    • 它维护一个 Cgroup 层次结构的内部表示,确保 Cgroup 路径的唯一性和正确性。
    • 负责处理 Cgroup 挂载点(/sys/fs/cgroup)的发现和验证。
  2. Cgroup 抽象层 (Cgroup Abstraction Layer):

    • 由一系列 C++ 类组成,封装了对 Cgroup 文件系统进行读写的操作。
    • 例如,CgroupController 基类,以及派生出的 CpuController, MemoryController, BlkioController 等。
    • 提供如 create_group(), set_limit(), add_task() 等易于使用的 API。
  3. 资源调度器 (Resource Orchestrator):

    • 框架的核心大脑,负责接收任务请求,并根据任务的资源需求、集群的当前负载以及预设的调度策略,决定在哪个节点上运行任务,以及为该任务分配多少 CPU、内存等资源。
    • 它会与 Cgroup 管理服务交互,为其提供 Cgroup 的名称、路径以及各项资源限制参数。
  4. 任务执行器 (Task Executor):

    • 负责在具体的节点上启动任务进程。
    • 在启动任务之前或之后,它会调用 Cgroup 抽象层的 API,将新启动的进程/线程 ID (PID/TID) 添加到由资源调度器指定的 Cgroup 中。
  5. 监控与告警 (Monitoring & Alerting):

    • 定期从 Cgroup 的 stat 文件中读取资源使用统计信息(如 cpuacct.usage, memory.usage_in_bytes)。
    • 将这些指标推送至框架的监控系统(如 Prometheus),以便实时可视化和配置告警规则。
    • 监听 Cgroup 事件,例如 memory.oom_control 触发的 OOM 事件,以便及时响应。

通过这种分层设计,我们能够将复杂的 Cgroup 细节与上层业务逻辑完全解耦,提供一个清晰、健壮且可维护的资源管理解决方案。

4. CPU 资源隔离的精细化控制

CPU 是分布式系统中最为核心的资源之一。不加控制的 CPU 争抢会导致严重的性能下降。Cgroups 的 cpucpuset 控制器提供了多种机制来实现精细化的 CPU 资源隔离。

4.1 cpu 控制器详解

cpu 控制器主要通过以下文件进行配置:

  • cpu.shares:
    • 这是一个相对权重值,默认为 1024。它表示一个 Cgroup 在所有可用的 CPU 时间中的相对份额。
    • 重要提示: cpu.shares 只有当系统出现 CPU 竞争时才起作用。如果系统有空闲 CPU,所有 Cgroup 都可以使用它们。例如,如果 Cgroup A 的 cpu.shares 是 1024,Cgroup B 是 512,那么在 CPU 满载时,A 将获得大约 B 两倍的 CPU 时间。
    • 最小值是 2。
  • cpu.cfs_period_uscpu.cfs_quota_us (CFS Bandwidth Control):
    • 这是实现硬性 CPU 限制的关键。cfs_period_us 定义了一个调度周期(微秒),cfs_quota_us 定义了在这个周期内 Cgroup 可以使用的 CPU 时间量(微秒)。
    • 例子: 如果 cpu.cfs_period_us 设置为 100000 (100ms),cpu.cfs_quota_us 设置为 50000 (50ms),那么这个 Cgroup 在每 100ms 内最多只能使用 50ms 的 CPU 时间,相当于限制其 CPU 使用率为 50%。
    • 这提供了一个非常精确的 CPU 带宽限制,即使系统有空闲 CPU,Cgroup 也不能超过这个上限。
    • cfs_quota_us 的默认值是 -1,表示不限制。
  • cpu.rt_period_uscpu.rt_runtime_us (Realtime Bandwidth Control):
    • 用于管理实时 (Realtime) 任务的 CPU 时间。实时任务具有更高的优先级,如果不加以限制,可能会导致非实时任务饿死。
    • rt_period_us 定义了实时调度周期,rt_runtime_us 定义了在这个周期内实时 Cgroup 可以使用的 CPU 时间。
    • 注意: 配置实时 Cgroups 需要较高的权限,并且通常只在对延迟有极高要求的特定场景下使用。
  • cpu.set (cpuset controller):
    • cpu 控制器独立,但同样重要。它允许将一个 Cgroup 绑定到特定的 CPU 核心和内存节点 (NUMA 节点)。
    • cpuset.cpus: 允许 Cgroup 运行的 CPU 核心列表(例如 "0-3", "0,2,4")。
    • cpuset.mems: 允许 Cgroup 访问的内存节点列表。
    • 优势: 提供了最强的 CPU 隔离,避免了缓存争用和跨 NUMA 节点访问带来的性能损失。对于性能敏感型应用至关重要。

4.2 C++ 实现:创建、配置与任务绑定

让我们来构建一个简化的 C++ 抽象层,用于管理 CPU Cgroups。

#include <string>
#include <vector>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <filesystem> // C++17 for directory operations
#include <unistd.h>   // For getpid()

namespace fs = std::filesystem;

// 定义Cgroup的根目录
const std::string CGROUP_ROOT = "/sys/fs/cgroup";

// 助手函数:向Cgroup文件写入内容
bool write_cgroup_file(const fs::path& cgroup_path, const std::string& filename, const std::string& content) {
    fs::path file_path = cgroup_path / filename;
    std::ofstream ofs(file_path);
    if (!ofs.is_open()) {
        std::cerr << "Error: Could not open Cgroup file for writing: " << file_path << std::endl;
        return false;
    }
    ofs << content;
    if (ofs.fail()) {
        std::cerr << "Error: Failed to write to Cgroup file: " << file_path << std::endl;
        return false;
    }
    return true;
}

// 助手函数:读取Cgroup文件内容
std::string read_cgroup_file(const fs::path& cgroup_path, const std::string& filename) {
    fs::path file_path = cgroup_path / filename;
    std::ifstream ifs(file_path);
    if (!ifs.is_open()) {
        // Not necessarily an error, some files might not exist or be readable for all Cgroups
        // std::cerr << "Warning: Could not open Cgroup file for reading: " << file_path << std::endl;
        return "";
    }
    std::string content((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
    return content;
}

// Cgroup CPU 控制器类
class CgroupCpuController {
public:
    explicit CgroupCpuController(const std::string& group_name)
        : group_name_(group_name), cpu_cgroup_path_(CGROUP_ROOT + "/cpu/" + group_name) {
        // 检查并创建 cpuacct 路径,虽然本例主要关注cpu控制器,但通常两者会一起使用
        cpuacct_cgroup_path_ = CGROUP_ROOT + "/cpuacct/" + group_name;
    }

    // 创建 Cgroup 组
    bool create_group() {
        if (!fs::exists(cpu_cgroup_path_)) {
            if (!fs::create_directory(cpu_cgroup_path_)) {
                std::cerr << "Error: Failed to create CPU Cgroup directory: " << cpu_cgroup_path_ << std::endl;
                return false;
            }
        }
        // 对于cpuacct也创建
        if (!fs::exists(cpuacct_cgroup_path_)) {
            if (!fs::create_directory(cpuacct_cgroup_path_)) {
                std::cerr << "Error: Failed to create CPUACCT Cgroup directory: " << cpuacct_cgroup_path_ << std::endl;
                return false;
            }
        }
        return true;
    }

    // 销毁 Cgroup 组
    bool destroy_group() {
        // 确保Cgroup内没有进程,否则无法删除
        if (!add_task(getpid())) { // 尝试将当前进程加入,然后移出,确保tasks文件被清空
             std::cerr << "Warning: Could not add current PID to Cgroup before removing, might fail if tasks exist." << std::endl;
        }
        if (!remove_task(getpid())) {
            std::cerr << "Warning: Could not remove current PID from Cgroup before removing, might fail if tasks exist." << std::endl;
        }

        // 尝试清空 tasks 文件
        if (!write_cgroup_file(cpu_cgroup_path_, "tasks", "")) {
             std::cerr << "Warning: Failed to clear tasks in " << cpu_cgroup_path_ << std::endl;
        }
        if (!write_cgroup_file(cpuacct_cgroup_path_, "tasks", "")) {
             std::cerr << "Warning: Failed to clear tasks in " << cpuacct_cgroup_path_ << std::endl;
        }

        if (fs::exists(cpu_cgroup_path_)) {
            if (!fs::remove(cpu_cgroup_path_)) {
                std::cerr << "Error: Failed to remove CPU Cgroup directory: " << cpu_cgroup_path_ << std::endl;
                return false;
            }
        }
        if (fs::exists(cpuacct_cgroup_path_)) {
            if (!fs::remove(cpuacct_cgroup_path_)) {
                std::cerr << "Error: Failed to remove CPUACCT Cgroup directory: " << cpuacct_cgroup_path_ << std::endl;
                return false;
            }
        }
        return true;
    }

    // 设置 CPU 份额 (cpu.shares)
    bool set_shares(unsigned long shares) {
        return write_cgroup_file(cpu_cgroup_path_, "cpu.shares", std::to_string(shares));
    }

    // 设置 CFS 配额 (cpu.cfs_period_us, cpu.cfs_quota_us)
    bool set_cfs_quota(long period_us, long quota_us) {
        if (!write_cgroup_file(cpu_cgroup_path_, "cpu.cfs_period_us", std::to_string(period_us))) return false;
        return write_cgroup_file(cpu_cgroup_path_, "cpu.cfs_quota_us", std::to_string(quota_us));
    }

    // 添加任务 PID 到 Cgroup
    bool add_task(pid_t pid) {
        // 必须同时添加到 cpu 和 cpuacct 两个控制器下
        if (!write_cgroup_file(cpu_cgroup_path_, "tasks", std::to_string(pid))) return false;
        return write_cgroup_file(cpuacct_cgroup_path_, "tasks", std::to_string(pid));
    }

    // 从 Cgroup 移除任务 PID
    bool remove_task(pid_t pid) {
        // 移除操作通常是将PID写入到父Cgroup的tasks文件中,
        // 或者简单地在销毁Cgroup前确保其tasks文件为空
        // 这里为了简化,仅提供一个概念上的remove_task,实际操作复杂
        // 更常见的是通过销毁Cgroup来移除所有任务,或者将任务移回根Cgroup
        // 写入根Cgroup的tasks文件
        return write_cgroup_file(CGROUP_ROOT + "/cpu/", "tasks", std::to_string(pid));
    }

    // 获取 CPU 使用统计 (cpuacct.usage)
    unsigned long long get_cpu_usage_us() {
        std::string content = read_cgroup_file(cpuacct_cgroup_path_, "cpuacct.usage");
        if (content.empty()) return 0;
        return std::stoull(content) / 1000; // 纳秒转微秒
    }

    // 获取 CPU 核数(通过 cpuset)
    // 注意:cpuset 是独立的控制器,需要单独的CgroupCpusetController类来管理
    // 这里仅做示意,需要一个CgroupCpusetController实例来获取
    // std::string get_cpus() { /* ... */ }

private:
    std::string group_name_;
    fs::path cpu_cgroup_path_;
    fs::path cpuacct_cgroup_path_; // cpuacct通常与cpu控制器一起使用
};

// 示例用法
int main() {
    std::string group_name = "my_dist_task_cpu_group";
    CgroupCpuController controller(group_name);

    // 1. 创建 Cgroup
    if (!controller.create_group()) {
        std::cerr << "Failed to create Cgroup: " << group_name << std::endl;
        return 1;
    }
    std::cout << "Cgroup '" << group_name << "' created successfully." << std::endl;

    // 2. 设置 CPU 份额 (例如,设置为默认的两倍)
    if (!controller.set_shares(2048)) {
        std::cerr << "Failed to set CPU shares." << std::endl;
        // return 1; // Not critical for demo
    }
    std::cout << "CPU shares set to 2048." << std::endl;

    // 3. 设置 CFS 配额 (例如,限制为 50% CPU)
    // 100ms 周期内最多使用 50ms CPU
    if (!controller.set_cfs_quota(100000, 50000)) {
        std::cerr << "Failed to set CFS quota." << std::endl;
        // return 1; // Not critical for demo
    }
    std::cout << "CFS quota set to 50% (50000us/100000us)." << std::endl;

    // 4. 将当前进程添加到 Cgroup
    pid_t current_pid = getpid();
    if (!controller.add_task(current_pid)) {
        std::cerr << "Failed to add current PID " << current_pid << " to Cgroup." << std::endl;
        // return 1;
    }
    std::cout << "Current PID " << current_pid << " added to Cgroup." << std::endl;

    // 5. 模拟一些 CPU 密集型工作并监控
    std::cout << "Performing some CPU-bound work for 5 seconds..." << std::endl;
    auto start_time = std::chrono::high_resolution_clock::now();
    double dummy_sum = 0;
    while (std::chrono::duration_cast<std::chrono::seconds>(std::chrono::high_resolution_clock::now() - start_time).count() < 5) {
        for (int i = 0; i < 1000000; ++i) {
            dummy_sum += std::sqrt(std::log(static_cast<double>(i + 1.0)));
        }
    }
    std::cout << "Dummy sum: " << dummy_sum << std::endl; // 防止编译器优化掉循环

    // 6. 获取 CPU 使用统计
    unsigned long long usage_us = controller.get_cpu_usage_us();
    std::cout << "CPU usage (microseconds) in Cgroup: " << usage_us << std::endl;

    // 7. 销毁 Cgroup
    // 为了让destroy_group成功,需要将当前进程从Cgroup中移除
    if (!write_cgroup_file(CGROUP_ROOT + "/cpu/", "tasks", std::to_string(current_pid))) {
        std::cerr << "Failed to move PID " << current_pid << " back to root Cgroup for CPU." << std::endl;
    }
    if (!write_cgroup_file(CGROUP_ROOT + "/cpuacct/", "tasks", std::to_string(current_pid))) {
        std::cerr << "Failed to move PID " << current_pid << " back to root Cgroup for CPUACCT." << std::endl;
    }

    if (!controller.destroy_group()) {
        std::cerr << "Failed to destroy Cgroup: " << group_name << std::endl;
        return 1;
    }
    std::cout << "Cgroup '" << group_name << "' destroyed successfully." << std::endl;

    return 0;
}

编译与运行提示:

  1. 需要 C++17 支持 (例如 g++ -std=c++17 your_file.cpp -o your_app)。
  2. 运行程序需要 root 权限,因为 Cgroup 文件系统是敏感的。

4.3 cpuset 控制器与 NUMA 亲和性

cpuset 控制器提供了更强的隔离能力,它能将 Cgroup 及其中的进程严格限制在一组 CPU 核心和内存节点上。

// Cgroup Cpuset 控制器类 (部分代码,仅展示核心功能)
class CgroupCpusetController {
public:
    explicit CgroupCpusetController(const std::string& group_name)
        : group_name_(group_name), cpuset_cgroup_path_(CGROUP_ROOT + "/cpuset/" + group_name) {}

    bool create_group() {
        if (!fs::exists(cpuset_cgroup_path_)) {
            // cpuset Cgroup 在创建时必须继承父Cgroup的cpus和mems配置
            // 否则会报错 "cpuset: cpu_exclusive or mem_exclusive not set"
            // 或者 "cpuset: cpus or mems is not set"
            // 通常,在创建子Cgroup之前,需要先将父Cgroup的cpus和mems写入子Cgroup
            // 例如,从根cpuset Cgroup读取cpus和mems
            std::string root_cpus = read_cgroup_file(CGROUP_ROOT + "/cpuset/", "cpuset.cpus");
            std::string root_mems = read_cgroup_file(CGROUP_ROOT + "/cpuset/", "cpuset.mems");

            if (root_cpus.empty() || root_mems.empty()) {
                std::cerr << "Error: Could not read root cpuset.cpus or cpuset.mems. "
                          << "Ensure cpuset controller is mounted and configured." << std::endl;
                return false;
            }

            if (!fs::create_directory(cpuset_cgroup_path_)) {
                std::cerr << "Error: Failed to create CPUSET Cgroup directory: " << cpuset_cgroup_path_ << std::endl;
                return false;
            }

            // 必须在创建后立即设置cpus和mems,否则Cgroup无效
            if (!write_cgroup_file(cpuset_cgroup_path_, "cpuset.cpus", root_cpus.substr(0, root_cpus.find_first_not_of(" tnr")))) return false;
            if (!write_cgroup_file(cpuset_cgroup_path_, "cpuset.mems", root_mems.substr(0, root_mems.find_first_not_of(" tnr")))) return false;
        }
        return true;
    }

    // 设置允许的 CPU 核心
    bool set_cpus(const std::string& cpus_list) {
        return write_cgroup_file(cpuset_cgroup_path_, "cpuset.cpus", cpus_list);
    }

    // 设置允许的内存节点
    bool set_mems(const std::string& mems_list) {
        return write_cgroup_file(cpuset_cgroup_path_, "cpuset.mems", mems_list);
    }

    // 添加任务 PID
    bool add_task(pid_t pid) {
        return write_cgroup_file(cpuset_cgroup_path_, "tasks", std::to_string(pid));
    }

    // ... destroy_group 等类似方法
private:
    std::string group_name_;
    fs::path cpuset_cgroup_path_;
};

使用 cpuset 控制器可以实现:

  • CPU 核心绑定: 将任务固定在指定的物理 CPU 核心上,减少上下文切换开销和缓存失效。
  • NUMA 亲和性: 确保任务只访问其本地 NUMA 节点上的内存,避免跨节点访问的延迟惩罚。这对于多核、多插槽服务器上的高性能应用至关重要。

表格:cpu 控制器关键文件及作用

文件名 作用 默认值/范围
cpu.shares 相对权重,在 CPU 竞争时按比例分配 CPU 时间。 1024 (最小 2)
cpu.cfs_period_us CFS 调度周期(微秒),定义一个时间窗口。 100000 (100ms)
cpu.cfs_quota_us CFS 配额(微秒),在 period_us 周期内 Cgroup 可使用的 CPU 时间上限。 -1 (无限制)
cpu.rt_period_us 实时调度周期(微秒),用于实时任务。 1000000 (1s)
cpu.rt_runtime_us 实时运行时间(微秒),在 rt_period_us 周期内实时任务可使用的 CPU 时间上限。 0 (无限制)
cpu.stat CPU 使用统计信息(如 nr_periods, nr_throttled, throttled_time)。 N/A
cpuacct.usage Cgroup 内所有进程累计的 CPU 使用时间(纳秒)。 N/A
cpuacct.usage_percpu Cgroup 内所有进程在每个 CPU 核心上累计的 CPU 使用时间(纳秒)。 N/A
cpuset.cpus 允许 Cgroup 运行的 CPU 核心列表(例如 "0-3", "0,2,4")。 继承父 Cgroup
cpuset.mems 允许 Cgroup 访问的内存节点列表。 继承父 Cgroup
tasks / cgroup.procs 列出或添加属于该 Cgroup 的进程/线程 ID。 N/A

5. 内存资源隔离的深度实践

内存是另一个关键且有限的资源。不受控制的内存使用是导致系统不稳定的常见原因。Cgroups 的 memory 控制器提供了强大的内存隔离和限制功能。

5.1 memory 控制器详解

memory 控制器通过以下文件进行配置:

  • memory.limit_in_bytes:
    • 硬限制。 Cgroup 可以使用的最大物理内存量(包括文件缓存)。当 Cgroup 尝试分配超过此限制的内存时,内核的 OOM Killer 可能会被触发,杀死 Cgroup 内的进程。
    • 单位可以是字节,也可以带 K, M, G 后缀。例如 "1G"。
  • memory.soft_limit_in_bytes:
    • 软限制。 当系统内存充足时,Cgroup 可以使用超过此限制的内存。但当系统内存紧张时,内核会优先回收超过软限制的 Cgroup 的内存。
    • 用于在资源紧张时实现“公平性”或“优先级”。
  • memory.swappiness:
    • 控制 Cgroup 内进程的内存交换行为。值范围 0-100。
    • 0 表示尽量不交换,倾向于回收文件缓存。100 表示积极交换,倾向于将匿名内存页换出到磁盘。
    • 可以根据应用特性进行调整:对于延迟敏感的应用,设置为较低值;对于内存密集型但对延迟不敏感的应用,可以设置为较高值。
  • memory.oom_control:
    • 控制 OOM Killer 的行为。
    • oom_kill_disable (0 或 1): 设置为 1 会禁用该 Cgroup 的 OOM Killer。慎用! 这意味着如果 Cgroup 耗尽内存,它不会被杀死,而是可能导致整个系统 OOM,或者系统变得极度不稳定。通常只用于非常特殊的内核/系统服务。
    • under_oom: 只读,显示 Cgroup 当前是否处于 OOM 状态。
  • memory.memsw.limit_in_bytes:
    • 内存和 Swap 空间的总和限制。例如,如果 memory.limit_in_bytes 是 1G,memory.memsw.limit_in_bytes 是 2G,那么 Cgroup 最多使用 1G 物理内存,以及额外的 1G Swap 空间。
    • 这对于避免 Cgroup 耗尽物理内存后,又通过大量 Swap 使用导致系统性能急剧下降的情况非常有用。
  • memory.stat:
    • 提供详细的内存使用统计信息,如 cache (文件缓存), rss (常驻内存集), mapped_file (映射文件), `pgfault (缺页中断次数) 等。
    • 这是监控 Cgroup 内存使用的重要来源。

5.2 C++ 实现:内存限制与 OOM 管理

// Cgroup Memory 控制器类
class CgroupMemoryController {
public:
    explicit CgroupMemoryController(const std::string& group_name)
        : group_name_(group_name), memory_cgroup_path_(CGROUP_ROOT + "/memory/" + group_name) {}

    // 创建 Cgroup 组
    bool create_group() {
        if (!fs::exists(memory_cgroup_path_)) {
            if (!fs::create_directory(memory_cgroup_path_)) {
                std::cerr << "Error: Failed to create Memory Cgroup directory: " << memory_cgroup_path_ << std::endl;
                return false;
            }
        }
        return true;
    }

    // 销毁 Cgroup 组
    bool destroy_group() {
        // ... 确保 tasks 文件清空
        if (!write_cgroup_file(memory_cgroup_path_, "tasks", "")) {
             std::cerr << "Warning: Failed to clear tasks in " << memory_cgroup_path_ << std::endl;
        }

        if (fs::exists(memory_cgroup_path_)) {
            if (!fs::remove(memory_cgroup_path_)) {
                std::cerr << "Error: Failed to remove Memory Cgroup directory: " << memory_cgroup_path_ << std::endl;
                return false;
            }
        }
        return true;
    }

    // 设置内存硬限制 (memory.limit_in_bytes)
    bool set_memory_limit(unsigned long long bytes) {
        return write_cgroup_file(memory_cgroup_path_, "memory.limit_in_bytes", std::to_string(bytes));
    }

    // 设置内存软限制 (memory.soft_limit_in_bytes)
    bool set_memory_soft_limit(unsigned long long bytes) {
        return write_cgroup_file(memory_cgroup_path_, "memory.soft_limit_in_bytes", std::to_string(bytes));
    }

    // 设置内存+Swap 总限制 (memory.memsw.limit_in_bytes)
    bool set_memsw_limit(unsigned long long bytes) {
        return write_cgroup_file(memory_cgroup_path_, "memory.memsw.limit_in_bytes", std::to_string(bytes));
    }

    // 设置 Swappiness (memory.swappiness)
    bool set_swappiness(int value) {
        if (value < 0 || value > 100) {
            std::cerr << "Error: Swappiness value must be between 0 and 100." << std::endl;
            return false;
        }
        return write_cgroup_file(memory_cgroup_path_, "memory.swappiness", std::to_string(value));
    }

    // 禁用 OOM Killer (memory.oom_control)
    // 0: 启用 (默认), 1: 禁用
    bool disable_oom_killer(bool disable) {
        return write_cgroup_file(memory_cgroup_path_, "memory.oom_control", "oom_kill_disable=" + std::to_string(disable ? 1 : 0));
    }

    // 添加任务 PID 到 Cgroup
    bool add_task(pid_t pid) {
        return write_cgroup_file(memory_cgroup_path_, "tasks", std::to_string(pid));
    }

    // 获取当前内存使用量 (memory.usage_in_bytes)
    unsigned long long get_current_memory_usage() {
        std::string content = read_cgroup_file(memory_cgroup_path_, "memory.usage_in_bytes");
        if (content.empty()) return 0;
        return std::stoull(content);
    }

    // 解析 memory.stat 文件,获取详细统计
    std::map<std::string, unsigned long long> get_memory_stats() {
        std::map<std::string, unsigned long long> stats;
        std::string content = read_cgroup_file(memory_cgroup_path_, "memory.stat");
        if (content.empty()) return stats;

        std::stringstream ss(content);
        std::string line;
        while (std::getline(ss, line)) {
            std::string key;
            unsigned long long value;
            size_t pos = line.find(' ');
            if (pos != std::string::npos) {
                key = line.substr(0, pos);
                value = std::stoull(line.substr(pos + 1));
                stats[key] = value;
            }
        }
        return stats;
    }

private:
    std::string group_name_;
    fs::path memory_cgroup_path_;
};

// 示例用法 (在 main 函数中添加)
/*
    std::string mem_group_name = "my_dist_task_mem_group";
    CgroupMemoryController mem_controller(mem_group_name);

    if (!mem_controller.create_group()) {
        std::cerr << "Failed to create Memory Cgroup: " << mem_group_name << std::endl;
        return 1;
    }
    std::cout << "Memory Cgroup '" << mem_group_name << "' created successfully." << std::endl;

    // 设置内存限制为 128MB
    if (!mem_controller.set_memory_limit(128 * 1024 * 1024)) {
        std::cerr << "Failed to set memory limit." << std::endl;
    }
    std::cout << "Memory limit set to 128MB." << std::endl;

    if (!mem_controller.add_task(current_pid)) {
        std::cerr << "Failed to add current PID to Memory Cgroup." << std::endl;
    }
    std::cout << "Current PID added to Memory Cgroup." << std::endl;

    // 模拟内存分配,可能触发 OOM
    std::cout << "Attempting to allocate more than 128MB of memory..." << std::endl;
    try {
        std::vector<char*> large_allocs;
        size_t alloc_size = 10 * 1024 * 1024; // 10MB chunks
        for (int i = 0; i < 20; ++i) { // Try to allocate 200MB
            large_allocs.push_back(new char[alloc_size]);
            memset(large_allocs.back(), 0, alloc_size); // 实际使用内存
            std::cout << "Allocated " << (i + 1) * alloc_size / (1024 * 1024) << "MB." << std::endl;
            // 每次分配后检查一下 Cgroup 内存使用情况
            std::cout << "Cgroup current memory usage: " << mem_controller.get_current_memory_usage() / (1024 * 1024) << "MB" << std::endl;
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }
        // 清理
        for (char* p : large_allocs) {
            delete[] p;
        }
        std::cout << "Memory allocation completed (might have been killed by OOM)." << std::endl;
    } catch (const std::bad_alloc& e) {
        std::cerr << "std::bad_alloc caught: " << e.what() << ". This might indicate Cgroup OOM killer was disabled or not fully effective." << std::endl;
    } catch (...) {
        std::cerr << "An unknown exception occurred during memory allocation. Check system logs for OOM events." << std::endl;
    }

    // 获取内存统计
    std::map<std::string, unsigned long long> mem_stats = mem_controller.get_memory_stats();
    std::cout << "Memory Cgroup Stats:" << std::endl;
    for (const auto& pair : mem_stats) {
        std::cout << "  " << pair.first << ": " << pair.second << std::endl;
    }

    // 销毁 Cgroup
    if (!write_cgroup_file(CGROUP_ROOT + "/memory/", "tasks", std::to_string(current_pid))) {
        std::cerr << "Failed to move PID " << current_pid << " back to root Cgroup for Memory." << std::endl;
    }
    if (!mem_controller.destroy_group()) {
        std::cerr << "Failed to destroy Memory Cgroup: " << mem_group_name << std::endl;
    }
    std::cout << "Memory Cgroup '" << mem_group_name << "' destroyed successfully." << std::endl;
*/

应对 OOM 事件的策略:

  • 默认行为: 当 Cgroup 达到内存硬限制时,内核 OOM Killer 会选择并杀死 Cgroup 内的某个进程。这通常是预期行为,确保 Cgroup 不会耗尽整个系统内存。
  • 监控 OOM 事件: 可以通过 cgroup.event_control 文件来设置对 OOM 事件的监听。当 OOM 发生时,会触发一个 eventfd,框架可以读取并作出响应(例如,重启任务,记录日志,向调度器报告失败)。
  • 禁用 OOM Killer (谨慎): 如前所述,oom_kill_disable=1 可以禁用 Cgroup 的 OOM Killer。这在某些情况下可能需要,但通常会增加整个系统的风险。
  • 结合 memory.soft_limit_in_bytes 通过设置软限制,在内存紧张时优先回收指定 Cgroup 的内存,而不是直接杀死进程。
  • 结合 memory.memsw.limit_in_bytes 防止进程在耗尽物理内存后又大量使用 Swap,导致系统性能急剧下降。

表格:memory 控制器关键文件及作用

文件名 作用 默认值/范围
memory.limit_in_bytes 内存硬限制,Cgroup 可使用的最大物理内存量。 无限制 (通常为系统总内存)
memory.soft_limit_in_bytes 内存软限制,系统内存紧张时优先回收超出此限制的 Cgroup 内存。 无限制
memory.swappiness 控制 Cgroup 内存的交换行为,0 (不交换) 到 100 (积极交换)。 60
memory.oom_control 控制 OOM Killer 行为,oom_kill_disable (0/1)。 oom_kill_disable=0 (启用)
memory.memsw.limit_in_bytes 内存和 Swap 空间的总和限制。 无限制
memory.usage_in_bytes 当前 Cgroup 使用的物理内存量。 N/A
memory.max_usage_in_bytes Cgroup 历史上使用过的最大物理内存量。 N/A
memory.stat 详细的内存使用统计(如 rss, cache, pgfault 等)。 N/A
tasks / cgroup.procs 列出或添加属于该 Cgroup 的进程/线程 ID。 N/A

6. 其他关键资源隔离与高级话题

除了 CPU 和内存,Cgroups 还提供了对其他关键资源的控制,以及更复杂的管理模式。

6.1 I/O 资源隔离 (blkio 控制器)

blkio 控制器允许我们限制 Cgroup 对块设备的 I/O 访问,这对于防止某个任务因为大量磁盘读写而影响其他任务的性能至关重要。

  • blkio.throttle.read_bps_deviceblkio.throttle.write_bps_device: 限制每秒读写字节数 (BPS)。
  • blkio.throttle.read_iops_deviceblkio.throttle.write_iops_device: 限制每秒读写操作数 (IOPS)。

这些文件通常需要指定设备号(major:minor),例如 8:0 1048576 表示限制设备 8:0 (通常是 /dev/sda) 为 1MB/s。

6.2 PID 资源隔离 (pids 控制器)

pids 控制器用于限制 Cgroup 内可以创建的进程和线程的总数量。这可以防止“fork 炸弹”或其他失控的进程创建行为耗尽系统 PID 资源。

  • pids.max: 设置 Cgroup 内最大进程/线程数。
  • pids.current: 只读,显示当前 Cgroup 内的进程/线程数。

6.3 Cgroup 层级结构与嵌套

Cgroups 支持树状的层级结构。这意味着你可以创建一个父 Cgroup,然后在其下创建子 Cgroup。子 Cgroup 会继承父 Cgroup 的资源限制,并且可以在继承的基础上进一步细化。

  • 应用场景:
    • 多租户环境: 为每个租户创建一个顶级 Cgroup,分配一个总的资源配额。然后,租户可以在其 Cgroup 下创建子 Cgroup,为自己的不同服务或应用进行二次分配。
    • 服务分层: 将一个大型分布式服务的不同组件(如前端、后端、数据库、日志服务)放入不同的 Cgroup,进行独立的资源管理。

例如:/sys/fs/cgroup/cpu/tenant_A/service_Xtenant_A 限制了总 CPU,service_X 则在此基础上进一步细化。

6.4 监控与指标收集

实时监控 Cgroup 的资源使用情况是运维的关键。我们可以:

  • *定期读取 `.stat文件:** 如cpuacct.usage,memory.stat`,获取原始数据。
  • 计算指标: 例如,通过前后两次 cpuacct.usage 的差值计算 CPU 使用率。
  • 集成到监控系统: 将这些指标通过 Pushgateway 或直接暴露 HTTP 接口等方式,集成到 Prometheus、Grafana 等监控系统,实现可视化和告警。

6.5 动态资源调整

分布式框架的资源需求是动态变化的。框架内核应具备根据负载变化、SLA 要求或管理员指令,动态调整 Cgroup 资源参数的能力。例如:

  • 当某个任务负载升高时,调度器可以尝试为其增加 cpu.cfs_quota_uscpu.shares
  • 当某个任务完成或进入空闲状态时,可以适当降低其资源限制,回收资源供其他任务使用。

这需要 Cgroup 抽象层提供相应的 update_limit 方法,并确保更新操作的原子性和线程安全。

6.6 Cgroup v2 展望

虽然我们主要关注了 Cgroup v1,但了解 Cgroup v2 的设计理念对于未来的规划至关重要。

  • 统一层次结构: 所有控制器共享一个单一的 Cgroup 树,解决了 v1 中控制器层次结构不一致的复杂性。
  • Delegation 机制: 更好地支持容器和用户对 Cgroup 子树的委托管理。
  • 更简洁的 API: 通过统一的文件接口 (cgroup.max, cgroup.weight 等) 简化了资源管理。
  • 改进的控制器: 例如,更精细的 I/O 控制和 PID 管理。

在未来,当 Cgroup v2 成为主流时,我们的 Cgroup 抽象层需要进行适配,但核心的资源隔离理念和 C++ 封装模式仍然适用。

7. C++ 实现细节与最佳实践

在 C++ 中实现 Cgroup 管理,需要特别关注一些底层细节和工程实践。

7.1 底层系统调用与文件系统操作

Cgroup 本质上是通过操作 /sys/fs/cgroup 下的文件来实现的。C++ 代码需要直接或间接使用以下系统调用:

  • mkdir() / rmdir(): 创建和删除 Cgroup 目录。
  • open() / close(): 打开和关闭 Cgroup 属性文件。
  • write() / read(): 向 Cgroup 属性文件写入配置或读取状态。
  • mount() / umount(): (通常由系统自动完成,但对于手动挂载 Cgroup 文件系统可能需要) 挂载和卸载 Cgroup 文件系统。

为了保证安全和健壮性,我们应避免直接使用裸的 open/write,而是封装到更高级别的 C++ 文件流 (std::ofstream, std::ifstream) 或利用 std::filesystem (C++17) 进行目录操作。

7.2 错误处理

底层系统调用和文件操作是容易失败的。必须进行健壮的错误处理:

  • 检查返回值: 每次系统调用或文件操作后,检查其返回值以判断是否成功。
  • errno 如果失败,检查全局变量 errno 以获取具体的错误码,并使用 strerror(errno) 获取可读的错误信息。
  • C++ 异常: 将底层错误封装成 C++ 异常,向上层抛出,以便上层逻辑进行统一处理。例如,CgroupOperationError 异常。
// 示例:带错误处理的 write_cgroup_file
bool write_cgroup_file_robust(const fs::path& cgroup_path, const std::string& filename, const std::string& content) {
    fs::path file_path = cgroup_path / filename;
    std::ofstream ofs(file_path);
    if (!ofs.is_open()) {
        std::string error_msg = "Failed to open Cgroup file for writing: " + file_path.string() + ". Error: " + strerror(errno);
        std::cerr << error_msg << std::endl;
        // throw std::runtime_error(error_msg); // 可以选择抛出异常
        return false;
    }
    ofs << content;
    if (ofs.fail()) {
        std::string error_msg = "Failed to write to Cgroup file: " + file_path.string() + ". Error: " + strerror(errno);
        std::cerr << error_msg << std::endl;
        // throw std::runtime_error(error_msg);
        return false;
    }
    return true;
}

7.3 文件系统路径的封装与管理

避免在代码中硬编码 Cgroup 路径。可以创建一个 CgroupPathResolver 类来构建和验证 Cgroup 路径。

class CgroupPathResolver {
public:
    explicit CgroupPathResolver(const std::string& root_path = "/sys/fs/cgroup") : root_(root_path) {
        if (!fs::exists(root_) || !fs::is_directory(root_)) {
            throw std::runtime_error("Cgroup root path does not exist or is not a directory: " + root_.string());
        }
    }

    fs::path get_controller_path(const std::string& controller_name) const {
        fs::path controller_path = root_ / controller_name;
        if (!fs::exists(controller_path) || !fs::is_directory(controller_path)) {
            throw std::runtime_error("Cgroup controller path does not exist or is not a directory: " + controller_path.string());
        }
        return controller_path;
    }

    fs::path get_group_path(const std::string& controller_name, const std::string& group_name) const {
        return get_controller_path(controller_name) / group_name;
    }

private:
    fs::path root_;
};

7.4 并发与线程安全

在分布式框架内核中,多个线程或组件可能同时尝试创建、修改或销毁 Cgroup。这要求我们的 Cgroup 抽象层是线程安全的。

  • 互斥锁 (Mutex): 使用 std::mutex 保护对 Cgroup 文件系统操作的临界区,尤其是在创建/删除目录和修改 Cgroup 属性时。
  • 原子操作: 对于简单的读写操作,如果能保证文件操作的原子性(通常内核会保证单个文件写入的原子性),则可以避免锁,但对于复合操作仍需谨慎。
  • 设计模式: 可以考虑使用单例模式来管理 CgroupPathResolver 或 CgroupManagerService,确保全局唯一性并集中管理资源。

7.5 幂等性

Cgroup 操作应设计为幂等。例如,多次调用 create_group() 应该只在第一次实际创建 Cgroup,后续调用不应报错或产生副作用。这可以通过在创建前检查目录是否存在来实现。销毁 Cgroup 也类似。

7.6 测试策略

对 Cgroup 管理模块进行充分测试至关重要:

  • 单元测试: 测试 Cgroup 抽象层中的各个方法,例如 set_shares, set_memory_limit 是否能正确写入文件。可以使用模拟文件系统或 A/B 切换 Cgroup 挂载点来隔离测试。
  • 集成测试: 启动一个真正的 Cgroup,将测试进程加入,然后验证资源限制是否生效。例如,尝试分配超出内存限制的内存,看是否触发 OOM。
  • 压力测试: 在高并发环境下创建和销毁大量 Cgroup,并进行资源限制,检查系统的稳定性和性能。

7.7 安全性考量

  • 权限管理: Cgroup 操作通常需要 root 权限。在生产环境中,框架内核服务应以最小特权原则运行。可以考虑将 Cgroup 操作封装成一个独立的、仅具有必要权限的守护进程,并通过 IPC 与主框架通信。
  • 避免 Cgroup 逃逸: 确保子 Cgroup 无法突破父 Cgroup 的限制,或者其内部进程无法逃逸到父 Cgroup 之外(例如,通过 setns() 系统调用)。虽然 Cgroup 本身旨在提供隔离,但复杂的配置或内核漏洞仍可能带来风险。

8. 挑战、陷阱与应对策略

在 C++ 中集成 Cgroups 并非一帆风顺,会遇到一些挑战:

  • Cgroup API 的复杂性与版本兼容性: Cgroup v1 各个控制器有不同的文件和行为,且不同 Linux 内核版本之间可能存在细微差异。这要求代码具有一定的适应性和容错性,或者针对特定内核版本进行优化。
    • 应对: 维护一个 Cgroup 特性矩阵,根据 uname() 获取的内核版本动态调整行为;或者提供配置选项让用户选择 Cgroup 版本。
  • 调试 Cgroup 相关问题的难度: 当 Cgroup 未按预期工作时,问题可能出在配置错误、权限问题、内核版本不兼容,甚至与其他 Cgroup 或系统进程的交互。
    • 应对: 详细的日志记录是关键,记录每次 Cgroup 操作的输入、输出和结果(包括 errno)。提供诊断工具,可以列出当前所有 Cgroup 的配置和状态。
  • 资源配置的艺术:过度限制与资源浪费的平衡: 过于严格的 Cgroup 限制可能导致任务无法充分利用资源,从而降低整体效率。过于宽松则可能失去隔离效果。
    • 应对: 需要深入理解应用的工作负载特性,通过基准测试和监控数据来优化资源配置。引入动态资源调度和自适应调整机制。
  • Cgroup v1 的一些已知问题: 例如,memory.stat 文件中的某些统计数据可能存在延迟或不完全准确。
    • 应对: 了解这些限制,并在设计监控和告警系统时加以考虑。可以结合其他系统级工具(如 /proc/meminfo)进行交叉验证。

9. 展望未来:持续演进的资源管理

Cgroups 作为 Linux 资源管理的核心机制,已经深入融入现代 IT 基础设施,尤其是容器技术。我们的 C++ 分布式框架内核与 Cgroups 的结合,将为构建高性能、高可靠、高弹性的系统提供坚实的基础。

展望未来,资源管理将继续演进:

  • 容器技术与 Cgroups 的深度融合: 随着 Kubernetes 等容器编排系统的普及,Cgroups 已经成为容器运行时(如 containerd、CRI-O)的底层基石。我们的框架可以更好地与这些系统集成,利用它们提供的更上层的抽象和管理能力。
  • 更智能的资源调度与自适应能力: 结合机器学习和人工智能技术,实现更加智能和自适应的资源调度,根据实时负载预测和应用行为模式,动态调整 Cgroup 参数,最大化资源利用率同时保证服务质量。
  • 硬件异构性与 Cgroups 的结合: 随着 GPU、FPGA 等异构硬件的广泛应用,Cgroups 也将需要扩展,以支持对这些特殊硬件资源的精细化隔离和管理。

通过深入理解和有效利用 Cgroups,结合 C++ 的强大能力,我们能够赋予分布式计算框架内核前所未有的资源控制精度,从而解锁更高的性能、更强的稳定性和更灵活的扩展性。这是一个充满挑战但也充满机遇的领域,值得我们持续投入和探索。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注