numad详解(1)

在之前的云主机性能优化过程中,我们引入了numad服务为云主机提供自动的内存分配策略。Linux man手册中对numad的定义为A user-level daemon that provides placement advice and process management for efficient use of CPUs and memory on systems with NUMA topology.(为高效利用CPU和内存提供布局建议和进程管理功能的用户态守护进程。)那么numad是如何实现进程的内存布局分布和CPU管理的,使用的又是什么样的管理策略呢。这篇文档将解答这些问题。

numad能做什么

简单来说,numad可以根据系统当前的压力情况,自动设置物理机上进程的CPU亲和性和内存numa分布。numad的默认策略是均衡多个numa node之间的压力。如果你只是想了解一下numad可以做什么,到这里就可以了。如果还想要以更精细的粒度管理物理机上的进程,需要进一步了解numad的进阶使用姿势。

numad的进阶使用

numad支持的参数列表如下:

-C <0|1> 是否将Cache视为可用内存
-d 设置日志级别为Debug,与指定-l 7效果一致
-h 显示帮助
-H <THP_scan_sleep_ms> 设置透明大页的扫描间隔,以毫秒为单位。系统通常将/sys/kernel/mm/tranparent_hugepage/khugepaged/scan_sleep_millisecs设置为10000ms,numad默认设置这个值为1000ms。设置为0时,numad使用系统默认值。设置较小值时有助于提升使用透明大页进程的性能。
-i <[min_interval:]max_interval> 设置numad扫描系统进程的时间间隔,以s为单位。默认的max_interval是15s,min_interval是5s。max_interval设置为0时守护进程会退出。更大的max_interval可以降低系统负载,但是也会降低对系统负载变化的响应。
-K <0|1> 设置numad是否继续使用interleaved内存或者尝试将interleave内存merge。默认是0,即merge interleave内存。
-l <log_level> 日志级别,可选5-7,默认为5-m <target_memory_locality> 设置本地node节点的内存阈值,以停止对进程内存的移动。默认是90%,即如果90%的系统内存在当前node上,则停止merge其它节点的内存到本节点。一般可以设置的值为50%-100%。如果设置100%,因为某些内存是不允许迁移的,可能导致这部分内存永远不会迁移成功。
-p <PID> 添加一个进程到numad的inclusion list。可以在numad服务启动时指定多个-p参数,但是后续对numad的调用只能指定一个。需要注意的是,在inclusion list中的进程并不一定会被numad显式的管理,除非达到numad的管理阈值(默认300M内存和0.5CPU的计算能力)
-r <PID> 从inclusion list和exclusion list中移除pid,numad服务启动之后,每次只能移除一个pid
-R <CPU_LIST> 指定一个numad不能使用的CPU列表,numad服务启动之后不可改变
-S <0|1> 指定numad服务的管理范围。默认为1,扫描所有的系统进程。当设置为0时仅扫描inclusion list中的pid。通常需要配合-p参数一起使用。
-t <logical_CPU_percent> 指定CPU超线程核的计算能力。numad中默认只有20%。
-u <target_utilization> 设置默认的node最大消耗比例,默认是85%。降低这个值可以为每个node保留更多的可用资源,提升这个值则可以更彻底的使用node资源。
-v 输出详细日志,与-l 6效果一样
-V 输出版本信息
-w <NCPUS[:MB]> numad的可执行文件运行模式,使用该参数不会拉起守护进程,而是从numad获取numa node分布建议。该运行模式返回建议的node列表。
-x <PID> 添加一个进程到exclusion list中,后续将不会扫描这些进程。numad服务启动时可以指定多个-x参数,但是启动之后每次只能添加一个。
  • numad可以以守护进程方式运行,也可以以二进制形式运行。
  • 守护进程在运行过程中可以接收二进制运行方式传入的参数,动态修改运行参数。
  • 二进制形式运行还可以根据资源用量获取分布建议。

numad原理简介

