Overview

复习

  • sh-xv6.c: 仅依赖系统调用的 “最小” 命令行 Shell

本次课回答的问题

  • Q: 如何在系统调用之上构建程序能够普遍受惠的标准库?

本次课主要内容

  • C 标准库设计与实现
  • 基于 libc 的应用程序

熟悉又陌生的 libc

为什么需要 libc?

“裸奔” 编程:能用 (而且绝对够用),但不好用

long syscall(int num, ...) {
  va_list ap;
  va_start(ap, num);
  register long a0 asm ("rax") = num;
  register long a1 asm ("rdi") = va_arg(ap, long);
  register long a2 asm ("rsi") = va_arg(ap, long);
  register long a3 asm ("rdx") = va_arg(ap, long);
  register long a4 asm ("r10") = va_arg(ap, long);
  va_end(ap);
  asm volatile("syscall"
    : "+r"(a0) : "r"(a1), "r"(a2), "r"(a3), "r"(a4)
    : "memory", "rcx", "r8", "r9", "r11");
  return a0;
}

任何程序都用得上的定义

Freestanding 环境下也可以使用的定义

然后,系统调用也要用得方便!

系统调用是操作系统 “紧凑” 的最小接口。并不是所有系统调用都像 fork 一样可以直接使用。

低情商 API:

extern char **environ;
char *argv[] = { "echo", "hello", "world", NULL, };
if (execve(argv[0], argv, environ) < 0) {
  perror("exec");
}

高情商 API:

execlp("echo", "echo", "hello", "world", NULL);
system("echo hello world");

封装 (1): 纯粹的计算

string.h: 字符串/数组操作

简单,不简单

void *memset(void *s, int c, size_t n) {
  for (size_t i = 0; i < n; i++) {
    ((char *)s)[i] = c;
  }
  return s;
}

让我们看看 clang 把它编译成了什么……

  • 以及,线程安全性?memset-race.c
  • 标准库只对 “标准库内部数据” 的线程安全性负责
    • 例子:printf 的 buffer

排序和查找

低情商 (低配置) API

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

void *bsearch(const void *key, const void *base,
              size_t nmemb, size_t size,
              int (*compar)(const void *, const void *));

高情商 API

sort(xs.begin(), xs.end(), [] (auto& a, auto& b) {...});
xs.sort(lambda key=...)

更多的例子

RTFM!

封装 (2): 文件描述符

stdio.h: 你熟悉的味道

FILE * 背后其实是一个文件描述符

  • 我们可以用 gdb 查看具体的 FILE * (例如 stdout)
    • 可以 “窥探” 到 glibc 的一些内部实现
    • 可以加载 glibc 的 debug symbols
      • 在这门课上不推荐:你调试起来会很浪费时间
  • 封装了文件描述符上的系统调用 (fseek, fgetpos, ftell, feof, ...)

vprintf 系列

  • 使用了 stdarg.h 的参数列表
int vfprintf(FILE *stream, const char *format, va_list ap);
int vasprintf(char **ret, const char *format, va_list ap);

popen 和 pclose

我们在 dosbox-hack.c 中使用了它

  • 一个设计有缺陷的 API
    • Since a pipe is by definition unidirectional, the type argument may specify only reading or writing, not both; the resulting stream is correspondingly read-only or write-only.

高情商 API (现代编程语言)

subprocess.check_output(['cat'],
  input=b'Hello World', stderr=subprocess.STDOUT)
let dir_checksum = {
  Exec::shell("find . -type f")
    | Exec::cmd("sort") | Exec::cmd("sha1sum")
}.capture()?.stdout_str();

封装 (3): 更多的进程/操作系统功能

err, error, perror

所有 API 都可能失败

$ gcc nonexist.c
gcc: error: nonexist.c: No such file or directory

这个 “No such file or directory” 似乎见得有点多?

  • cat nonexist.c, wc nonexist.c 都是同样的 error message
  • 这不是巧合!
    • 我们也可以 “山寨” 出同样的效果
    • warn("%s", fname); (观察 strace)
      • err 可以额外退出程序
  • errno 是进程共享还是线程独享?
    • 这下知道协程的轻量了吧

environ (7)

我们也可以实现自己的 env.c

  • 问题来了:environ 是谁赋值的?
    • 这个函数又是在哪里定义的?

RTFM 后

  • 对环境变量的理解又上升了

封装 (4): 地址空间

malloc 和 free

Specification 很简单 (同 Lab1)

  • 在大区间 $[L, R)$ 中维护互不相交的区间的集合

