系统信息记录
这篇文章展示了我的只需最小开销就可记录服务器负载的方法。使用系统时钟来执行一个C语言编写的程序,把系统信息写入页与簇对齐的文件。该文件可以被从服务器下载到本地,并通过Javascript在HTML-SVG中渲染。
系统信息, sysinfo, CPU负载, RAM, 交换分区, 系统调用, 记录, 内存页, 磁盘簇, 文件映射, C语言, Javascript, SVG, Systemd-timer
--by Captdam @ Oct 28, 2026Index
之前,我有一台运行Longview好几年的服务器。最近,我将我的服务器迁移到了一台新的安装了新的Ubuntu发行版的Linode上,但是却总是没办法成功安装Longview。头大了好久之后,我放弃了。毕竟,这不过是一个记录系统性能的程序,我完全可以自己搓一个。
免费版的Longview每5分钟记录一次系统负载,并保存数据12小时。付费版的则每分钟都记录一次,并永久保留数据。如果我自己搓一个,我可以做到更高的分辨率(甚至每秒一次记录),并且数据无限保留。除了一点额外的CPU和磁盘空间开销以外,完全不花钱。
记录信息
首先,我需要记录哪些数据?
显然,最重要的是CPU负载和RAM、交换分区用量。CPU负载可以用来测量任务处理量与处理速度,RAM与交换分区用量则提示是否会有内存溢出(out-of-memory)的风险。(而我之前确实遇到过一次,导致我的网站掉线了大半天,因此我对这个问题比较敏感。)
因为我的服务器主要用于网站,因此服务器上主要开销都来自PHP-FPM。所以,我认为没有必要记录进程树。至于FPM状态(fpm_get_status()),我将交给另一个工具。
记录文件结构
我将使用一个文件来保存系统信息记录。该文件有一个头,其中包括:
-
该记录文件的信息,例如文件中数据的数量。
-
固定的系统信息,例如总RAM与交换分区的大小。显然,总RAM与交换分区的大小不会在系统运行时改变。
,后接一系列的数据:
-
在特定时间的系统信息,例如CPU负载。
#define DATA_COUNT 255
typedef struct {
header_t header;
data_t data[DATA_COUNT];
} buffer_t;
这一系列的数据槽位构将成一个环形缓冲。我们在时间1写入槽位1,在时间2写入槽位2。当所有槽位都写完,我们绕回槽位1。使用一个索引data_wp来记录下次写入时使用的槽位,并保存在文件头中。下表展示了环形缓冲架构:
| 偏移 | 内容 |
|---|---|
| 头 | data_wp = 3 |
| 槽位0 | 记录[t-3] |
| 槽位1 | 记录[t-2] |
| 槽位2 | 记录[t-1] |
| 槽位3 | 记录[t] |
| 槽位4 | 记录[t+1-DATA_COUNT] |
| ... | ... |
| 槽位DATA_COUNT-1 | 记录[t-4] |
通过使用环形缓冲,我们每次只用写文件头与特定槽位。与FIFO相比,不需要将缓冲中所有数据都前移,因此只需要最少量的写入,即最高的性能。
我们将使用C语言来手动实现环形缓冲:
buffer_t* buffer = getRecordFile();
header_t* header = &(buffer->header);
data_t* data = &(buffer->data[header->data_wp]);
data->something = getSomething();
header->data_wp = header->data_wp == DATA_COUNT - 1 ? 0 : header->data_wp + 1;
注意,槽位数量不为2的次方。因此,我们只能使用header->data_wp = header->data_wp == DATA_COUNT - 1 ? 0 : header->data_wp + 1而不是header->data_wp = (header->data_wp + 1) & (DATA_COUNT - 1)来回绕该索引。
在我们的实现中,记录文件大小需要和系统内存页大小与磁盘簇大小对齐。也就是说,记录文件的大小才是2的次方,槽位数则不是。
Sysinfo
在Linux上,我们可以通过读系统“文件”/proc/meminfo来获取内存信息:
cat /proc/meminfo
MemTotal: 979328 kB
MemFree: 119688 kB
MemAvailable: 623512 kB
Buffers: 70664 kB
Cached: 544036 kB
SwapCached: 19616 kB
...
和/proc/stat来获取CPU负载:
cat /proc/stat
cpu 742019 33020 1270627 216150208 151630 0 12232 156155 0 0
cpu0 742019 33020 1270627 216150208 151630 0 12232 156155 0 0
intr 306200231 94 9 0 0 1624 0 0 0 1 0 0 0 15 0 0 0 0 0 0 0 0 0 0 0 0 0 0 15204220 0 7117751 8178783 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
...
例如,下面的PHP脚本就展示了如何解析内存信息:
function getSizeRaw(string $value): int {
$t = trim($value);
if (substr($t, -2) == 'kB') {
return 1024 * substr($t, 0, -2);
} else if (substr($t, -2) == 'MB') {
return 1024 * 1024 * substr($t, 0, -2);
} else if (substr($t, -2) == 'GB') {
return 1024 * 1024 * 1024 * substr($t, 0, -2);
}
return (int)$t;
}
$data = [];
foreach(file('/proc/meminfo', FILE_SKIP_EMPTY_LINES) as $x) {
[$name, $value] = explode(':', $x);
switch (trim($name)) {
case 'MemTotal': $data['total'] = getSizeRaw($value); break;
case 'MemFree': $data['free'] = getSizeRaw($value); break;
case 'MemAvailable': $data['avail'] = getSizeRaw($value); break;
case 'SwapTotal': $data['swaptotal'] = getSizeRaw($value); break;
case 'SwapFree': $data['swapfree'] = getSizeRaw($value); break;
}
}
C标准库(libc)提供了系统调用sysinfo()以便我们不手动解析系统文件就能获得当前系统信息,包括:
-
运行时间。
-
进程数。
-
CPU负载(1、5、15分钟平均值)。
-
总RAM与交换分区的大小。
-
空闲、共享与缓存RAM。
-
空闲交换分区。
此外,64位平台还包括:
-
总高位内存的大小。
-
空闲高位内存。
体积优化
sysinfo()系统调用返回了一个结构:
// 32-bit
struct sysinfo {
long uptime; /* Seconds since boot */
unsigned long loads[3]; /* 1, 5, and 15 minute load averages */
unsigned long totalram; /* Total usable main memory size */
unsigned long freeram; /* Available memory size */
unsigned long sharedram; /* Amount of shared memory */
unsigned long bufferram; /* Memory used by buffers */
unsigned long totalswap; /* Total swap space size */
unsigned long freeswap; /* Swap space still available */
unsigned short procs; /* Number of current processes */
char _f[22]; /* Pads structure to 64 bytes */
};
// 64-bit
struct sysinfo {
long uptime; /* Seconds since boot */
unsigned long loads[3]; /* 1, 5, and 15 minute load averages */
unsigned long totalram; /* Total usable main memory size */
unsigned long freeram; /* Available memory size */
unsigned long sharedram; /* Amount of shared memory */
unsigned long bufferram; /* Memory used by buffers */
unsigned long totalswap; /* Total swap space size */
unsigned long freeswap; /* Swap space still available */
unsigned short procs; /* Number of current processes */
unsigned long totalhigh; /* Total high memory size */
unsigned long freehigh; /* Available high memory size */
unsigned int mem_unit; /* Memory unit size in bytes */
char _f[20-2*sizeof(long)-sizeof(int)];
};
在32位平台上,该结构有64字节长。在64位平台上,该结构有112字节长。
对于大部分电脑来说,long在32位平台上为32位宽,在64位平台上为64位宽。int在两种平台上都是32位宽。short在两种平台上都是16位宽。
在64位平台上,procs后可能需要添加空格以保证long totalhigh对齐可读。
在某些情况下,该结构的大小可能还会不同,即使同样都是64位机,因为int和long的宽度并没有一个通用的确定的值。个人来说,我倾向于使用显性宽度的类型,例如int32_t与uint64_t。
为了节约空间,以此提高这个记录程序的性能,我决定最小化该数据结构:
typedef struct {
uint16_t data_wp, data_total;
uint16_t ram_total, swap_total;
uint16_t data_blocksize;
uint16_t _pad[3];
} header_t;
typedef struct {
uint32_t uptime;
uint16_t process;
uint8_t load1, load5;
uint16_t ram_free, ram_shared, ram_buffer, swap_free;
} data_t;
对于系统信息相关的数据:
-
运行时间 Uptime
uint32_t-uint32_t支持最大4294967295秒,即136年。应该没谁服务器开上百年不重启吧。uint16_t则太短(18小时),uint64_t则没有必要。 -
进程数 Process
uint16_t-uint16_t支持最大65535个进程,也够用了。虽然我这台机器上/proc/sys/kernel/pid_max返回4194304,但基本上只会有150到200个进程运行。struct sysinfo也使用16位的unsigned short来记录该信息。 -
负载 Load
uint8_t- 我将以0.4%分辨率记录1分钟与5分钟平均值。也就是说,满负载为250个0.4%,uint8_t正好。不幸的是,我没有额外空间来记录15分钟平均值了。
data_t中所有16个字节都被分配了。我也不准备为了多记录这么一个8比特的数据而将data_t扩展到32字节。至于向uptime借用8比特,其只剩下24比特的空间,将会每194天溢出。因此,我决定舍弃15分钟平均值。 -
RAM与交换分区大小(总量与空闲)
uint16_t- 我们没必要记录实际的值,如果超过4GB,将需要使用uint64_t。因此,我将记录块数量。比如说,我将一个块定义为128kB,于是,我就可以把6GB记作49152(6GB / 128kB = 49152),只需使用uint16_t。 -
块大小
uint16_t- 我们在文件头中记录块大小。因为基本所有系统上,内存页大小和磁盘簇大小最小都肯定为4kB,我们只需要记录一个块等同于多少个4kB页。比如说,我可以把128kB记作64(128kB / 4kB = 64),只需使用uint16_t。块大小定义了我们的记录程序对RAM和交换分区的分辨率。例如,如果块大小为128kB,但空闲RAM为520kB,我们只能记录取整后的512kB。
不过,相较于大了好几个数量级的实际读书,分辨率带来的误差微乎其微,且高精度的RAM和交换分区信息也没有什么实际作用。
对于记录文件相关的数据:
-
总记录槽位数
uint16_t-uint16_t支持最大65535个记录。 -
下次写入槽位(索引)
uint16_t- 通过array[data_wp]定位,使用和data_total相同的类型。
像这样,文件头为16字节长,每一个记录槽位也是16字节长。4kB空间就可以保存255条记录,而64kB空间则可以保存多达4095条记录。
内存页和磁盘簇使用越少越好。这不仅能节约RAM和磁盘空间,还可以提高我们记录系统信息时打开文件、写内存、保存文件的速度。我们不希望这个记录系统信息的程序消耗太多系统资源。
4kB是一个神奇的数字。它既是内存页大小(不考虑大页),也就是最小RAM用量单位,即使我们使用更小的数量,系统也得为我们的程序分配这么大的RAM,因为虚拟内存按页分配。同时也是磁盘簇的大小,也就是说只要一次磁盘IO来读或写这个记录文件,使用更小的数字不会有任何提升(甚至可能反噬,只占部分簇文件将需要对整个簇进行读-改-写),使用更大的数字则需要多个IO。
记录频率与长度
下边展示了一些记录文件的大小、槽位数、频率与可保存的总时长:
| 文件大小 | 槽位数 | 频率 | 时长 |
|---|---|---|---|
| 4kB | 255 | 1秒 | 4分15秒 |
| 4kB | 255 | 6分 | 25时3分 |
| 64kB | 4095 | 6分 | 17天 |
| 64kB | 255 | 0.5小时 | 85天 |
| 1MB | 65535 | 1小时 | 7.5年 |
记录程序
我们将使用C语言来编写一个记录当前系统信息的程序。
相比于创建一个自发周期采样的长运行后台进程,这个程序应该被系统时钟周期性地执行,例如使用systemd-timer或cronjob。这简化了该程序的设计,并提供更高的灵活性。
数据结构
#define FILE_SIZE 4096 // 65536 // Better align with memory page size and disk cluster size
#define DATA_COUNT 255 // 4095 // Cross check with FILE_SIZE, FILE_SIZE = 16 + 16 * DATA_UNITSIZE
#define DATA_UNITSIZE ((uint64_t)(64 * 4096))
typedef struct {
uint16_t data_wp, data_total;
uint16_t ram_total, swap_total;
uint16_t data_blocksize;
uint16_t _pad[3];
} header_t;
typedef struct {
uint32_t uptime;
uint16_t process;
uint8_t load1, load5;
uint16_t ram_free, ram_shared, ram_buffer, swap_free;
} data_t;
typedef struct {
header_t header;
data_t data[DATA_COUNT];
} buffer_t;
int main(int argc, char** argv) {
static_assert(sizeof(data_t) == 16, "Data size must be 16");
static_assert(sizeof(header_t) == 16, "Header size must be 16");
static_assert(sizeof(buffer_t) == FILE_SIZE, "Buffer size mismatch with data count");
static_assert(DATA_UNITSIZE / 4096 <= 65535, "Data unit cannot fit into uint16_t");
定义记录文件数据结构。记录文件可以用buffer_t表示,包括一个文件头header_t header,后接一系列数据槽位data_t data[DATA_COUNT]。
文件头包含:
- 数据槽位数量。
- 下一次写入槽位(索引)。
- 总RAM大小(单位:块)。
- 总交换分区大小(单位:块)。
- RAM与交换分区块大小(单位:4kB)。
- 补齐到16字节。
每个数据槽位包含:
- 运行时间(启动后秒)。
- 进程数。
- 1分钟与5分钟平均CPU负载(最大250)。
- 空闲、共享与缓存RAM、空闲交换分区(单位:块)。
在编译时,我们将检查:
-
文件头和数据槽位都是16字节大。
-
记录文件大小
FILE_SIZE和数据槽位数量DATA_COUNT匹配(FILE_SIZE = 16 + 16 * FILE_SIZE)。这用于在修改大小时交叉检查,以确保对齐。 -
AM与交换分区块大小
DATA_UNITSIZE(单位:4kB)不在uint16_t溢出。最大允许262140MB(65535 * 4kB)。
测量当前系统信息
struct sysinfo sys_info;
if (sysinfo(&sys_info)) {
fprintf(stderr, "Cannot get sysinfo err: %d.\n", errno);
return -1;
}
uint64_t ram_total = (uint64_t)(sys_info.totalram) * sys_info.mem_unit, ram_write = ram_total / DATA_UNITSIZE;
uint64_t swap_total = (uint64_t)(sys_info.totalswap) * sys_info.mem_unit, swap_write = swap_total / DATA_UNITSIZE;
if ( ram_write > (uint16_t)-1 || swap_write > (uint16_t)-1 ) {
fprintf(stderr, "DATA_UNITSIZE (%"PRIu64") too small, allow max %"PRIu64" - RAM needs %"PRIu64" Swap needs %"PRIu64"\n", DATA_UNITSIZE, (uint64_t)(DATA_UNITSIZE * (uint16_t)-1), ram_total, swap_total);
return -1;
}
使用系统调用sysinfo()获得当前系统信息。
在运行时进行总RAM与交换分区大小数据类型检查。如果总RAM与交换分区大小(单位:块)在uint16_t会溢出的话,程序将终止。
因为总RAM肯定比空闲、共享与缓存RAM大,同样,总交换分区肯定比空闲交换分区大,所以只要检查总RAM与交换分区大小数据类型。另外,总RAM与交换分区大小是固定的,如果总RAM与交换分区大小不能被放进uint16_t,这个问题在第一次运行时就会浮现出来。我们就可以调整大小。
打开记录文件
if (argc != 2) {
fprintf(stderr, "Buffer filename not provided.\n");
return -1;
}
int file = open(argv[1],
O_RDWR | O_CREAT,
S_IRUSR|S_IWUSR | S_IRGRP | S_IROTH
);
if (file < 0) {
fprintf(stderr, "Cannot open file err: %d.\n", errno);
return -1;
}
if (flock(file, LOCK_EX) < 0) {
fprintf(stderr, "Cannot lock file err: %d.\n", errno);
return -1;
}
if (ftruncate(file, sizeof(buffer_t)) < 0) {
fprintf(stderr, "Cannot allocate buffer file err: %d.\n", errno);
return -1;
}
buffer_t* buffer = mmap(NULL, sizeof(buffer_t), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, file, 0);
为了提高性能,我们将使用文件描述符(int file = open())而不是文件指针(FILE* fp = fopen())来操作记录文件。
我们将使用读写模式打开文件。如果这个文件不存在(第一次运行),就创建一个。设置文件权限,自己可读写,组和他人都只读。
我们必须要让文件他人可读,这样才能用另一个程序来允许读取系统信息记录。
获取一个记录文件的建议性锁。这用于防止我们在其他进程读取时写入文件,也防止我们写入时被其它进程读取。
截短(也表示扩展)这个文件到所需的长度。这主要用于在新建文件时。因为我们的记录文件的长度是固定的FILE_SIZE,因此更好在创建文件时就分配这么长的文件空间。这样,就不需要每次写入新数据时都动态分配空间,那不仅慢还有更多失败可能性。
将记录文件以读写模式(同文件打开的模式)映射到内存缓冲中。设置为可共享的,这样写入的数据在外部也是可读的。同时,填充该缓冲,以防止之后页缺失。
已定义的、固定的内存是最安全的。
创建文件头(第一次运行)
header_t* header = &(buffer->header);
if (header->data_total == 0) {
fprintf(stderr, "Create file.\n");
header->data_wp = 0;
header->data_total = DATA_COUNT;
header->data_blocksize = DATA_UNITSIZE / 4096; // of 4k page
header->ram_total = ram_total / DATA_UNITSIZE;
header->swap_total = swap_total / DATA_UNITSIZE;
}
首次运行这个程序时,记录文件不存在。这种情况下,我们将创建记录文件并填充到需要的大小。当填充文件时,将使用零。我们可以通过检查文件头的方式判断文件是否是新创建的。我决定通过读取文件头的数据槽位数header->data_total来判断,如果为零,则说明是新的文件。
对于新建记录文件,我们需要填充下列信息:
-
初始化写索引为0,也就是说下次写入时使用数据槽位0。
-
数据槽位数。
-
RAM与交换分区块大小(单位:4kB)。
-
总RAM与交换分区大小(单位:块)。
我们使用uint16_t来记录信息以节约空间。处理数据时,必须将数据以更大的数据类型,如uint64_t来操作,否则,C编译器将使用原始的数据类型,即uint16_t来计算,有溢出风险。我们只在写入结果到内存时才截短到uint16_t。
写入系统信息
float load1 = 250.0f * sys_info.loads[0] / get_nprocs() / (1<<SI_LOAD_SHIFT); // out of 250
float load5 = 250.0f * sys_info.loads[1] / get_nprocs() / (1<<SI_LOAD_SHIFT); // out of 250
data_t* data = &(buffer->data[header->data_wp]);
data->uptime = sys_info.uptime; // Need more than tens of years uptime?
data->process = sys_info.procs > (uint16_t)-1 ? (uint16_t)-1 : sys_info.procs;
data->load1 = load1 > 255 ? 255 : load1;
data->load5 = load5 > 255 ? 255 : load5;
data->ram_free = (uint64_t)(sys_info.freeram) * sys_info.mem_unit / DATA_UNITSIZE;
data->ram_shared = (uint64_t)(sys_info.sharedram) * sys_info.mem_unit / DATA_UNITSIZE;
data->ram_buffer = (uint64_t)(sys_info.bufferram) * sys_info.mem_unit / DATA_UNITSIZE;
data->swap_free = (uint64_t)(sys_info.freeswap) * sys_info.mem_unit / DATA_UNITSIZE;
header->data_wp = header->data_wp == DATA_COUNT - 1 ? 0 : header->data_wp + 1;
使用写索引&(buffer->data[header->data_wp])以获取到要写入的槽位的指针。
-
CPU负载
sysinfo()系统调用返回unsigned long表示CPU负载。这个数包含了所有核心的负载总和,因此,多核系统上其结果可超过100%。要获得平均单核负载,除以
get_nprocs(),即核心数。CPU负载的取值范围可以表示为
1<<SI_LOAD_SHIFT。我们把平均单核负载除以这个数就可以获得单位单核CPU负载,取值范围为0到1。因为我们使用
uint8_t记录CPU负载,取值范围为0到255。我们可以把单位单核CPU负载乘以250,使其取值范围为0到250,即0.4%的分辨率。为了防止计算过程中溢出和最小化化整误差,我们使用浮点数float计算,单精度浮点数进度足够。将CPU负载写入内存
uint8_t,并限制为0到255,以防CPU负载高于100%的情况。 -
运行时间
将运行时间截短为32位无符号整数。这允许最大136年不间断运行,完全够用。毕竟没人这么久还不重启以此服务器。
-
进程数
如果
sysinfo()系统调用返回超过65535个进程,限制该数量为65535。不过这并不常见,因为struct sysinfo的定义也使用16比特宽的unsigned short来记录这个信息。 -
RAM和交换分区
记录空闲、共享与缓存RAM、空闲交换分区,单位:块。
最后,写索引进一。如果到达环形缓冲末尾,回转到0。
下面的代码在调试时使用,已检查结果,注意在首次运行时会创建记录文件:
fprintf(stdout, "data_total %"PRIu32" Write pointer to %"PRIu32" %p %p.\n", header->data_total, header->data_wp, buffer, data);
fprintf(stdout, "uptime %"PRIu32"\n", data->uptime);
fprintf(stdout, "- process %"PRIu16"\n", data->process);
fprintf(stdout, "- load %"PRIu8" %"PRIu8"\n", data->load1, data->load5);
fprintf(stdout, "ram_total %"PRIu16"\n", header->ram_total);
fprintf(stdout, "- ram_free %"PRIu16"\n", data->ram_free);
fprintf(stdout, "- ram_shared %"PRIu16"\n", data->ram_shared);
fprintf(stdout, "- ram_buffer %"PRIu16"\n", data->ram_buffer);
fprintf(stdout, "swap_total %"PRIu16"\n", header->swap_total);
fprintf(stdout, "- swap_free %"PRIu16"\n", data->swap_free);
fprintf(stdout, "x %"PRIu16" x 4kB\n", header->data_blocksize);
./sysinfo_log4k.elf 4k.bin
Create file.
data_total 255 Write pointer to 0 0x74d6e5c95000 0x74d6e5c95010.
uptime 2205178
- process 189
- load 11 24
ram_total 3825
- ram_free 406
- ram_shared 23
- ram_buffer 285
swap_total 40895
- swap_free 40446
x 64 x 4kB
./sysinfo_log4k.elf 4k.bin
data_total 255 Write pointer to 1 0x7177825c7000 0x7177825c7020.
uptime 2205190
- process 189
- load 48 31
ram_total 3825
- ram_free 406
- ram_shared 23
- ram_buffer 285
swap_total 40895
- swap_free 40446
x 64 x 4kB
同步并关闭记录文件
int pagesize = getpagesize();
void* data_page = (void*)((uint64_t)data & ~(uint64_t)(pagesize - 1));
if (data_page == buffer) {
msync(data_page, pagesize, MS_ASYNC);
}
msync(buffer, pagesize, MS_SYNC);
munmap(buffer, sizeof(buffer_t));
flock(file, LOCK_UN);
close(file);
return 0;
}
将内存中修改同步到文件。取消文件映射,解锁并关闭文件描述符。
多页同步
在讨论内存映射同步前,我们需要达成共识:
-
我们可以安全地假设内存页大小是2的次方,且最小为4096字节长。几乎所有的情况下,这个数都是4096。
-
当同步内存映射到底层文件时,我们将需要同步整个页。即使我们只修改了一个页的一部分,也需要同步整个页。
-
我们将记录文件的大小定义为页的倍数。比如说,4kB、64kB。
-
文件头和数据槽位都是16字节大小的。也就是说,页的大小是文件头/数据槽位的倍数。这代表文件头/数据槽位肯定只会占用一个特定页的一部分,不存在有一个文件头/数据槽位的前半部分在一个页,后半部分在另一个页的情况。
在记录系统信息后,有两项改变:
-
文件头中的写索引:
buffer->header.data_wp。 -
修改的数据槽位:
buffer->data[buffer->header.data_wp]。
如果文件头和修改的数据槽位都在同一页中,我们只需要同步该页即可。另外,因为文件头就在缓冲开头,header等同于buffer,且header肯定是页对齐的(mmap()返回buffer)。
如果文件头和修改的数据槽位不在同一页中,我们将需要同步各自所在的页。我们先非阻塞同步数据页(不等待),然后阻塞同步文件头页(等待写入完成)。
写入顺序非常重要,应为在文件锁失效的情况下(因为我们使用的是建议性锁,其它进程可能忽略锁检查,或是某些情况下,比如远程文件读写,并不支持锁),读进程可以认为写索引指向的槽位是脏数据,参考下表:
| 偏移 | 内容 |
|---|---|
| 头 | data_wp = 3 |
| 槽位0 | 记录[t-3] |
| 槽位1 | 记录[t-2] |
| 槽位2 | 最新记录 |
| 槽位3 | 脏记录(正在写/下次写/刚写完) |
| 槽位4 | 最旧记录(危险区,如果读进程比采样率慢) |
| ... | ... |
| 槽位DATA_COUNT-1 | 记录[t-4] |
-
读发生在在写入数据页与文件头页前 - 脏槽位包含最旧记录。
-
读发生在在写入数据页后但文件头页前 - 脏槽位包含最新记录。我们只能在下次读取时才能认为该记录有效。
-
读发生在在写入数据页与文件头页后 - 脏槽位下移,到现在来说的最旧记录。之前的脏槽位中的最新记录现在就是有效的了。
虽然我们可以同步一整个大于4kB的记录文件,功能性上面来说,并无问题。但是,性能上来说,没有修改的页也需要被写入,造成了冗余。文件越大,影响越严重。但是,值得提出的是,两次系统调用msynch()将带来两次上下文切换,且上下文切换的开销也不容小视。
编译与测试
#define FILE_SIZE 4096
#define DATA_COUNT 255
gcc a.c -O2 -o sysinfo_log4k.elf
#define FILE_SIZE 65536
#define DATA_COUNT 4095
gcc a.c -O2 -o sysinfo_log64k.elf
使用GCC编译这个C语言程序。我决定创建两个版本,一个创建4kB文件,支持255条记录,另一个创建64kB文件,支持4095条记录。
下图展示了运行数次程序后记录文件的内容:
| 地址 | F | E | D | C | B | A | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Header | 填充 | 块大小 | 总交换分区 | 总RAM | 槽位数量 | 写索引 | ||||||||||
| 0x000 | 填充 | 0x0040 64页 256kB | 0x9FBF 40895块 10,469,120kB | 0x0EF1 3825块 979,200kB |
0x00FF 255 | 0x000F 15 |
||||||||||
| 数据槽位 | 空闲交换分区 | 缓存RAM | 共享RAM | 贡献RAM | 5分钟平均负载 | 1分钟平均负载 | 进程数 | 运行时间 | ||||||||
| 0x010 | 0x9DFE 40446块 10,354,176kB | 0x011D 285块 72,960kB | 0x0017 23块 4,352kB | 0x0E96 406块 103,936kB |
0x18 24 9.6% | 0x0B 11 4.4% |
0x00BD 189 | 0x0021A5FA 2205178秒 25.5天 |
||||||||
| 0x100 | 0x00000000000000000000000000000000 空白,下次写 |
|||||||||||||||
| 0xFF0 | 0x00000000000000000000000000000000 环形缓冲最后一项,索引=255 |
|||||||||||||||
系统时钟
我决定使用systemd-timer而不是cronjob因为systemd-timer允许高达秒级别的频率,而cronjob只能到分级别的频率。
我创建了3个时钟任务:
短期实时记录
该任务用于实时记录系统信息,有助于发现实时负载尖峰。
比如说,我可以使用这个任务来压力测试服务器。任何负载变化都能被立刻发现(确切来说,下一次采样,也就是几秒后)。
最先,我设定为每秒记录一次,并保存255条记录在记录文件中,也就是说约4分钟长的实时系统信息。但是,记录之间,sysinfo()返回的数据看起来并无变化。或许是因为其底层刷新的速度小于1Hz。因此,我决定将采样率降低为2Hz。
下面是定义该任务的timer和service文件:
// sysinfo_log_2s.timer
[Unit]
Description=Record sysinfo every 2 seconds
StartLimitBurst=3
StartLimitIntervalSec=1
[Timer]
OnBootSec=2s
OnUnitActiveSec=2s
AccuracySec=1s
[Install]
WantedBy=timers.target
// sysinfo_log_2s.service
[Unit]
Description=Record sysinfo every 2 seconds
StartLimitBurst=3
StartLimitIntervalSec=1
[Service]
ExecStart=/path/to/executable/sysinfo_log4k.elf /path/to/recordfile/sysinfo_log_2s.bin
日记录
该任务追踪24小时区间的系统信息,用于展示日趋势,例如,日间与夜间对比。
该任务每6分钟记录一次,包含255条记录,即25.5小时长的系统信息。
下面是定义该任务的timer和service文件:
// sysinfo_log_6m.timer
[Unit]
Description=Record sysinfo every 6 minutes
StartLimitBurst=3
StartLimitIntervalSec=1
[Timer]
OnBootSec=2s
OnUnitActiveSec=6m
AccuracySec=1m
[Install]
WantedBy=timers.target
// sysinfo_log_6m.service
[Unit]
Description=Record sysinfo every 6 minutes
StartLimitBurst=3
StartLimitIntervalSec=1
[Service]
ExecStart=/path/to/executable/sysinfo_log4k.elf /path/to/recordfile/sysinfo_log_6m.bin
月记录
该任务追踪一个月区间的系统信息,用于展示长期趋势。
该任务每10分钟记录一次,包含4095条记录,即28.4天的系统信息。
下面是定义该任务的timer和service文件:
// sysinfo_log_6m.timer
[Unit]
Description=Record sysinfo every 10 minutes
StartLimitBurst=3
StartLimitIntervalSec=1
[Timer]
OnBootSec=2s
OnUnitActiveSec=10m
AccuracySec=1m
[Install]
WantedBy=timers.target
// sysinfo_log_6m.service
[Unit]
Description=Record sysinfo every 10 minutes
StartLimitBurst=3
StartLimitIntervalSec=1
[Service]
ExecStart=/path/to/executable/sysinfo_log64k.elf /path/to/recordfile/sysinfo_log_10m.bin
启动系统时钟
下面命令用于启动系统时钟:
systemctl enable --now sysinfo_log_2s.timer
systemctl enable --now sysinfo_log_6m.timer
systemctl enable --now sysinfo_log_10m.timer
要展示所有时钟:
systemctl list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-10-27 18:50:24 UTC 799ms Tue 2026-10-27 18:50:22 UTC 1s ago sysinfo_log_2s.timer sysinfo_log_2s.service
Tue 2026-10-27 18:52:30 UTC 2min 5s Tue 2026-10-27 18:46:30 UTC 3min 54s ago sysinfo_log_6m.timer sysinfo_log_6m.service
Tue 2026-10-27 18:55:58 UTC 5min Tue 2026-10-27 18:45:58 UTC 4min 25s ago sysinfo_log_10m.timer sysinfo_log_10m.service
读取(并渲染)记录
记录程序的可执行文件和记录文件应该被保存在一个网络不可访问的目录(例如/home/myhome),但设置为xx4(任何用户可读)。在网络可访问的目录(例如/var/www/html)中创建软链接并指向记录文件。这样,网络接口(执行用户www-data)就不能读写记录程序可执行文件与原始记录文件,而是只能读记录文件的副本。即使网络接口炸了,也不会通过我们的记录程序影响服务器。(例如,将记录程序更换为病毒程序。)
网络服务器将不会对记录文件进行任何处理,反而,该文件直接以二进制的方式下载到本地。
当然,网络接口需要对文件进行一些访问控制。例如,只有管理员才能下载。具体如何实现不在本文的讨论范围。另外,网络接口也需要检查文件锁。
在记录文件下载到本地后,我们通过Javascript来解码并渲染。
帮助函数
const _ = (s, p = document) => p.querySelector(s);
const __ = (s, p = document) => p.querySelectorAll(s);
const dom = j => {
if (typeof j == 'string') {
const x = (new DOMParser()).parseFromString(j, 'text/html').body;
if (_('parsererror', x))
throw new Error(_('parsererror', x).textContent);
if (_('sourcetext', x))
throw new Error(_('sourcetext', x).textContent);
return x;
} else {
let tag = 'div', children = [];
if ('_' in j) { tag = j._; delete j._; }
if ('children' in j) { children = j.children; delete j.children; }
const x = Object.assign(document.createElement(tag), j);
children.map(y => {if (y) x.appendChild(dom(y))});
return x;
}
};
const svg_new = (width, height) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('preserveAspectRatio', 'none');
svg.setAttribute('viewBox', '0 0 '+width+ ' '+height);
return svg;
};
const poly_new = (type, points, color='transparent', fill='transparent') => {
const poly = document.createElementNS('http://www.w3.org/2000/svg', type);
poly.setAttribute('points', points);
poly.setAttribute('stroke', color);
poly.setAttribute('fill', fill);
return poly;
};
class MaxMin {
constructor(min, max) {
this.min = min;
this.max = max;
}
add(value) {
this.min = value < this.min ? value : this.min;
this.max = value > this.max ? value : this.max;
}
}
const uptimeHuman = second => {
const day = Math.floor(second / 3600 / 24);
second -= day * 3600 * 24;
const hour = Math.floor(second / 3600);
second -= hour * 3600;
const minute = Math.floor(second / 60);
second -= minute * 60;
return [day+'d'+hour+'h'+minute+'m'+second+'s', day, hour, minute, second];
};
帮助函数:
-
_()和__()- 选择器。 -
dom()- 创建DOM树。 -
svg_new()和poly_new()- 创建SVG和SVG多边形/折线。 -
MaxMin- 记录一组数的最小与最大值。 -
uptimeHuman()- 将秒转换为方便阅读的天-时-分-秒格式。
DOM
// HTML
<div id="waveform_text"><div id="waveform_text_overview"></div><div id="waveform_text_pointer"></div></div>
<svg id="waveform" viewBox="0 0 100 100" preserveAspectRatio="none" stroke-width="0.15"></svg>
<style>#waveform_text { position: absolute; padding: 0 1ch; background: rgba(255,255,255,0.25); } #waveform_text:hover { background: rgba(255,255,255,0.85); } #waveform { width: 100%; height: 400px; fill: transparent; } #waveform>* { width: 100%; height: 100%; }</style>
// JS
let sysinfo_data = [];
let sysinfo_pointer_svg = svg_new(2500, 100);
创建HTML元素:
-
div #waveform_text- 打印系统信息。第一个子元素用于打印趋势信息,第二个子元素用于打印可交互操作面板(光标指向的时间的信息)。 -
#waveform- 绘制趋势。
创建两个全局变量:
-
sysinfo_data- 保存从服务器下载并解码后的记录文件。 -
#waveform- 用于绘制可交互操作面板(一个光标)的DOM。
趋势图
下载记录文件
const sysinfo_log = async (filename) => { try {
const file = new Uint16Array(( await (( await fetch('/sysinfo_log_' + filename + '.bin') ).bytes()) ).buffer);
从服务器下载记录文件。因为记录文件主要使用uint16_t,我们可以用Uint16Array来在Javascript储存它。
处理文件头
const data_blocksize = file[4] * 4096;
const svg_width = file[1] - 1;
const header = {
data_wp: file[0],
data_total: file[1],
ram_total: file[2] * data_blocksize,
swap_total: file[3] * data_blocksize
};
let path_cpu = '', maxmin_cpu = new MaxMin(100, 0);
let path_cpu5 = '', maxmin_cpu5 = new MaxMin(100, 0);
let path_ramFree = '', maxmin_ramFree = new MaxMin(header.ram_total, 0);
let path_ramShared = '', maxmin_ramShared = new MaxMin(header.ram_total, 0);
let path_ramBuffer = '', maxmin_ramBuffer = new MaxMin(header.ram_total, 0);
let path_swapFree = '', maxmin_swapFree = new MaxMin(header.swap_total, 0);
let maxmin_ramUsed = new MaxMin(header.ram_total, 0);
let maxmin_swapUsed = new MaxMin(header.swap_total, 0);
提取文件头信息:
-
data_wp- 环形缓冲写索引。 -
data_total- 环形缓冲中槽位数。 -
ram_total- 总RAM大小,将单位从块转换为字节。 -
swap_total- 总交换分区大小,将单位从块转换为字节。 -
data_blocksize- RAM与交换分区块大小,将单位从页转换为字节。
不用再担心储存空间限制了。在本地可以使用原始大小。
处理数据槽位
sysinfo_data = [];
for (let i = 0; i < header.block_total; i++) {
let ptr = (i + header.block_wp) % header.block_total;
const ram_free = file[8 + 8 * ptr + 4] * data_blocksize;
const ram_shared = file[8 + 8 * ptr + 5] * data_blocksize;
const ram_buffer = file[8 + 8 * ptr + 6] * data_blocksize;
const swap_free = file[8 + 8 * ptr + 7] * data_blocksize;
const d = {
uptime: (file[8 + 8 * ptr + 1] << 16) | file[8 + 8 * ptr + 0],
process: file[8 + 8 * ptr + 2],
load: (file[8 + 8 * ptr + 3] & 0xFF) / 2.5, // Server gives uint8_t[2], range 0 - 250
load5: (file[8 + 8 * ptr + 3] & 0xFF00) / (2.5 * 256),
ram_free: ram_free,
ram_shared: ram_shared,
ram_buffer: ram_buffer,
swap_free: swap_free,
ram_used: header.ram_total - ram_free - ram_shared - ram_buffer,
swap_used: header.swap_total - swap_free
};
sysinfo_data.push(d);
//max_cpu = d.load > max_cpu ? d.load : max_cpu;
maxmin_cpu.add(d.load);
maxmin_cpu5.add(d.load5);
maxmin_ramFree.add(d.ram_free);
maxmin_ramShared.add(d.ram_shared);
maxmin_ramBuffer.add(d.ram_buffer);
maxmin_swapFree.add(d.swap_free);
maxmin_ramUsed.add(d.ram_used);
maxmin_swapUsed.add(d.swap_used);
path_cpu += ' '+i+','+ (100-d.load);
path_cpu5 += ' '+i+','+ (100-d.load5);
path_ramFree += ' '+i+','+ (d.ram_free/data_blocksize); // Reduce svg size, don't use byte number (too large), use block size
path_ramBuffer += ' '+i+','+ (d.ram_buffer/data_blocksize + d.ram_free/data_blocksize) ;
path_ramShared += ' '+i+','+ (d.ram_shared/data_blocksize + d.ram_buffer/data_blocksize + d.ram_free/data_blocksize); // Stack display
path_swapFree += ' '+i+','+ d.swap_free/data_blocksize;
}
清空缓冲sysinfo_data,我们将用最新的数据重新填充它。
因为我们使用环形缓冲,且写索引data_wp指向下一次要写入的槽位(或是在文件锁失效时的脏槽位),data_wp+0为最旧数据,data_wp+1为第二旧数据,data_wp-1为最新数据。
每个数据槽位包括下列信息:
-
uptime- 32位,由高低Uint16Array组和。 -
process- 进程数。 -
load- 1分钟平均负载,保存在低字节,将取值范围从0到250转换为0.0到100.0。注意,Javascript默认使用float。 -
load5- 1分钟平均负载,保存在高字节,将取值范围从0到250转换为0.0到100.0。 -
ram_free- 空闲RAM,将单位从块转换为字节。 -
ram_shared- 共享RAM,将单位从块转换为字节。 -
ram_buffer- 缓存RAM,将单位从块转换为字节。 -
swap_free- 空闲交换分区,将单位从块转换为字节。 -
ram_used- 已使用RAM,虽然记录文件中并不包含,但是可以通过使用总RAM大小和其它RAM大小计算。 -
swap_used- 已使用交换分区,虽然记录文件中并不包含,但是可以通过使用总交换分区大小和空闲交换分区大小计算。
对于每一项信息:
-
使用
MaxMin追踪其在整个记录文件中的峰值与谷值。 -
创建一个多边形/折线路径点。为了降低SVG的大小,我们需要使用RAM和交换分区的块数量,这将限制SVG最大高度位65k像素(
UINT16_MAX)。不然,我们就会得到上G像素的雷霆SVG。
打印信息
const data_last = sysinfo_data[sysinfo_data.length-1];
_('#waveform_text_overview').replaceChildren(
dom({_: 'p', textContent: 'CPU: Current '+data_last.load+'% ('+maxmin_cpu.min+'% min '+maxmin_cpu.max+'% max) - Avg5 '+data_last.load5+'% ('+maxmin_cpu5.min+'% min '+maxmin_cpu5.max+'% max) - Process '+data_last.process, style: 'color: red'}),
dom({_: 'p', textContent: 'RAM: Free '+data_last.ram_free/1024/1024+'MB ('+maxmin_ramFree.min/1024/1024+'MB min '+maxmin_ramFree.max/1024/1024+'MB max) - Buffer '+data_last.ram_buffer/1024/1024+'MB ('+maxmin_ramBuffer.min/1024/1024+'MB min '+maxmin_ramBuffer.max/1024/1024+'MB max) - Shared '+data_last.ram_shared/1024/1024+'MB ('+maxmin_ramShared.min/1024/1024+'MB min '+maxmin_ramShared.max/1024/1024+'MB max) - Used '+data_last.ram_used/1024/1024+'MB ('+maxmin_ramUsed.min/1024/1024+'MB min '+maxmin_ramUsed.max/1024/1024+'MB max)', style: 'color: darkgreen'}),
dom({_: 'p', textContent: 'Swap: Free '+data_last.swap_free/1024/1024+'MB ('+maxmin_swapFree.min/1024/1024+'MB min '+maxmin_swapFree.max/1024/1024+'MB max) - Used '+data_last.swap_used/1024/1024+'MB ('+maxmin_swapUsed.min/1024/1024+'MB min '+maxmin_swapUsed.max/1024/1024+'MB max)', style: 'color: blue'}),
dom({_: 'p', textContent: 'Uptime '+uptimeHuman(data_last.uptime)[0]+' - '+header.block_total+' samples'}),
);
打印各项信息的当前(最近)、最低、最高值。
绘制SVG
const svg_cpu = svg_new(svg_width, 100);
svg_cpu.appendChild(poly_new('polyline', path_cpu, 'rgb(255, 0, 0)'));
svg_cpu.appendChild(poly_new('polyline', path_cpu5, 'rgb(127, 63, 63)'));
const svg_ram = svg_new(svg_width, header.ram_total/data_blocksize);
svg_ram.appendChild(poly_new('polygon', svg_width+',0 0,0 0,'+header.ram_total/data_blocksize+' '+svg_width+','+header.ram_total/data_blocksize, '#030', '#030'));
svg_ram.appendChild(poly_new('polygon', svg_width+',0 0,0' + path_ramShared, 'transparent', 'darkgreen'));
svg_ram.appendChild(poly_new('polygon', svg_width+',0 0,0' + path_ramBuffer, 'transparent', 'green'));
svg_ram.appendChild(poly_new('polygon', svg_width+',0 0,0' + path_ramFree, 'transparent', 'lightgreen'));
const svg_swap = svg_new(svg_width, header.swap_total/data_blocksize);
svg_swap.appendChild(poly_new('polyline', path_swapFree, 'rgb(0, 0, 255)'));
const svg_grid = svg_new(svg_width, 100);
for (let i = 1; i < 10; i++) {
const y = 10 * i;
svg_grid.appendChild(poly_new('polyline', svg_width+','+y+' 0,'+y, i == 5 ? 'rgba(128,128,128,0.75)' : 'rgba(128,128,128,0.45)'));
}
sysinfo_pointer_svg = svg_new(svg_width, 100);
_('#waveform').replaceChildren(svg_ram, svg_grid, svg_cpu, svg_swap, sysinfo_pointer_svg);
_('#waveform').dispatchEvent(new Event('mousemove'));
} catch (e) { dialog_error(0, 'System info log failed: ' + e); throw e; } };
使用折线来绘制CPU负载(红色)与交换分区用量(蓝色)趋势。
使用多边形来绘制RAM用量(绿色)趋势。Use polygon to draw the RAM trend.
最后,加上网格(灰色)方便阅读。每格为10%。
因为SVG内数据变化,向可交互操作面板发出一个事件来更新它。
该任务可以通过setInterval()来周期性地刷新SVG趋势图。
可交互操作面板
_('#waveform').onmousemove = event => {
const cursor = event.layerX ?? _('#waveform').dataset.cursor ?? 0;
_('#waveform').dataset.cursor = cursor;
const x = cursor / _('#waveform').getBoundingClientRect().width;
const data_ptr = sysinfo_data[Math.floor(x * sysinfo_data.length)];
_('#waveform_text_pointer').replaceChildren(
dom({_: 'span', textContent: 'CPU: '+data_ptr.load+'% - ('+data_ptr.load5+'%) - Process '+data_ptr.process, style: 'color: red'}),
dom({_: 'span', textContent: ' | RAM: Free '+data_ptr.ram_free/1024/1024+'MB - Buffer '+data_ptr.ram_buffer/1024/1024+'MB - Shared '+data_ptr.ram_shared/1024/1024+'MB - Used '+data_ptr.ram_used/1024/1024+'MB', style: 'color: darkgreen'}),
dom({_: 'span', textContent: ' | Swap: Free '+data_ptr.swap_free/1024/1024+'MB - Used '+data_ptr.swap_used/1024/1024+'MB', style: 'color: blue'}),
dom({_: 'span', textContent: ' | Uptime '+uptimeHuman(data_ptr.uptime)[0]+' ('+uptimeHuman(sysinfo_data[sysinfo_data.length-1].uptime-data_ptr.uptime)[0]+' ago)'}),
);
const svg_width = sysinfo_pointer_svg.viewBox.baseVal.width, svg_height = sysinfo_pointer_svg.viewBox.baseVal.height;
const poly_x = x * svg_width, poly_y = svg_height;
sysinfo_pointer_svg.replaceChildren(poly_new('polyline', poly_x+',0 '+poly_x+','+poly_y, '#FFF'));
};
当光标在SVG趋势图上移动时,根据光标位置,从缓冲sysinfo_data中读取该位置(事件)的信息。
打印该信息并绘制光标。