了解了numad的使用姿势之后我们不禁想搞清楚numad是如何实现进程的自动均衡分布的呢?下面就简单介绍一下numad的均衡原理。

  • numad运行守护进程会初始化一个内核消息队列用于接收传入的动态参数,并且有一个线程用于动态参数更新的处理
  • 获取物理节点的node信息,包含node的内存,CPU等资源的余量/使用量/总量,node与其他node之间的distance,node上的cpu列表等信息。
  • 获取物理节点上所有的进程信息,包含进程的vmsize,rss,CPU用量,node分布,在每个node上的内存分布等信息。
  • 通过一个循环优化每个需要管理的进程
    • 根据特定的算法获取进程最优分布的目标numa node list
            算法的关键参数为node的内存/cpu余量以及node与其他node的distance。
      
    • 为进程设置CPU亲和性到目标numa node list上(通过sched_setaffinity系统调用)
    • 为进程迁移内存到目标numa node list上(通过__NR_migrate_pages系统调用)

numad源码分析

其实上面的分析已经基本够用了,但是我们还是想打开numad的源码探究一下。

源码获取

https://github.com/K1773R/numad.git

源码分析

numad使用C语言编写,首先找到入口main函数 此类程序的main函数中一般都要先处理入参。numad的main函数根据传入的参数完成一些全局变量的初始化,后续的操作均需依赖这些初始化的值。

use_inactive_file_cache
log_level
thp_scan_sleep_ms
min_interval
max_interval
keep_interleaved_memory
target_memlocality
exclude_pid_list
include_pid_list
reserved_cpu_str
scan_all_processes
htt_percent
target_utilization
requested_cpus
requested_mbs

打开numad日志文件句柄,默认日志文件/var/log/numad.log

open_log_file();

初始化一个内核的消息队列,这个消息队列的作用是守护进程在运行过程中接收新的参数。

void init_msg_queue() {
    //程序猿的恶趣味,作为一个魔术数字相当于队列名称
    key_t msg_key = 0xdeadbeef;
    //如果不存在则创建的标志位
    int msg_flg = 0660 | IPC_CREAT;
    msg_qid = msgget(msg_key, msg_flg);
    if (msg_qid < 0) {
        numad_log(LOG_CRIT, "msgget failed\n");
        exit(EXIT_FAILURE);
    }
    //防止队列已经存在,清空队列中的消息。
    flush_msg_queue();
}

消息队列的数据结构如下:

//消息体最大长度限制
#define MSG_BODY_TEXT_SIZE 96

typedef struct msg_body {
    long src_pid;
    long cmd;
    long arg1;
    long arg2;
    char text[MSG_BODY_TEXT_SIZE];
} msg_body_t, *msg_body_p;

typedef struct msg {
    // msg mtype is dest PID
    long dst_pid;
    msg_body_t body;
} msg_t, *msg_p;

消息队列的操作接口

void recv_msg(msg_p m) {
    //读取消息队列msg_qid的消息
    //接收消息类型为当前进程PID的第一条消息
    //最后一个参数为阻塞等待,队列中没有该类型的消息将一直等待
    if (msgrcv(msg_qid, m, sizeof(msg_body_t), getpid(), 0) < 0) {
        numad_log(LOG_CRIT, "msgrcv failed\n");
        exit(EXIT_FAILURE);
    }
    // printf("Received: >>%s<< from process %d\n", m->body.text, m->body.src_pid);
}

void send_msg(long dst_pid, long cmd, long arg1, long arg2, char *s) {
    //组装消息数据结构
    msg_t msg;
    //消息类型就是目的进程pid
    msg.dst_pid = dst_pid;
    msg.body.src_pid = getpid();
    msg.body.cmd = cmd;
    msg.body.arg1 = arg1;
    msg.body.arg2 = arg2;
    int s_len = strlen(s);
    if (s_len >= MSG_BODY_TEXT_SIZE) {
        numad_log(LOG_CRIT, "msgsnd text too big\n");
        exit(EXIT_FAILURE);
    }
    strcpy(msg.body.text, s);
    size_t m_len = sizeof(msg_body_t) - MSG_BODY_TEXT_SIZE + s_len + 1;
    //消息发往之前初始化的队列,队列id为之前初始化的msg_qid
    //IPC_NOWAIT 队列满之后不等待直接返回
    if (msgsnd(msg_qid, &msg, m_len, IPC_NOWAIT) < 0) {
        numad_log(LOG_CRIT, "msgsnd failed\n");
        exit(EXIT_FAILURE);
    }
    // printf("Sent: >>%s<< to process %d\n", msg.body.text, msg.dst_pid);
}

获取当前系统可用的cpu数量,并初始化给全局变量