$$ M = \big\{ [\ell_0, r_0), [\ell_1, r_1), \ldots, [\ell_n, r_n) \big\}$$

  • malloc($s$) - 返回一段大小为 $s$ 的区间
    • 必要时可以向操作系统申请额外的 $[L, R)$ (观察 strace)
    • 允许在内存不足时 “拒绝” 请求
  • free($\ell, r$) - 给定 $\ell$,删除 $[\ell, r) \in M$
    • 是否想起了《算法导论》?

多线程安全

  • Scalability 就是个很大的问题了

实现高效的 malloc/free

Premature optimization is the root of all evil.

——D. E. Knuth

重要的事情说三遍:

  • 脱离 workload 做优化就是耍流氓
  • 脱离 workload 做优化就是耍流氓
  • 脱离 workload 做优化就是耍流氓
    • 在开始考虑性能之前,理解你需要考虑什么样的性能

然后,去哪里找 workload?

Workload 分析

指导思想:$O(n)$ 大小的对象分配后至少有 $\Omega(n)$ 的读写操作,否则就是 performance bug (不应该分配那么多)。

  • 越小的对象创建/分配越频繁
    • 字符串、临时对象等 (几十到几百字节);生存周期可长可短
  • 较为频繁地分配中等大小的对象
    • 较大的数组、复杂的对象;更长的生存周期
  • 低频率的大对象
    • 巨大的容器、分配器;很长的生存周期
  • 并行、并行、再并行
    • 所有分配都会在所有处理器上发生
    • 使用链表/区间树 (first fit) 可不是个好想法

malloc, Fast and Slow

设置两套系统:

  • fast path
    • 性能极好、并行度极高、覆盖大部分情况
    • 但有小概率会失败 (fall back to slow path)
  • slow path
    • 不在乎那么快
    • 但把困难的事情做好
      • 计算机系统里有很多这样的例子 (比如 cache)

人类也是这样的系统

  • Daniel Kahneman. Thinking, Fast and Slow. Farrar, Straus and Giroux, 2011.

malloc: Fast Path 设计

使所有 CPU 都能并行地申请内存

  • 线程都事先瓜分一些 “领地” (thread-local allocation buffer)
  • 默认从自己的领地里分配
    • 除了在另一个 CPU 释放,acquire lock 几乎总是成功
  • 如果自己的领地不足,就从全局的池子里借一点

不要在乎一点小的浪费

  • 这就是为什么要对齐到 $2^k$ 字节

小内存:Segregated List

分配: Segregated List (Slab)

  • 每个 slab 里的每个对象都一样大
    • 每个线程拥有每个对象大小的 slab
    • fast path → 立即在线程本地分配完成
    • slow path → pgalloc()
  • 两种实现
    • 全局大链表 v.s. List sharding (per-page 小链表)

回收

  • 直接归还到 slab 中
    • 注意这可能是另一个线程持有的 slab,需要 per-slab 锁 (小心数据竞争)

大内存:一把大锁保平安

Buddy system (1963)

  • 如果你想分配 1, 2, 3, 4, ... $n$ 个连续的页面?
    • 例如:64 KB/页面
  • 那就 first fit 或者 best fit 吧……

你只需要一个数据结构解决问题

  • 区间树;线段树……

现实世界中的 malloc/free

以上就是所有现代 malloc/free 实现的基础

  • 当然,实际情况会复杂一些,性能也是锱铢必较
    • glibc: arena → heap → tcache (thread-local)
    • tcmalloc: thread-caching malloc, mimalloc
    • OpenJDK: ZGC: region based + tlab (thread-local)
      • managed memory 允许 object move,因此复杂得多……

无止境地封装

想知道有没有更多的功能?

RTFM: The GNU C Library, RTFSC: Newlib

  • 都不太长 (glibc 的手册和 newlib 的源码) 也好读
  • Computer System 系列课程的重要目标:摆正看手册的心态

你会发现很多宝藏

  • Globbing
  • Regex
  • Shell-style word expansion
  • ……

我们还可以……

走出 C 的领域,基于 libc 实现

  • C++ 编译器
    • 继续实现 C++ Standard Library
    • 继续实现 OpenJDK (HotSpot)
    • 继续实现 V8 (JavaScript)
  • CPython
  • Go
    • 再之后 Go 就可以自己编译自己了 (Goodbye, C!)

Eventually, (千疮百孔) 的整个计算机世界!

总结

总结

本次课回答的问题

  • Q: 如何在系统调用之上构建程序能够普遍受惠的标准库?

Take-away messages

  • libc
    • RTFM; RTFSC: 充满了宝藏
    • 性能优化中的 fast/slow path
  • 然后,你拥有了整个世界!

4 月 2 日 (周六) 线上测验

End.