num_cpus = get_num_cpus();

int get_num_cpus() {
    //通过sysconf库函数获取当前系统识别到的cpu数量
    int n1 = sysconf(_SC_NPROCESSORS_CONF);
    //获取当前系统online的cpu数量
    int n2 = sysconf(_SC_NPROCESSORS_ONLN);
    if (n1 < n2) {
        n1 = n2;
    }
    if (n1 < 0) {
        numad_log(LOG_CRIT, "Cannot count number of processors\n");
        exit(EXIT_FAILURE);
    }
    return n1;
}

获取内存页及内存大页的大小并初始化给对应的全局变量

//通过sysconf库函数获取
page_size_in_bytes = sysconf(_SC_PAGESIZE);
//通过/proc/meminfo文件内容查询
huge_page_size_in_bytes = get_huge_page_size_in_bytes();

检查numad服务是否在运行中

//读取/var/run/numad.pid文件获取当前运行的pid
//如果有内容则检查/proc/%d目录是否存在。
//如果两项检查都通过则说明numad服务仍在正常运行,返回对应的pid。
int daemon_pid = get_daemon_pid();

在numad服务运行过程中,仍然可以通过执行numad二进制文件修改运行参数。使用的方式是向上面创建的内核消息队列中发送一条消息并退出。

//这里以-u和-w参数为例说明处理方式
if (u_flag) {
    //发送一条消息到消息队列
    send_msg(daemon_pid, 'u', target_utilization, 0, "");
}
if (w_flag) {
    //发送一条消息到消息队列
    send_msg(daemon_pid, 'w', requested_cpus, requested_mbs, "");
    //-w参数有返回结果,因此从队列中读取一条返回信息
    recv_msg(&msg);
    //通过标准输出返回结果
    fprintf(stdout, "%s\n", msg.body.text);
}

通过二进制形式执行的numad命令到此就结束了,退出之前会关闭日志文件句柄

close_log_file();
exit(EXIT_SUCCESS);

如果numad服务尚未运行,那么接下来需要处理一个新运行的numad服务 如果传入了cpu预留参数,则需要初始化一个cpu_set_t结构的队列用于存储预留的cpu

if (reserved_cpu_str != NULL) {
    //初始化CPU队列
    CLEAR_CPU_LIST(reserved_cpu_mask_list_p);
    //将预留CPU加入reserved_cpu_mask_list_p队列
    int n = add_ids_to_list_from_str(reserved_cpu_mask_list_p, reserved_cpu_str);
    char buf[BUF_SIZE];
    str_from_id_list(buf, BUF_SIZE, reserved_cpu_mask_list_p);
    numad_log(LOG_NOTICE, "Reserving %d CPUs (%s) for non-numad use\n", n, buf);
    //翻转reserved_cpu_mask_list_p队列,后面会用。翻转之后队列中存储的内容变为所有可被调度的CPU
    negate_cpu_list(reserved_cpu_mask_list_p);
}

如果指定了-w参数,numad只是提供内存和cpu的放置建议,不需要运行daemon。

//更新node信息,numad核心内容,后面会详细分析这里的代码
update_nodes();
sleep(2);
update_nodes();
numad_log(LOG_NOTICE, "Getting NUMA pre-placement advice for %d CPUs and %d MBs\n", requested_cpus, requested_mbs);
//根据需要的CPU和内存数量选择一个合适的node并返回,代码后面会详细分析
id_list_p node_list_p = pick_numa_nodes(-1, requested_cpus, requested_mbs, 0);
char buf[BUF_SIZE];
str_from_id_list(buf, BUF_SIZE, node_list_p);
//通过标准输出输出结果并退出
fprintf(stdout, "%s\n", buf);
close_log_file();
exit(EXIT_SUCCESS);

如果没有指定-w参数,并且指定的运行间隔max_interval>0,需要启动一个daemon服务。 首先设置透明大页刷新时间(/sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs)。thp_scan_sleep_ms参数如果为0则保持系统默认设置,否则根据传入的值设置,默认为1000ms

check_prereqs(argv[0]);

如果编译参数没有指定NO_DAEMON,需要自身daemon化

#if (!NO_DAEMON)
        //fork一个子进程,在子进程中运行numad。
        daemon_pid = fork();
        if (daemon_pid < 0) { numad_log(LOG_CRIT, "fork() failed\n"); exit(EXIT_FAILURE); }
        //父进程至此退出
        if (daemon_pid > 0) { exit(EXIT_SUCCESS); }
        //以下内容在子进程中执行
        //重置进程的文件权限
        umask(S_IWGRP | S_IWOTH);
        //为进程设置新的session id,之后将与当前进程组脱离关系作为一个新的进程组的组长进程。
        int sid = setsid();
        if (sid < 0) { numad_log(LOG_CRIT, "setsid() failed\n"); exit(EXIT_FAILURE); }
        //改变当前进程的工作目录为根目录
        if ((chdir("/")) < 0) { numad_log(LOG_CRIT, "chdir() failed"); exit(EXIT_FAILURE); }
        //创建进程pid文件/var/run/numad.pid,创建完成之后检查/proc/%d目录是否存在。
        daemon_pid = register_numad_pid();
        if (daemon_pid != getpid()) {
            numad_log(LOG_CRIT, "Could not register daemon PID\n");
            exit(EXIT_FAILURE);
        }
        //关闭标准输入输出及标准错误输出
        fclose(stdin);
        fclose(stdout);
        if (log_fs != stderr) {
            fclose(stderr);
        }
#endif

以上只是依据linux的规范完成numad服务进程自身的daemon化,接下来需要处理daemon进程对信号的处理逻辑。

//内核提供的sigaction结构初始化,可以用于设置信号处理逻辑
struct sigaction sa;
memset(&sa, 0, sizeof(sa)); 
//信号处理逻辑,此action被触发之后会被调用。此处会设置几个全局变量的值。
//SIGHUP got_sighup
//SIGTERM got_sigterm
//SIGQUIT got_sigquit
sa.sa_handler = sig_handler;
//向内核注册该线程的信号处理结构
if (sigaction(SIGHUP, &sa, NULL)
    || sigaction(SIGTERM, &sa, NULL)
    || sigaction(SIGQUIT, &sa, NULL)) {
    numad_log(LOG_CRIT, "sigaction does not work?\n");
    exit(EXIT_FAILURE);
}

接下来numad需要初始化一个进程管理的hash表,哈希表的初始大小为MIN_PROCESS_HASH_TABLE_SIZE(16),并且大小始终为2的幂数。

//process管理信息的数据结构为:
typedef struct process_data {
    int pid;//进程pid
    unsigned int flags;
    uint64_t data_time_stamp; // hundredths of seconds
    uint64_t bind_time_stamp;
    uint64_t num_threads;
    uint64_t MBs_size;
    uint64_t MBs_used;
    uint64_t cpu_util;
    uint64_t CPUs_used;  // scaled * ONE_HUNDRED
    uint64_t CPUs_used_ring_buf[RING_BUF_SIZE];
    int ring_buf_ix;
    char *comm;
    id_list_p node_list_p;
    uint64_t *process_MBs;
} process_data_t, *process_data_p;
//一个新的hash table,大小是原来的两倍。将process_hash_table的内容拷贝到新的hash table。
process_hash_table_expand();

numad服务启动后,为了接收后续动态传入的参数,需要新启动一个处理线程

//初始化两个线程互斥锁,用于保护pid_list和numa node数据
pthread_mutex_init(&pid_list_mutex, NULL);
pthread_mutex_init(&node_info_mutex, NULL);
pthread_attr_t attr;
if (pthread_attr_init(&attr) != 0) {
    numad_log(LOG_CRIT, "pthread_attr_init failure\n");
    exit(EXIT_FAILURE);
}
pthread_t tid;
//新建一个线程,运行set_dynamic_options函数
if (pthread_create(&tid, &attr, &set_dynamic_options, &tid) != 0) {
    numad_log(LOG_CRIT, "pthread_create failure: setting thread\n");
    exit(EXIT_FAILURE);
}
//set_dynamic_options函数运行一个死循环,从之前初始化的msg_qid队列中读取消息,根据传入的参数做处理。
//处理方式只要设置一下全局变量,像某些在使用中的变量(node_info, pid_list等)在设置之前还需要先获取之前初始化好的互斥锁。

相关阅读:

numad详解(2)

numad详解(3)

numad详解(4)

本文来自网易实践者社区,经作者岳文远授权发布。