Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

Intel MPI安装

设置

  • 加载 mpivars.[c]sh 脚本。
  • 创建文本文件 mpd.hosts ,其中保存有集群的节点列表,每行一个名字
    • (只针对开发者) 确保环境变量 PATH 中包含有相应的编译器,比如 icc。
    • (只针对开发者) 使用适当的编译驱动编译测试程序,比如 mpiicc。
1
$ mpiicc -o test test.c
  • 使用 mpirun 运行测试程序
    1
    $ mpirun -r ssh -f mpd.hosts -n <# of processes> ./test

编译链接

  • 保证在PATH环境变量中编译器设置正确。使用Intel编译器,确保LD_LIBRARY_PATH环境变量中含有编译库的路径。
  • 通过相应的 mpi 命令编译 MPI 程序。比如调用 mpicc 使用 GNU C 编译器:
    1
    $ mpicc <path-to-test>/test.c
    (支持的编译器都有对应的以 mpi 开头的命令,比如 Intel Fortran (ifort ) 对应的为 mpiifort).

运行MPI程序

设置 MPD 守护进程

Intel MPI 库使用 Multi-Purpose Daemon (MPD) 任务调度机制。为运行使用 mpiicc 编译的程序,首先需要设置好 MPD 守护进程。

与系统管理员为系统中所有用户启动一次 MPD 守护进程不同,用户需要启动和维护自己的一组 MPD 守护进程。这种设置增强了系统安全性,并为控制可执行程序的环境提供了更强的灵活性。

设置MPD的步骤如下:

  • 设置相应的环境变量和目录。比如,在 .zshrc 或 .bashrc 文件中:
    • 保证 PATH 变量中包含有<installdir>/bin或者Intel 64 位架构对应的<installdir>/bin64目录,其中<installdir>指的是 MPI 的安装路径。可使用 Intel MPI 库中带有的 mpivars.[c]sh 来设置此变量。
    • 确保 PATH 中包含有的 Python 至少为 2.2 或以上版本。
    • 如果使用 Intel 编译器,确保LD_LIBRARY_PATH变量包含有编译器的库目录。可使用编译器中带有的 {icc,ifort}*vars.[c]sh 脚本来设置。
    • 设置应用程序所需要的其它环境变量。
  • 创建 $HOME/.mpd.conf 文件,设置 MPD 密码,需要在文件中写入一行:

    1
    secretword=<mpd secret word>

    不要使用 Linux 登陆密码。<mpd secret word>可为任意字符串,它仅仅在不同的集群用户对 MPD 守护进程进行控制时有用。

  • 使用 chmod 设置 $HOME/.mpd.conf 文件的权限

    1
    $ chmod 600 $HOME/.mpd.conf
  • 保证你在集群的所有节点上 rsh 命令看到同样的 PATH 和 .mpd.conf 内容。 比如在集群的所有节点上执行下面的命令:

    1
    2
    $ rsh <node> env
    $ rsh <node> cat $HOME/.mpd.conf
  • 保证每个节点都能够与其它任意节点连接。可使用安装中提供的 sshconnectivity 脚本。该脚本使用提供所有节点列表的文件作为参数,每个节点一行:

    1
    $ sshconnectivity.exp machines.LINUX

集群使用的是 ssh 而不是 rsh:

- 需要确保任一节点与其它节点连 接时都不需要密码。这需要参照系统管理手册。
- 在启动 mpdboot 时需要加上调 用参数 -r ssh 或 --rsh=ssh
  • 创建文本文件 mpd.hosts , 其中列出了集群中所有的节点,每行一个主机名。比如:

    1
    2
    3
    4
    $ cat > mpd.hosts
    node1
    node2
    ...
  • 使用 mpdallexit 命令关闭上一次的 MPD 守护进程。

    1
    $ mpdallexit
  • 使用 mpdboot 命令启动 MPD 守护进程。

    1
    $ mpdboot -n <#nodes>
  • 如果文件 $PWD/mpd.hosts 存在,则会被用作默认参数。如果没有主机名文件,启用 mpdboot 只会在本地机器上运行 MPD 守护进程。

  • 使用 mpdtrace 命令检查 MPD 守护进程的状态:
    1
    $ mpdtrace

其输出结果应该为当前进行 MPD 守护进程的节点列表。该列表应该与 mpd.hosts 文件中节点列表符合。

网络结构选择

Intel MPI 库会动态选择大部分适用的网络结构以便 MPI 进程之间进行通讯。设置环境变量I_MPI_DEVICE为下表中的某个值:

I_MPI_DEVICE 值 支持的结构
sock TCP/Ethernet/sockets
shm Shared memory only (no sockets)
ssm TCP + shared memory
rdma[:] InfiniBand, Myrinet (via specified DAPL provider)
rdssm[:] TCP + shared memory + DAPL

要保证所选择的网络结构可用。比如,使用 shm 只有当所有进程可以通过共享内存进 行通讯时才行;使用 rdma 只有当所有进程可以通过单一的 DAPL 相互通讯时才行。

运行MPI程序

运行使用 Intel MPI 库连接的程序,使用 mpiexec 命令:

1
$ mpiexec -n <# of processes> ./myprog

使用 -n 参数设置进程数,这是 mpiexec 唯一需要明显指定的选项。如果使用的网络结构与默认的不同,需要使用 -genv 选项来提供一个可以赋给 I_MPI_DEVICE 变量的值。

比如使用 shm 结构来运行 MPI 程序,可执行如下命令:

1
$ mpiexec -genv I_MPI_DEVICE shm -n <# of processes> ./myprog

比如使用 rdma 结构来运行 MPI 程序,可执行如下命令:
1
$ mpiexec -genv I_MPI_DEVICE rdma -n <# of processes> ./myprog

可以通过命令选择任何支持的设备。

如果应用程序运行成功,可将其移动到使用不同结构的集群中,不需要重新链接程序。

MPI错误代码对照表

  • 00CA : no resources available  
  • 00CB : configuration error  
  • 00CD : illegal call  
  • 00CE : module not found  
  • 00CF : driver not loaded  
  • 00D0 : hardware fault  
  • 00D1 : software fault  
  • 00D2 : memory fault  
  • 00D7 : no me age  
  • 00D8 : storage fault  
  • 00DB : internal timeout  
  • 00E1 : too many cha els open  
  • 00E2 : internal fault  
  • 00E7 : hardware fault  
  • 00E9 : sin_serv.exe not started  
  • 00EA : protected  
  • 00F0 : scp db file does not exist  
  • 00F1 : no global dos storage available  
  • 00F2 : error during tra mi ion  
  • 00F2 : error during reception  
  • 00F4 : device does not exist  
  • 00F5 : incorrect sub system  
  • 00F6 : unknown code  
  • 00F7 : buffer too small  
  • 00F8 : buffer too small  
  • 00F9 : incorrect protocol  
  • 00FB : reception error  
  • 00FC : licence error  
  • 0101 : co ection not established / parameterised  
  • 010A : negative acknowledgement received / timeout error  
  • 010C : data does not exist or disabled  
  • 012A : system storage no longer available  
  • 012E : incorrect parameter  
  • 0132 : no memory in DPRAM  
  • 0201 : incorrect interface ecified  
  • 0202 : maximum amount of interfaces exceeded  
  • 0203 : PRODAVE already initialised  
  • 0204 : wrong parameter list 
  • 0205 : PRODAVE not initialised 
  • 0206 : handle ca ot be set 
  • 0207 : data segment ca ot be disabled 
  • 0300 : initialisiation error 
  • 0301 : initialisiation error 
  • 0302 : block too small, DW does not exist 
  • 0303 : block limit exceeded, correct amount
  • 0310 : no HW found 
  • 0311 : HW defective 
  • 0312 : incorrect config param 
  • 0313 : incorrect baud rate / interrupt vector 
  • 0314 : HSA parameterised incorrectly 
  • 0315 : MPI addre error 
  • 0316 : HW device already allocated 
  • 0317 : interrupt not available 
  • 0318 : interrupt occupied 
  • 0319 : sap not occupied 
  • 031A : no remote station found 
  • 031B : internal error 
  • 031C : system error 
  • 031D : error buffer size 
  • 0320 : hardware fault 
  • 0321 : DLL function error 
  • 0330 : version conflict 
  • 0331 : error com config 
  • 0332 : hardware fault 
  • 0333 : com not configured 
  • 0334 : com not available 
  • 0335 : serial drv in use 
  • 0336 : no co ection 
  • 0337 : job rejected 
  • 0380 : internal error 
  • 0381 : hardware fault 
  • 0382 : no driver or device found 
  • 0384 : no driver or device found 
  • 03FF : system fault 
  • 0800 : toolbox occupied 
  • 4001 : co ection not known 
  • 4002 : co ection not established 
  • 4003 : co ection is being established 
  • 4004 : co ection broken down 
  • 8000 : function already actively occupied 
  • 8001 : not allowed in this operating status 
  • 8101 : hardware fault 
  • 8103 : object acce not allowed 
  • 8104 : context is not su orted 
  • 8105 : invalid addre  
  • 8106 : type (data type) not su orted 
  • 8107 : type (data type) not co istent 
  • 810A : object does not exist 
  • 8301 : memory slot on CPU not sufficient 
  • 8404 : grave error  8500 : incorrect PDU size 
  • 8702 : addre invalid 
  • D201 : syntax error block name 
  • D202 : syntax error function parameter 
  • D203 : syntax error block type 
  • D204 : no linked block in storage medium 
  • D205 : object already exists 
  • D206 : object already exists 
  • D207 : block exists in EPROM 
  • D209 : block does not exist 
  • D20E : no block available 
  • D210 : block number too big 
  • D241 : protection level of function not sufficient 
  • D406 : information not available 
  • EF01 : incorrect ID2 
  • FFFB : TeleService Library not found 
  • FFFE : unknown error FFFE hex 
  • FFFF : timeout error. Check interfac

MPI的命令

编译命令列表

本节提供有关不同命令类型以及如何使用这些命令的信息:

  • 编译命令列出了可用的英特尔® MPI 库编译器命令、相关选项和环境变量。
  • mpirun 提供了mpirun 命令的描述和示例。
  • mpiexec.hydra 提供有关 mpiexec.hydra 命令、其选项、环境变量,以及相关的功能和实用程序。
  • cpuinfo 提供了 cpuinfo 实用程序的语法、参数、描述和输出示例。
  • impi_info 提供有关可用环境变量的信息。
  • mpitune 提供有关 mpitune 实用程序的配置选项的信息。

编译器命令

下表列出了可用的英特尔® MPI 库编译器命令及其底层编译器和编程语言。

Compiler Command Default Compiler Supported Languages
Generic Compilers
mpicc gcc, cc C
mpicxx g++ C/C++
mpifc gfortran Fortran77/Fortran 95
GNU* Compilers
mpigcc gcc C
mpigxx g++ C/C++
mpif77 gfortran Fortran 77
mpif90 gfortran Fortran 95
Intel® Fortran, C++ Compilers
mpiicc icc C
mpiicpc icpc C++
mpiifort ifort Fortran77/Fortran 95

编译器命令注意事项

  • 编译器命令仅在英特尔 MPI 库软件开发套件 (SDK) 中可用。
  • 有关所列编译器的支持版本,请参阅发行说明。
  • 要显示编译器命令的小帮助,请在不带任何参数的情况下执行它。
  • 编译器包装脚本位于<install-dir>/bin目录中,其中<install-dir>是英特尔 MPI 库安装目录。
  • 可以通过获取<install-dir>/env/vars.[c]sh脚本来建立环境设置。 要使用特定的库配置,请将以下参数之一传递给脚本以切换到相应的配置:release、debug、release_mt 或 debug_mt。
  • 确保相应的底层编译器已在您的 PATH 中。 如果您使用英特尔® 编译器,请从安装目录获取vars.sh脚本以设置编译器环境。

编译选项

-nostrip:使用此选项可在静态链接英特尔® MPI 库时关闭调试信息。

-config=<名称>:使用此选项来获取编译器配置文件。该文件应包含要设置与指定的编译器一起使用的环境设置。对配置文件使用以下命名约定:

1
<安装目录>/etc/mpi<编译器>-<名称>.conf

  • <compiler>={cc,cxx,f77,f90},取决于编译的语言。
  • <name>是底层编译器的名称,其中空格被连字符替换;例如,cc -64<name>值为cc--64

-profile=<profile_name>:使用此选项指定 MPI 分析库。<profile_name>是配置文件的名称,加载相应的分析库。配置文件取自<install-dir>/etc。英特尔 MPI 库为英特尔® 跟踪收集器提供了几个预定义的配置文件:

  • <install-dir>/etc/vt.conf — 常规跟踪库
  • <install-dir>/etc/vtfs.conf — 故障安全跟踪库
  • <install-dir>/etc/vtmc.conf — 正确性检查跟踪库
  • <install-dir>/etc/vtim.conf — 负载不平衡跟踪库

您还可以将自己的配置文件创建为<profile-name>.conf。您可以定义以下环境配置文件中的变量:

  • PROFILE_PRELIB - 在英特尔 MPI 库之前加载的库(和路径)
  • PROFILE_POSTLIB - 在英特尔 MPI 库之后加载的库
  • PROFILE_INCPATHS - 任何包含文件的 C 预处理器参数

例如,使用以下几行创建文件myprof.conf

1
2
PROFILE_PRELIB="-L<path_to_myprof>/lib -lmyprof"
PROFILE_INCPATHS="-I<paths_to_myprof>/include"

使用相关编译器包装器的-profile=myprof选项来选择此新配置文件。

-t-trace:使用-t-trace选项将生成的可执行文件链接到英特尔® 跟踪收集器库。使用此选项与-profile=vt选项具有相同的效果。您还可以使用I_MPI_TRACE_PROFILE环境变量到<profile_name>来指定另一个分析库。例如,将I_MPI_TRACE_PROFILE设置为vtfs以链接到故障安全版本的英特尔跟踪收集器。

-trace-imbalance:使用-trace-imbalance选项将生成的可执行文件链接到负载不平衡跟踪库。使用此选项与-profile=vtim选项具有相同的效果。要使用此选项,请在VT_ROOT环境中包含 Intel Trace Collector 的安装路径

-check_mpi:使用此选项将生成的可执行文件链接到英特尔® 跟踪收集器正确性检查库。默认值为libVTmc.so。使用此选项与-profile=vtmc具有相同的效果

您还可以使用I_MPI_CHECK_PROFILE环境变量给<profile_name>指定另一个库。

要使用此选项,请在VT_ROOT环境中包含 Intel Trace Collector 的安装路径变量。

-ilp64:使用此选项可启用部分 ILP64 支持。英特尔 MPI 库的所有整数参数都被视为 64 位值。

-no_ilp64:使用此选项可明确禁用 ILP64 支持。此选项必须与 -i8 结合使用

如果您为使用英特尔 Fortran 编译器的单独编译指定 -i8 选项,您仍然必须使用用于链接的 i8 orilp64 选项。

-dynamic_log:将此选项与 -t 选项结合使用可动态链接英特尔跟踪收集器库。

要运行生成的程序,请将$VT_ROOT/slib包含在LD_LIBRARY_PATH环境变量中。

-G:使用此选项在调试模式下编译程序并将生成的可执行文件链接到英特尔 MPI 库的调试版本。有关如何使用附加的信息,请参阅I_MPI_DEBUG。默认情况下,优化的库与 -g 选项链接。

在运行时使用vars.{sh|csh} [debug|debug_mt]加载特定的 libmpi.so 配置。

-link_mpi=<参数>:使用此选项始终链接指定版本的英特尔 MPI 库。查看用于详细参数描述的I_MPI_LINK环境变量。此选项会覆盖所有其他选择特定选项的选项。

在运行时使用vars.{sh|csh}[debug|debug_mt]加载特定的 libmpi.so 配置。

-O:使用此选项可启用编译器优化。

-fast:使用此选项可最大限度地提高整个程序的速度。此选项强制英特尔 MPI 库的静态链接方法。此选项仅受 mpiicc、mpiicpc 和 mpiifort 英特尔® 编译器包装器支持。

-echo:使用此选项可显示命令脚本执行的所有操作。

-show:使用此选项可了解如何调用底层编译器,而无需实际运行它。使用以下命令查看所需的编译器标志和选项:

1
$ mpiicc -show -c test.c

使用以下命令查看所需的链接标志、选项和库:

1
$ mpiicc -show -o a.out test.o

此选项对于确定直接使用底层编译器的复杂构建过程的命令行特别有用。

-show_env:使用此选项可查看调用底层编译器时生效的环境设置。

-{cc,cxx,fc}=<编译器>:使用此选项可选择底层编译器。下表列出了可用的 LLVM 和 IL0 编译器选项以及用于调用它们的命令。

-nofortbind, -nofortran:使用此选项可禁用 mpiicc 与 Fortran 绑定的链接。这与I_MPI_FORT_BIND变量具有相同的效果。

-v:使用此选项打印编译器包装器脚本版本及其底层编译器版本。

-norpath:使用此选项可为英特尔® MPI 库的编译器包装器禁用 rpath。

mpirun:启动 MPI 作业并提供与作业调度程序的集成。

1
mpirun <选项>

参数

  • <options>mpiexec.hydra选项,如mpiexec.hydra部分所述。这是默认操作模式。

使用此命令启动 MPI 作业。 mpirun 命令使用 Hydra 作为底层进程管理器。mpirun命令检测 MPI 作业是否是从使用Torque*PBS Pro*LSF*Parallelnavi* NQS*Slurm*Univa* Grid Engine*LoadLeveler*等作业调度程序分配的会话内提交的。mpirun命令从各自的环境中提取主机列表,并根据上述方案自动使用这些节点。在这种情况下,您不需要创建主机文件。使用系统上安装的作业调度程序分配会话,并在此会话中使用 mpirun 命令运行 MPI 作业。

例子

1
$ mpirun -n <# of processes> ./myprog

此命令调用 mpiexec.hydra 命令(Hydra 进程管理器),该命令启动 myprog。

mpiexec.hydra:使用 Hydra 进程管理器启动 MPI 作业。

句法

1
mpiexec.hydra<g-options> <l-options> <executable>

或者

1
mpiexec.hydra<g-options> <l-options> <executable1> : <l-options> <executable2>

参数

  • <g-options>:适用于所有 MPI 进程的全局选项
  • <l-options>:适用于单个参数集的本地选项
  • <executable>./a.out或可执行文件的路径/名称

描述

  • 使用 mpiexec.hydra 实用程序通过 Hydra 进程管理器运行 MPI 应用程序。

使用第一个简短的命令行语法以单个参数集启动<executable>的所有 MPI 进程。例如,以下命令对指定的进程和主机执行a.out

1
$ mpiexec.hydra -f <hostfile> -n <# of processes> ./a.out

  • <# of processes>指定运行 a.out 可执行文件的进程数
  • <hostfile>指定运行 a.out 可执行文件的主机列表

使用第二个长命令行语法为不同的 MPI 程序运行设置不同的参数集。例如,以下命令使用不同的参数集执行两个不同的二进制文件:

1
$ mpiexec.hydra -f <hostfile> -env <VAR1> <VAL1> -n 2 ./a.out : -env <VAR2> <VAL2> -n 2 ./b.out

注意您需要区分全局选项和本地选项。在命令行语法中,将本地选项放在全局选项之后。

Global Hydra Options

英特尔® MPI 库的 Hydra 进程管理器的全局选项。全局选项应用于启动命令中的所有参数集。参数集由冒号“:”分隔。

-tune <文件名>:使用此选项指定包含二进制格式的调优数据的文件名。

-usize <usize>:使用此选项设置MPI_UNIVERSE_SIZE,它可用作MPI_COMM_WORLD的属性。

  • <size>定义 Universe 大小
  • SYSTEM设置大小等于通过主机文件或资源管理器传递给 mpiexec 的内核数。
  • INFINITE不限制大小。这是默认值。
  • <value>将大小设置为 ≥ 0 的数值。

-hostfile <hostfile>-f <hostfile>:使用此选项指定运行应用程序的主机名。如果主机名重复,则此名称仅使用一次。有关更多详细信息,另请参阅I_MPI_HYDRA_HOST_FILE环境变量。

注意使用以下选项更改集群节点上的进程排布:

  • 使用-perhost-ppn-grr选项通过循环调度将连续的MPI 进程放置在每个主机上。
  • 使用-rr选项使用循环调度将连续的MPI 进程放置在不同的主机上。

-machinefile <机器文件>-machine <机器文件>:使用此选项可通过机器文件控制进程放置。要定义要启动的进程总数,请使用-n选项。例如:

1
2
3
4
$ cat ./machinefile
node0:2
node1:2
node0:1

-hosts-group:使用此选项可使用方括号、逗号和破折号设置节点范围(如在Slurm*工作负载管理器中)。有关更多详细信息,请参阅 Hydra 环境变量中的I_MPI_HYDRA_HOST_FILE环境变量。

-silent-abort:使用此选项可禁用中止警告消息。有关更多详细信息,请参阅 Hydra 环境变量中的I_MPI_SILENT_ABORT环境变量。

-nameserver:使用此选项以主机名:端口格式指定名称服务器。有关更多详细信息,请参阅 Hydra 环境变量中的I_MPI_HYDRA_NAMESERVER环境变量。

-genv <ENVVAR> <value>:使用此选项可为所有 MPI 进程将<ENVVAR>环境变量设置为指定的<value>

-genvall:使用此选项可以将所有环境变量传播到所有 MPI 进程。

-genvnone:使用此选项可禁止将任何环境变量传播到任何 MPI 进程。

-genvexcl <环境变量名称列表>:使用此选项可禁止将列出的环境变量传播到任何 MPI 进程。

-genvlist <列表>:使用此选项可传递带有当前值的环境变量列表。<list>是要发送到所有 MPI 进程的环境变量的逗号分隔列表。

-pmi-connect <mode>:使用此选项可选择进程管理接口 (PMI) 消息的缓存模式。<mode>的可能值为:

  • <mode>:要使用的缓存模式
  • nocache:不缓存 PMI 消息。
  • cache:在本地 pmi_proxy 管理进程上缓存 PMI 消息,以最大限度地减少 PMI 请求的数量。缓存的信息会自动传播到子管理进程。
  • lazy-cache:带有 PMI 信息的请求传播。
  • alltoall:信息在所有 pmi_proxy 之间自动交换,然后才能完成任何获取请求。这是默认模式。

有关更多详细信息,请参阅I_MPI_HYDRA_PMI_CONNECT环境变量。

-perhost <# of processes >-ppn <# of processes >-grr <# of processes >:使用此选项可以使用循环调度在组中的每个主机上放置指定数量的连续 MPI 进程。有关更多详细信息,请参阅I_MPI_PERHOST环境变量。注意在作业调度程序下运行时,默认情况下会忽略这些选项。为了能够使用这些选项控制进程放置,请禁用I_MPI_JOB_RESPECT_PROCESS_PLACEMENT

-rr:使用此选项可以使用循环调度将连续的 MPI 进程放置在不同的主机上。此选项等效于-perhost 1。有关更多详细信息,请参阅I_MPI_PERHOST环境变量。

-trace [<profiling_library>]-t [<profiling_library>]:使用此选项,使用指定的英特尔® 跟踪收集器<profiling_library>来分析您的 MPI 应用程序。如果未指定<profiling_library>,则使用默认分析库libVT.so。设置I_MPI_JOB_TRACE_LIBS环境变量以覆盖默认分析库。

-trace-imbalance:使用此选项通过使用libVTim.so库的英特尔® 跟踪收集器来分析您的 MPI 应用程序。

-aps:使用此选项可使用应用程序性能快照从 MPI 应用程序收集统计信息。收集的数据包括硬件性能指标、内存消耗数据、内部 MPI 不平衡和 OpenMP* 不平衡统计。使用此选项时,会生成一个包含统计数据的新文件夹aps_result_<date>-<time>。您可以使用 aps 实用程序分析收集的数据,例如:

1
2
$ mpirun -aps -n 2 ./myApp
$ aps aps_result_20171231_235959

-mps:使用此选项仅从使用应用程序性能快照的 MPI 应用程序收集 MPI 和 OpenMP* 统计信息。与-aps选项不同,-mps不收集硬件指标。该选项等效于:

1
$ mpirun -n 2 aps -c mpi,omp ./myapp

-trace-pt2pt:使用此选项来收集有关使用英特尔® 跟踪分析器和收集器的点对点操作的信息。该选项要求您还使用-trace选项。

-trace-collectives:使用此选项来收集有关使用英特尔® 跟踪分析器和收集器的集合操作的信息。该选项要求您还使用-trace选项。

使用-trace-pt2pt-trace-collectives减少生成的跟踪文件的大小或消息检查器报告的数量。这些选项适用于静态和动态链接的应用程序。

-configfile <文件名>:使用此选项指定包含命令行选项的文件<filename>,每行一个可执行文件。空行和以“#”开头的行将被忽略。命令行中指定的其他选项被视为全局选项。您可以在默认加载的配置文件中指定全局选项(<installdir>/etc中的mpiexec.conf~/.mpiexec.conf和工作目录中的mpiexec.conf)。其余选项可以在命令行指定。

-branch-count <数量>:使用此选项来限制 Hydra 管理进程启动的子管理进程的数量。有关更多详细信息,请参阅I_MPI_HYDRA_BRANCH_COUNT环境变量。

-pmi-aggregate-pmi-noaggregate:使用此选项分别打开或关闭 PMI 请求的聚合。默认值为-pmi-aggregate,表示默认启用聚合。有关更多详细信息,请参阅I_MPI_HYDRA_PMI_AGGREGATE环境变量。

-gdb:使用此选项可在 GDB*(GNU 调试器)下运行可执行文件。您可以使用以下命令:

1
$ mpiexeс.hydra -gdb -n <进程数><可执行文件>

-gdba <pid>:使用此选项将 GNU* 调试器附加到现有的 MPI 作业。您可以使用以下命令:

1
$ mpiexec.hydra -gdba <pid>

-nolocal:使用此选项可避免在启动mpiexec.hydra的主机上运行<executable>。您可以在部署专用主节点以启动 MPI 作业和一组专用计算节点以运行实际 MPI 进程的集群上使用此选项。

-hosts <节点列表>:使用此选项指定应在其上运行 MPI 进程的特定<nodelist>。例如,以下命令在主机 host1 和 host2 上运行可执行文件 a.out:

1
$ mpiexec.hydra -n 2 -ppn 1 -hosts host1,host2 ./a.out

注意如果<nodelist>仅包含一个节点,则此选项被解释为本地选项。有关详细信息,请参阅本地选项。

-iface <interface>:使用此选项可选择适当的网络接口。例如,如果您的 InfiniBand* 网络的 IP 仿真配置为 ib0,您可以使用以下命令。

1
$ mpiexec.hydra -n 2 -iface ib0 ./a.out

有关更多详细信息,请参阅I_MPI_HYDRA_IFACE环境变量。

-demux <mode>
使用此选项可为多个 I/O 设置轮询模式。默认值为轮询。

  • <spec>定义多个 I/O 的轮询模式
  • poll设置 poll 为轮询方式。这是默认值。
  • select:select 作为轮询模式。

有关更多详细信息,请参阅I_MPI_HYDRA_DEMUX环境变量。

-enable-x-disable-x:使用此选项来控制 Xlib* 流量转发。默认值为-disable-x,表示不转发Xlib 流量。

-l,-prepend-rank:使用此选项可在写入标准输出的所有行的开头插入 MPI 进程编号。

-ilp64:使用此选项预加载 ILP64 接口。

-s <规格>:使用此选项将标准输入定向到指定的 MPI 进程。

  • <spec>定义 MPI 进程等级
  • all使用所有进程。
  • none不要将标准输出定向到任何进程。
  • <l>,<m>,<n>指定一个确切的列表并仅使用进程<l><m><n>。默认的值为零。
  • <k>,<l>-<m>,<n>指定范围并使用进程<k><l><m><n>

-noconf:使用此选项可禁用对 mpiexec.hydra 配置文件的处理。

-ordered-output:使用此选项可避免混合来自 MPI 进程的数据输出。此选项影响标准输出和标准错误流。注意 使用此选项时,每个进程的最后输出行以行尾 ‘\n’ 字符结束。否则应用程序可能会停止响应。

-path <目录>:使用此选项指定可执行文件的路径。

-tmpdir <目录>:使用此选项为临时文件设置目录。有关更多详细信息,请参阅I_MPI_TMPDIR环境变量。

-version-V:使用此选项显示英特尔® MPI 库的版本。

-info:使用此选项可显示英特尔® MPI 库的构建信息。使用此选项时,将忽略其他命令行参数。

-localhost:使用此选项可显式指定启动节点的本地主机名。

-rmk <RMK>:使用此选项可选择要使用的资源管理内核。英特尔® MPI 库仅支持 pbs。有关更多详细信息,请参阅I_MPI_HYDRA_RMK环境变量。

-outfile-pattern <文件>:使用此选项将标准输出重定向到指定的文件。

-errfile-pattern <文件>:使用此选项将 stderr 重定向到指定的文件。

-gpath <路径>:使用此选项指定可执行文件的路径。

-gwdir <目录>:使用此选项指定可执行文件运行的工作目录。

-gumask <umask>:使用此选项为远程可执行文件执行umask <umask>命令。

-gdb-ia:使用此选项可在特定于英特尔® 架构的 GNU* 调试器下运行进程。

-prepend-pattern:使用此选项可指定前置到进程输出的模式。

-verbose-v:使用此选项从 mpiexec.hydra 打印调试信息,例如:

  • 服务进程参数
  • 为启动应用程序传递的环境变量和参数
  • 作业生命周期中的 PMI 请求/响应

有关更多详细信息,请参阅I_MPI_HYDRA_DEBUG环境变量。

-print-rank-map:使用此选项打印出 MPI 编号映射。

-print-all-exitcodes:使用此选项打印所有进程的退出代码。

-bootstrap <引导服务器>:使用此选项可选择要使用的内置引导服务器。引导服务器是系统提供的基本远程节点访问机制。 Hydra 支持多个运行时引导服务器,例如 ssh、rsh、pdsh、fork、persist、slurm、ll、lsf 或 sge,以启动 MPI 进程。默认引导服务器是 ssh。通过选择 slurm、ll、lsf 或 sge,您可以使用相应的 srun、llspawn.stdio、blaunch 或 qrsh 内部作业调度程序实用程序在各自选定的作业调度程序(Slurm、LoadLeveler、LSF 和SGE)。

  • <arg>字符串参数
  • ssh使用安全shell。这是默认值。
  • rsh使用远程shell。
  • pdsh 使用并行分布式shell。
  • pbs 使用 Torque* pbsdsh。
  • pbsdsh pbs bootstrap的别名。
  • fork 使用fork调用。
  • persist 使用Hydra服务
  • slurm 使用Slurm* srun。
  • ll 使用LoadLeveler* llspawn.stdio。
  • lsf 使用LSF blaunch。
  • sge 使用Univa Grid Engine qrsh。

-bootstrap-exec <引导服务器>:使用此选项可将可执行文件设置为用作引导服务器。默认引导服务器是 ssh。例如:

1
$ mpiexec.hydra -bootstrap-exec <bootstrap_server_executable> -f hosts -env <VAR1> <VAL1> -n 2 ./a.out

有关更多详细信息,请参阅I_MPI_HYDRA_BOOTSTRAP

-bootstrap-exec-args <args>:使用此选项为引导服务器可执行文件提供附加参数。

1
$ mpiexec.hydra -bootstrap-exec-args <参数> -n 2 ./a.out

要与 Slurm* 调度程序紧密集成(包括对挂起/恢复的支持),请使用 Slurm 页面上概述的方法:http://www.schedmd.com/slurmdocs/mpi_guide.html#intel_mpi。有关更多详细信息,请参阅`I_MPI_HYDRA_BOOTSTRAP_EXEC_EXTRA_ARGS`。

-v6:使用此选项可强制使用 IPv6 协议。

Local Hydra Options

本节介绍英特尔® MPI 库的 Hydra 进程管理器的本地选项。局部选项仅应用于指定它们的参数集。参数集由冒号“:”分隔。

-n <进程数>-np <进程数>:使用此选项可设置使用当前参数集运行的 MPI 进程数。

-env <envar> <value>:使用此选项将<envar>环境变量设置为当前参数集中所有 MPI 进程的指定<value>

-envall:使用此选项可传播当前参数集中的所有环境变量。有关更多详细信息,请参阅I_MPI_HYDRA_ENV环境变量。

-envnone:使用此选项可禁止将任何环境变量传播到当前参数集中的 MPI 进程。注意 该选项不适用于本地主机。

-envexcl <list-of-envvar-names>:使用此选项可禁止将列出的环境变量传播到当前参数集中的 MPI 进程。

-envlist <list>:使用此选项可传递带有当前值的环境变量列表。<list>是要发送到 MPI 进程的环境变量的逗号分隔列表。

-host <nodename>:使用此选项指定要在其上运行 MPI 进程的特定<nodename>。 例如,以下命令在主机 host1 和 host2 上执行 a.out:

1
$ mpiexec.hydra -n 2 -host host1 ./a.out : -n 2 -host host2 ./a.out

-path <dir>:使用此选项指定要在当前参数集中运行的<executable>文件的路径。

-wdir <dir>:使用此选项指定<executable>文件在当前参数中运行的工作目录

gtool options

-gtool:使用此选项可通过mpiexec.hydrampirun命令为指定进程启动英特尔® VTune™ Amplifier XE、英特尔® Advisor、Valgrind 和 GDB(GNU 调试器)等工具。 此选项的替代方法是I_MPI_GTOOL环境变量。

1
2
3
4
5
-gtool "<command line for tool 1>:<ranks set 1>[=launch mode 1][@arch 1]; 
<command line for tool 2>:<ranks set 2>[=exclusive][@arch 2];
… ;
<command line for tool n>:<ranks set n>[=exclusive][@arch n]"
<executable>

or:

1
2
3
4
5
6
$ mpirun -n <# of processes> 
-gtool "<command line for tool 1>:<ranks set 1>[=launch mode 1][@arch 1]"
-gtool "<command line for tool 2>:<ranks set 2>[=launch mode 2][@arch 2]"

-gtool "<command line for a tool n>:<ranks set n>[=launch mode 3][@arch n]"
<executable>

在语法中,分隔符;-gtool选项可以互换。

参数

  • <rank set>:指定工具执行中涉及的进程范围。用逗号分隔等级或使用“-”符号表示一组连续的进程。 要为所有进程运行该工具,请使用 all 参数。注意如果您指定了不正确的排名索引,则会打印相应的警告,并且该工具会继续为有效的排名工作。
  • [=launch mode] 指定启动模式(可选)。 有关可用值,请参见下文。
  • [@arch]指定工具运行的架构(可选)。对于给定的<rank set>,如果指定此参数,则仅为驻留在具有指定架构的主机上的进程启动该工具。该参数是可选的。注意相同@arch参数的进程集不能重叠。缺少@arch参数也被认为是不同的架构。因此,以下语法被认为是有效的:-gtool "gdb: 0-3=attach;gdb:0-3=attach@hsw;/usr/bin/gdb:0-3=attach@knl。另外,请注意一些工具不能一起工作,或者同时使用它们可能会导致不正确的结果。

下表列出了[=launch mode]的参数值。您可以为每个工具指定多个值,这些值用逗号“,”分隔。

  • exclusive 指定此值可防止工具在每个主机上启动超过一个进程。
  • attach 指定此值以将工具从-gtool附加到可执行文件。如果您使用调试器或其他可以以调试器方式附加到进程的工具,则需要指定此值。此模式仅通过调试器进行了测试。
  • node-wide 指定此值以将-gtool中的工具应用于<rank set>所在的所有进程或在所有进程的情况下应用于所有节点。也就是说,该工具应用于比可执行文件更高的级别(应用于 pmi_proxy 守护程序)。将-remote参数用于rank以仅在远程节点上使用该工具。

以下示例演示了使用-gtool选项的不同场景。通过 mpirun 命令启动英特尔® VTune™ Amplifier XE 和 Valgrind*:

1
2
$ mpirun -n 16 -gtool "vtune -collect hotspots -analyze-system \
-r result1:5,3,7-9=exclusive@bdw;valgrind -log-file=log_%p:0,1,10-12@hsw" a.out

此命令为在代号为 Broadwell 的英特尔® 微架构上运行的进程启动 vtune。每个主机只启动一个 vtune 副本,索引最小的进程会受到影响。同时,为在代号为 Haswell 的英特尔® 微架构上运行的所有指定进程启动 Valgrind*。 Valgrind 的结果保存到文件log_<process ID>

为不同的rank set设置不同的环境变量:

1
$ mpirun -n 16 -gtool "env VARIABLE1=value1 VARIABLE2=value2:3,5,7-9; env VARIABLE3=value3:0,11" a.out

示例 3通过-machinefile选项为特定进程应用工具。m_file中有如下信息

1
2
3
4
$ cat ./m_file
hostname_1:2
hostname_2:3
hostname_3:1

以下命令行演示了如何使用-machinefile选项来应用工具

1
2
$ mpirun -n 6 -machinefile m_file -gtool "vtune -collect hotspots -analyze-system \
-r result1:5,3=exclusive@hsw;valgrind:0,1@bdw" a.out

在此示例中,使用-machinefie选项意味着索引为 0 和 1 的进程位于hostname_1机器上,进程 3 位于hostname_2机器上,而进程 5 位于hostname_3机器上。之后,如果hostname_2hostname_3机器具有代号为Haswell 的英特尔® 微架构,则vtune 仅应用于rank 3 和5(因为这些rank属于不同的机器,所以exclusive 选项匹配它们)。同时,Valgrind* 工具适用于在hostname_1机器上分配的两个进程,以防它具有代号为 Broadwell 的英特尔® 微架构。

-gtoolfile <gtool_config_file>:使用此选项在配置文件中指定-gtool参数。所有相同的规则都适用。此外,您可以使用分节符分隔不同的命令行。例如,如果gtool_config_file包含以下设置:

1
2
env VARIABLE1=value1 VARIABLE2=value2:3,5,7-9; env VARIABLE3=value3:0,11
env VARIABLE4=value4:1,12

以下命令为进程 3、5、7、8 和 9 设置VARIABLE1VARIABLE2,并为进程 0 和 11 设置VARIABLE3,而为进程 1 和 12 设置VARIABLE4

1
$ mpirun -n 16 -gtoolfile gtool_config_file a.out

注意选项和环境变量-gtool-gtoolfileI_MPI_GTOOL是互斥的。选项-gtool-gtoolfile具有相同的优先级并且比I_MPI_GTOOL具有更高的优先级。命令行中的第一个指定选项有效,第二个被忽略。因此,如果您未指定-gtool-gtoolfile,请使用I_MPI_GTOOL

cpuinfo

提供有关系统中使用的处理器的信息。

1
cpuinfo [[-]<选项>]

g:关于单个集群节点的一般信息显示:

  • 处理器产品名称
  • 节点上的包/套接字数
  • 节点上和每个包内的核心和线程数
  • SMT 模式启用

i:逻辑处理器标识表,相应地标识每个逻辑处理器的线程、内核和包。

  • ThreadId - 内核中的唯一处理器标识符。
  • CoreId - 包内的唯一核心标识符。
  • PackageId - 节点内的唯一包标识符。

d:节点分解表显示节点内容。每个条目都包含有关包、内核和逻辑处理器的信息。

  • 包裹ID - 物理包裹标识符。
  • 内核 ID - 属于此包的内核标识符列表。
  • 处理器ID - 属于该包的处理器列表。这个列表顺序直接对应于核心列表。括号中的一组处理器属于一个内核。

c:逻辑处理器的缓存共享显示共享特定缓存级别的大小和处理器组的信息。

  • 大小——以字节为单位的缓存大小。
  • 处理器- 括在括号中的处理器组列表,它们共享此缓存或不共享其他缓存。

s:微处理器签名十六进制字段(英特尔平台符号)显示签名值:

  • extended family
  • extended model
  • family
  • model
  • type
  • stepping

f:微处理器功能标志指示微处理器支持哪些功能。使用 Intel 平台符号。

n:表显示了有关 NUMA 节点的以下信息:

  • NUMA Id - NUMA 节点标识符。
  • 处理器 - 此节点中的处理器列表。
  • 如果节点没有处理器,则不会显示该节点。

cpuinfo 实用程序打印出可用于定义合适的进程固定设置的处理器体系结构信息。输出由多个表组成。每个表对应于参数表中列出的单个选项之一。

cpuinfo 实用程序可用于 Intel 微处理器和非 Intel 微处理器,但它可能仅提供有关非 Intel 微处理器的部分信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$ cpuinfo -gdcs
===== Processor composition =====
Processor name : Intel(R) Xeon(R) X5570
Packages(sockets) : 2
Cores : 8
Processors(CPUs) : 8
Cores per package : 4
Threads per core : 1
===== Processor identification =====
Processor Thread Id. Core Id. Package Id.
0 0 0 0
1 0 0 1
2 0 1 0
3 0 1 1
4 0 2 0
5 0 2 1
6 0 3 0
7 0 3 1
===== Placement on packages =====
Package Id. Core Id. Processors
0 0,1,2,3 0,2,4,6
1 0,1,2,3 1,3,5,7
===== Cache sharing =====
Cache Size Processors
L1 32 KB no sharing
L2 256 KB no sharing
L3 8 MB (0,2,4,6)(1,3,5,7)
===== Processor Signature =====
_________ ________ ______ ________ _______ __________
| xFamily | xModel | Type | Family | Model | Stepping |
|_________|________|______|________|_______|__________|
| 00 | 1 | 0 | 6 | a | 5 |
|_________|________|______|________|_______|__________|

impi_info

提供有关可用英特尔® MPI 库环境变量的信息。

1
impi_info <选项>

参数

  • -a | -all:显示所有 IMPI 变量。
  • -h | -help:显示帮助信息。
  • -v | -variable:显示所有可用变量或指定变量的描述。
  • -c | -category:显示指定类别的所有可用类别或变量。
  • -e | -expert:显示所有专家变量。

impi_info 实用程序提供有关英特尔 MPI 库中可用环境变量的信息。 对于每个变量,它打印出名称、默认值和值数据类型。 默认情况下,会显示一个简化的变量列表。 使用 -all 选项显示所有可用变量及其说明。 impi_info 输出示例:

1
2
3
4
5
6
7
$ ./impi_info
| NAME | DEFAULT VALUE | DATA TYPE |
====================================================
| I_MPI_THREAD_SPLIT | 0 | MPI_INT |
| I_MPI_THREAD_RUNTIME | none | MPI_CHAR |
| I_MPI_THREAD_MAX | -1 | MPI_INT |
| I_MPI_THREAD_ID_KEY | thread_id | MPI_CHAR |

mpitune

为给定的 MPI 应用程序调整英特尔® MPI 库参数。

1
mpitune <options>

  • -c | --config-file <file>指定一个配置文件来运行一个调整会话。
  • -d | --dump-file <file>指定存储收集结果的文件。 该选项用于分析模式。
  • -m | --mode {collect | analyze} 指定 mpitune 模式。 支持的模式是收集和分析:
    • 收集模式运行调整过程并将结果保存在临时文件中;
    • 分析模式将临时文件转换为英特尔 MPI 库使用的 JSON 树,并生成一个表,以人类可读的格式表示算法值。
  • -h | --help显示帮助信息。
  • -v | --version显示产品版本。

mpitune 实用程序允许您根据集群配置或应用程序自动调整英特尔 MPI 库参数,例如集合操作算法。

tuner迭代地启动具有不同配置的基准测试应用程序,以测量性能并存储每次启动的结果。基于这些结果,tuner为被调优的参数生成最佳值。

注意从英特尔 MPI 库 2019 更新 4 版本开始,您必须指定两个 mpitune 配置文件,它们的模式和转储文件字段不同。一种更简单的替代方法是使用英特尔 MPI 库附带的单个配置文件模板之一。在这种情况下,您必须使用命令行来定义模式和转储文件字段。

  • -mode选项定义两种可能的 MPI 调优模式之一:收集或分析。
  • -dump-file选项定义处于分析模式时临时文件的路径。该路径在第一次迭代后由 mpitune 返回。

配置文件应指定所有tuner参数,这些参数通过--config-file选项传递给调优器。典型的配置文件由主要部分、指定通用选项和特定库参数的搜索空间部分(例如,用于特定集合操作)组成。要注释一行,请使用#。<installdir>/etc/tune_cfg中提供了所有配置文件示例。请注意,英特尔® MPI 基准测试的配置文件已经创建。调优过程包括两个步骤:数据收集(收集模式)和数据分析(分析模式):

  • $ mpitune -m collect -c <path-to-config-file2>
  • $ mpitune -m analyze -c <path-to-config-file1>

另一个变体是:

  • $ mpitune -m analyze -c <path-to-config-file1>

其中第一步中接收到的转储文件的路径在包含模板的配置文件中使用。调整结果以 JSON 树的形式呈现,可以使用I_MPI_TUNING环境变量添加到库中。

以下选项也可以用:

  • -f <filename>指定包含主机名的文件。
  • -hosts <hostlist>指定以逗号分隔的主机列表。
  • -np <value>指定进程数。
1
2
$ mpitune -np 2 -ppn 1 -hosts HOST1,HOST2 -m collect -c <path-to-config-file2>
$ mpitune -np 2 -ppn 1 -hosts HOST1,HOST2 -m analyze -c <path-to-config-file1>

mpitune Configuration Options

Application Options

-app:为要启动的命令行设置模板以收集调整结果。命令行可以包含声明为@<var_name>@的变量。使用其他选项进一步定义变量。例如:

1
-app: mpirun -np @np@ -ppn @ppn@ IMB-MPI1 -msglog 0:@logmax@ -npmin @np@ @func@

注意应用程序必须产生输出(在标准输出或文件或任何其他目标中),调优器可以解析这些输出以选择要调整的值和其他变量。有关详细信息,请参阅下面的-app-regex-appregex-legend选项。

-app-regex:设置要计算的正则表达式以从应用程序输出中提取所需的值。使用正则表达式组将值分配给变量。变量和组关联使用-app-regex-legend选项设置。例如,要从此输出中提取#bytest_max[usec] 值:

1
2
3
#bytes #repetitions t_min[usec] t_max[usec] t_avg[usec]
0 1000 0.06 0.06 0.06
1 1000 0.10 0.10 0.10

使用以下配置:

1
-app-regex: (\d+)\s+\d+\s+[\d.+-]+\s+([\d.+-]+)

-app-regex-legend指定从正则表达式中提取的变量列表。变量对应于正则表达式组。调优器使用最后一个变量作为启动的性能指标。使用-tree-opt设置指标的优化方向。例如:

1
-app-regex-legend: size, time

-iter使用一组给定的参数设置每次启动的迭代次数。更多的迭代次数会提高结果的准确性。例如:

1
-iter: 3

环境变量

编译环境变量

I_MPI_{CC,CXX,FC,F77,F90}_PROFILE指定默认的分析库。

1
2
3
4
5
I_MPI_CC_PROFILE=<profile-name>
I_MPI_CXX_PROFILE=<profile-name>
I_MPI_FC_PROFILE=<profile-name>
I_MPI_F77_PROFILE=<profile-name>
I_MPI_F90_PROFILE=<profile-name>

<profile-name>指定默认的分析库。

设置此环境变量以选择默认使用的特定 MPI 分析库。这与使用-profile=<profile-name>作为 mpiicc 或其他英特尔® MPI 库编译器包装器的参数具有相同的效果。

I_MPI_TRACE_PROFILE-trace选项指定默认配置文件。

1
I_MPI_TRACE_PROFILE=<profile-name>

<profile-name>指定跟踪profile-name。默认值为 vt。设置此环境变量以选择要与mpiicc-trace选项或其他英特尔 MPI 库编译器包装器一起使用的特定 MPI 分析库。I_MPI_{CC,CXX,F77,F90}_PROFILE环境变量覆盖I_MPI_TRACE_PROFILE

I_MPI_CHECK_PROFILE-check_mpi选项指定默认配置文件。

1
I_MPI_CHECK_PROFILE=<profile-name>

<profile-name>指定检查profile-name。默认值为vtmc。设置此环境变量以选择特定 MPI 检查库,以与 mpiicc 或其他英特尔 MPI 库编译器包装器的-check_mpi选项一起使用。I_MPI_{CC,CXX,F77,F90}_PROFILE环境变量覆盖I_MPI_CHECK_PROFILE

I_MPI_CHECK_COMPILER打开/关闭编译器兼容性检查。

1
I_MPI_CHECK_COMPILER=<arg>

参数

  • enable |yes | on | 1 启用检查编译器。
  • disable |no| off | 0 禁用检查编译器。这是默认值。

如果I_MPI_CHECK_COMPILER设置为启用,英特尔 MPI 库编译器包装器会检查底层编译器的兼容性。正常编译需要使用已知版本的底层编译器。

I_MPI_{CC,CXX,FC,F77,F90}设置要使用的底层编译器的路径/名称。

1
2
3
4
5
I_MPI_CC=<compiler>
I_MPI_CXX=<compiler>
I_MPI_FC=<compiler>
I_MPI_F77=<compiler>
I_MPI_F90=<compiler>

<compiler>指定要使用的编译器的完整路径/名称。设置此环境变量以选择要使用的特定编译器。如果编译器不在搜索路径中,请指定编译器的完整路径。注意某些编译器可能需要额外的命令行选项。注意 如果配置文件存在于指定的编译器中,则该配置文件是源文件。有关详细信息。

I_MPI_ROOT设置英特尔 MPI 库安装目录路径。

1
I_MPI_ROOT=<path>

<path>指定英特尔 MPI 库的安装目录。设置此环境变量以指定英特尔 MPI 库的安装目录。注意如果您使用的是 Visual Studio 集成,则可能需要使用I_MPI_ONEAPI_ROOT

VT_ROOT设置英特尔® Trace Collector 安装目录路径。

1
VT_ROOT=<path>

<path>指定 Intel Trace Collector 的安装目录。设置此环境变量以指定 Intel Trace Collector 的安装目录。

I_MPI_COMPILER_CONFIG_DIR设置编译器配置文件的位置。

1
I_MPI_COMPILER_CONFIG_DIR=<path>

<path>指定编译器配置文件的位置。默认值为<install-dir>/etc。设置此环境变量以更改编译器配置文件的默认位置。

I_MPI_LINK选择特定版本的英特尔 MPI 库进行链接。

1
I_MPI_LINK=<arg>

  • opt多线程优化库(带全局锁)。这是默认值
  • dbg多线程调试库(带全局锁)
  • opt_mt多线程优化库(线程拆分模型具有每个对象的锁)
  • dbg_mt多线程调试库(线程拆分模型具有每个对象的锁)

将此变量设置为始终链接到英特尔 MPI 库的指定版本。

I_MPI_DEBUG_INFO_STRIP在静态链接应用程序时打开/关闭调试信息剥离。

1
I_MPI_DEBUG_INFO_STRIP=<arg>

参数

  • enable |yes | on | 1 启用。这是默认值。
  • disable |no| off | 0 禁用。

使用此选项可在静态链接英特尔 MPI 库时打开/关闭调试信息剥离。默认情况下会去除调试信息。

I_MPI_{C,CXX,FC,F}FLAGS设置编译所需的特殊标志。

1
2
3
4
I_MPI_CFLAGS=<flag>
I_MPI_CXXFLAGS=<flag>
I_MPI_FCFLAGS=<flag>
I_MPI_FFLAGS=<flag>

使用此环境变量来指定特殊的编译标志。

I_MPI_LDFLAGS设置链接所需的特殊标志。

1
I_MPI_LDFLAGS=<flag>

I_MPI_FORT_BIND禁用 mpiicc 与 Fortran 绑定的链接。

1
I_MPI_FORT_BIND=<args>

参数

  • enable |yes | on | 1 启用。这是默认值。
  • disable |no| off | 0 禁用。

默认情况下,即使不使用 Fortran,mpiicc 也会针对 Fortran 绑定进行链接。 使用此环境变量来更改此默认行为。 与-nofortbind选项具有相同的效果。

Hydra 环境变量

I_MPI_HYDRA_HOST_FILE设置主机文件以运行应用程序。

1
I_MPI_HYDRA_HOST_FILE=<arg>

<hostsfile>主机文件的完整或相对路径

I_MPI_HYDRA_HOSTS_GROUP使用方括号、逗号和破折号设置节点范围。

1
I_MPI_HYDRA_HOSTS_GROUP=<arg>

将此变量设置为能够使用方括号、逗号和破折号设置节点范围(如在 Slurm* 工作负载管理器中)。例如:

1
I_MPI_HYDRA_HOSTS_GROUP="hostA[01-05],hostB,hostC[01-05,07,09-11]"

您可以使用-hosts-group选项设置节点范围。

I_MPI_HYDRA_DEBUG打印调试信息。

1
I_MPI_HYDRA_DEBUG=<参数>

参数

  • enable |yes | on | 1 启用。
  • disable |no| off | 0 禁用。这是默认值。

I_MPI_HYDRA_ENV设置此环境变量以控制环境变量传播到 MPI 进程。默认情况下,整个启动节点环境传递给 MPI 进程。设置此变量还会覆盖远程 shell 设置的环境变量。

1
I_MPI_HYDRA_ENV=<参数>

  • all将所有环境传递给所有 MPI 进程

I_MPI_JOB_TIMEOUT设置 mpiexec.hydra 的超时时间。以秒为单位定义 mpiexec.hydra 超时时间

1
2
I_MPI_JOB_TIMEOUT=<timeout>
I_MPI_MPIEXEC_TIMEOUT=<timeout>

  • <n> ≥ 0 超时时间的值。默认超时值为零,即意味着没有超时。

设置此环境变量以使 mpiexec.hydra 在启动后<timeout>秒内终止作业。<timeout>值应大于零。否则环境变量设置将被忽略。

I_MPI_JOB_STARTUP_TIMEOUT设置 mpiexec.hydra 作业启动超时。<timeout>以秒为单位定义 mpiexec.hydra 启动超时时间。如果某些进程未启动,则设置此环境变量以使 mpiexec.hydra 在<timeout>秒内终止作业。<timeout>值应大于零。

1
I_MPI_JOB_STARTUP_TIMEOUT=<timeout>

  • <n> ≥ 0超时时间的值。默认超时值为零,即意味着没有超时。

I_MPI_JOB_TIMEOUT_SIGNAL定义作业因超时而终止时要发送的信号。如果I_MPI_JOB_TIMEOUT环境变量指定的超时期限到期,则定义要发送的信号编号以停止 MPI 作业。 如果您设置了系统不支持的信号编号,则 mpiexec.hydra 命令会打印一条警告消息并使用默认信号编号 9 (SIGKILL) 继续终止任务。

1
I_MPI_JOB_TIMEOUT_SIGNAL=<number>

  • <n> > 0信号编号。 默认值为 9(SIGKILL)

I_MPI_JOB_ABORT_SIGNAL定义当作业意外终止时要发送到所有进程的信号。设置此环境变量以定义任务终止的信号。 如果您设置了不受支持的信号编号,mpiexec.hydra 会打印一条警告消息并使用默认信号 9 (SIGKILL)。

1
I_MPI_JOB_ABORT_SIGNAL=<number>

  • <n> > 0默认值为 9(SIGKILL)

I_MPI_JOB_SIGNAL_PROPAGATION控制信号传播。设置此环境变量以控制信号(SIGINT、SIGALRM 和 SIGTERM)的传播。 如果启用信号传播,则接收到的信号将发送到 MPI 作业的所有进程。 如果禁用信号传播,则 MPI 作业的所有进程都将使用默认信号 9 (SIGKILL) 停止。

1
I_MPI_JOB_SIGNAL_PROPAGATION=<arg>

  • enable | yes | on | 1 Turn on propagation
  • disable | no | off | 0 Turn off propagation. This is the default value

I_MPI_HYDRA_BOOTSTRAP设置引导服务器。

1
I_MPI_HYDRA_BOOTSTRAP=<arg>

  • sshUse secure shell. This is the default value
  • rshUse remote shell
  • pdsh Use parallel distributed shell
  • pbsdsh Use Torque and PBS pbsdsh command
  • fork Use fork call
  • slurm Use Slurm* srun command
  • ll Use LoadLeveler* llspawn.stdio command
  • lsf Use LSF* blaunch command
  • sge Use Univa Grid Engine qrsh command

I_MPI_HYDRA_BOOTSTRAP_EXEC将可执行文件设置为用作引导服务器。

1
I_MPI_HYDRA_BOOTSTRAP_EXEC=<arg>

  • <executable>可执行文件的名称

I_MPI_HYDRA_BOOTSTRAP_EXEC_EXTRA_ARGS为引导服务器设置其他参数。

1
I_MPI_HYDRA_BOOTSTRAP_EXEC_EXTRA_ARGS=<arg>

设置此环境变量以指定引导服务器的其他参数。 注意如果启动器(blaunch、lsf、pdsh、pbsdsh)回退到 ssh,请通过调用 ssh 传递参数。

I_MPI_HYDRA_BOOTSTRAP_AUTOFORK控制对本地进程的 fork 调用的使用。

1
I_MPI_HYDRA_BOOTSTRAP_AUTOFORK = <arg>

  • enable | yes | on | 1 对本地进程使用 fork。 这是 ssh、rsh、ll、lsf 和 pbsdsh 引导服务器的默认值
  • disable | no | off | 0 不要对本地进程使用 fork。 这是sge引导服务器的默认值

设置此环境变量以控制本地进程对 fork 调用的使用。 注意此选项不适用于 slurm 和 pdsh 引导服务器。

I_MPI_HYDRA_RMK使用指定值作为资源管理内核获取可用节点的数据,外部设置进程计数。

1
I_MPI_HYDRA_RMK=<arg>

  • <rmk> 资源管理内核。 支持的值为 slurm、ll、lsf、sge、pbs、cobalt。

I_MPI_HYDRA_PMI_CONNECT定义 PMI 消息的处理方法。

1
I_MPI_HYDRA_PMI_CONNECT=<value>

  • nocache 不缓存 PMI 消息
  • cache 在本地 pmi_proxy 管理进程上缓存 PMI 消息,以最大限度地减少 PMI 请求的数量。 缓存的信息会自动传播到子管理进程。
  • lazy-cache 按需传播。
  • alltoall 信息在所有 pmi_proxy 之间自动交换,然后才能完成任何获取请求。 这是默认值。

使用此环境变量来选择 PMI 消息处理方法。I_MPI_PERHOST定义mpiexec.hydra命令的-perhost选项的默认行为。

1
I_MPI_PERHOST=<value>

<value>定义一个默认用于-perhost的值。

  • integer > 0 选项的确切值
  • all 节点上的所有逻辑 CPU
  • allcores 节点上的所有内核(物理 CPU)。 这是默认值。

设置此环境变量以定义-perhost选项的默认行为。 除非明确指定,否则-perhost选项隐含在I_MPI_PERHOST中设置的值中。 注意在作业调度程序下运行时,默认情况下会忽略此环境变量。 要使用I_MPI_PERHOST控制进程放置,请禁用I_MPI_JOB_RESPECT_PROCESS_PLACEMENT变量。

I_MPI_JOB_TRACE_LIBS通过-trace选项选择要预加载的库。

1
I_MPI_JOB_TRACE_LIBS=<arg>

<list>要预加载的库的空白分隔列表。 默认值为vt

设置此环境变量以通过-trace选项选择用于预加载的替代库。

I_MPI_JOB_CHECK_LIBS通过-check_mpi选项选择要预加载的库。

1
I_MPI_JOB_CHECK_LIBS=<arg>

<list>要预加载的库的空白分隔列表。 默认值为vtmc。设置此环境变量以通过-check_mpi选项选择用于预加载的替代库。

I_MPI_HYDRA_BRANCH_COUNT设置分层分支计数。设置此环境变量以限制由mpiexec.hydra操作或每个pmi_proxy管理进程启动的子管理进程的数量。

1
I_MPI_HYDRA_BRANCH_COUNT =<num>

参数为<n> >= 0。默认值为16。该值表示如果节点数大于16,则启用分层结构。如果I_MPI_HYDRA_BRANCH_COUNT=0,则不存在分层结构。 如果I_MPI_HYDRA_BRANCH_COUNT=-1,则分支计数等于默认值。

I_MPI_HYDRA_PMI_AGGREGATE打开/关闭 PMI 消息的聚合。

1
I_MPI_HYDRA_PMI_AGGREGATE=<arg>

  • enable | yes | on | 1 启用 PMI 消息聚合。 这是默认值。
  • disable | no | off | 0 禁用 PMI 消息聚合。

I_MPI_HYDRA_GDB_REMOTE_SHELL设置远程 shell 命令以运行 GDB 调试器。 此命令使用 Intel® Distribution for GDB。

1
I_MPI_HYDRA_GDB_REMOTE_SHELL=<arg>

  • ssh Secure Shell (SSH). 这是默认值。
  • rsh Remote shell (RSH)

设置此环境变量以指定远程 shell 命令以在远程机器上运行 GNU* 调试器。 您可以使用此环境变量来指定任何与 SSH 或 RSH 具有相同语法的 shell 命令。

I_MPI_HYDRA_IFACE设置网络接口。设置此环境变量以指定要使用的网络接口。 例如,如果您的 InfiniBand* 网络的 IP 模拟是在 ib0 上配置的,则使用“-iface ib0”。

1
I_MPI_HYDRA_IFACE=<arg>

<network interface>设置您系统中配置的网络接口。

I_MPI_HYDRA_DEMUX设置解复用器(demux)模式。

1
I_MPI_HYDRA_DEMUX=<arg>

  • poll 将 poll 设置为多 I/O 解复用器 (demux) 模式引擎。 这是默认值。
  • select 将 select 设置为多 I/O 解复用器 (demux) 模式引擎

设置此环境变量以指定多 I/O 解复用模式引擎。 默认值为轮询。

I_MPI_TMPDIR指定一个临时目录。

1
I_MPI_TMPDIR=<arg>

  • <path>临时目录。 默认值为/tmp

设置此环境变量以指定临时文件的目录。

I_MPI_JOB_RESPECT_PROCESS_PLACEMENT指定是使用作业调度程序提供的 process-per-node 放置,还是显式设置。

1
I_MPI_JOB_RESPECT_PROCESS_PLACEMENT=<arg>

  • enable | yes | on | 1 使用作业调度程序提供的进程放置。 这是默认值
  • disable | no | off | 0 不要使用作业调度程序提供的进程放置

如果设置了该变量,Hydra 管理器将使用作业调度程序提供的流程放置(默认)。 在这种情况下,-ppn选项及其等效项将被忽略。 如果禁用该变量,Hydra 进程管理器将使用带有-ppn或其等效项的进程放置集。

I_MPI_GTOOL指定要为选定进程启动的工具。 此变量的替代方法是-gtool选项。

1
2
3
4
I_MPI_GTOOL="
<command line for a tool 1>:<ranks set 1>[=exclusive][@arch 1];
<command line for a tool 2>:<ranks set 2>[=exclusive][@arch 2]; … ;
<command line for a tool n>:<ranks set n>[=exclusive][@arch n]"

  • <command-line-for-a-tool>指定工具的启动命令,包括参数。
  • <rank set>指定工具执行中涉及的进程范围。 用逗号分隔等级或使用“-”符号表示一组连续的等级。 要为所有等级运行该工具,请使用 all 参数。 注意如果您指定了不正确的排名索引,则会打印相应的警告,并且该工具会继续为有效的排名工作。
  • [=exclusive] 指定此参数可防止为每个主机启动超过一个等级的工具。 该参数是可选的。
  • [@arch] 指定工具运行的架构(可选)。 对于给定的<rank set>,如果指定此参数,则仅为驻留在具有指定架构的主机上的进程启动该工具。 该参数是可选的。

使用此选项可为指定进程启动英特尔® VTune™ Amplifier XE、Valgrind 和 GNU Debugger 等工具。

以下命令行示例演示了使用I_MPI_GTOOL环境变量的不同场景。 通过设置I_MPI_GTOOL环境变量启动英特尔® VTune™ Amplifier XE 和 Valgrind*:

1
2
$ export I_MPI_GTOOL="vtune -collect hotspots -analyze-system -r result1:5,3,7-9=exclusive@bdw; valgrind -log-file=log_%p:0,1,10-12@hsw"
$ mpiexec.hydra -n 16 a.out

此命令为在代号为 Broadwell 的英特尔® 微架构上运行的进程启动 vtune。 每个主机只启动一个 vtune 副本,索引最小的进程会受到影响。 同时,为在代号为 Haswell 的英特尔® 微架构上运行的所有指定进程启动 Valgrind*。 Valgrind 的结果保存到文件log_<process ID>

通过设置I_MPI_GTOOL环境变量启动 GDB(对于英特尔® oneAPI,这将启动英特尔® GDB 分发版):

1
$ mpiexec.hydra -n 16 -genv I_MPI_GTOOL="gdb:3,5,7-9" a.out

使用此命令将 GDB 应用于给定的进程集。

注意选项和环境变量-gtool-gtoolfileI_MPI_GTOOL是互斥的。 选项-gtool-gtoolfile具有相同的优先级并且比I_MPI_GTOOL具有更高的优先级。 命令行中的第一个指定选项有效,第二个被忽略。 因此,如果您未指定-gtool-gtoolfile,请使用I_MPI_GTOOL

I_MPI_HYDRA_TOPOLIB设置拓扑检测接口。设置这个环境变量来定义平台检测的接口。 默认情况下使用 hwloc* 接口,但您可以显式设置变量以使用本机英特尔 MPI 库接口

1
I_MPI_HYDRA_TOPOLIB=<arg>

  • hwloc:hwloc* 库函数被调用以进行拓扑检测。

I_MPI_PORT_RANGE指定允许的端口号范围。设置此环境变量以指定英特尔® MPI 库的允许端口号范围。

1
I_MPI_PORT_RANGE=<range>

<min>:<max>允许的端口范围。

I_MPI_SILENT_ABORT控制中止警告消息。

1
I_MPI_SILENT_ABORT=<arg>

  • enable | yes | on | 1 不打印中止警告消息
  • disable | no | off | 0 打印中止警告消息。 这是默认值

设置此变量以禁用打印中止警告消息。 在MPI_Abort调用的情况下打印消息。 您还可以使用-silent-abort选项禁用这些消息的打印。

I_MPI_HYDRA_NAMESERVER指定名称服务器。

1
I_MPI_HYDRA_NAMESERVER=<arg>

<hostname>:<port>设置主机名和端口。

设置此变量以按以下格式为 MPI 应用程序指定名称服务器:

1
I_MPI_HYDRA_NAMESERVER = hostname:port

I_MPI_ADJUST Family Environment Variables

I_MPI_ADJUST_<opname>控制集合运算算法选择。

1
I_MPI_ADJUST_<opname>="<algid>[:<conditions>][;<algid>:<conditions>[...]]"

  • <algid>算法标识符
    • >= 0 设置一个数字以选择所需的算法。 值 0 使用集合算法选择的基本逻辑。
  • <conditions> 逗号分隔的条件列表。 空列表选择所有消息大小和进程组合
    • <l><l>大小的消息
    • <l>-<m>:大小从<l><m>的消息
    • <l>@<p>:消息大小<l>和进程数<p>
    • <l>-<m>@<p>-<q>:消息大小从<l><m>,进程数从<p><q>

设置此环境变量以在特定条件下为集合操作<opname>选择所需的算法。 每个集合操作都有自己的环境变量和算法。以下是各种集合操作及对应的算法

I_MPI_ADJUST_ALLGATHERMPI_Allgather

  1. Recursive doubling
  2. Bruck’s
  3. Ring
  4. Topology aware Gatherv + Bcast
  5. Knomial

I_MPI_ADJUST_ALLGATHERVMPI_Allgatherv

  1. Recursive doubling
  2. Bruck’s
  3. Ring
  4. Topology aware Gatherv + Bcast

I_MPI_ADJUST_ALLREDUCEMPI_Allreduce

  1. Recursive doubling
  2. Rabenseifner’s
  3. Reduce + Bcast
  4. Topology aware Reduce + Bcast
  5. Binomial gather + scatter
  6. Topology aware binominal gather + scatter
  7. Shumilin’s ring
  8. Ring
  9. Knomial
  10. Topology aware SHM-based flat
  11. Topology aware SHM-based Knomial
  12. Topology aware SHM-based Knary

I_MPI_ADJUST_ALLTOALLMPI_Alltoall

  1. Bruck’s
  2. Isend/Irecv + waitall
  3. Pair wise exchange
  4. Plum’s

I_MPI_ADJUST_ALLTOALLVMPI_Alltoallv

  1. Isend/Irecv + waitall
  2. Plum’s

I_MPI_ADJUST_ALLTOALLWMPI_Alltoallw

  1. Isend/Irecv + waitall

I_MPI_ADJUST_BARRIERMPI_Barrier

  1. Dissemination
  2. Recursive doubling
  3. Topology aware dissemination
  4. Topology aware recursive doubling
  5. Binominal gather + scatter
  6. Topology aware binominal gather + scatter
  7. Topology aware SHM-based flat
  8. Topology aware SHM-based Knomial
  9. Topology aware SHM-based Knary

I_MPI_ADJUST_BCASTMPI_Bcast

  1. Binomial
  2. Recursive doubling
  3. Ring
  4. Topology aware binomial
  5. Topology aware recursive doubling
  6. Topology aware ring
  7. Shumilin’s
  8. Knomial
  9. Topology aware SHM-based flat
  10. Topology aware SHM-based Knomial
  11. Topology aware SHM-based Knary
  12. NUMA aware SHM-based (SSE4.2)
  13. NUMA aware SHM-based (AVX2)
  14. NUMA aware SHM-based (AVX512)

I_MPI_ADJUST_EXSCANMPI_Exscan

  1. Partial results gathering
  2. Partial results gathering regarding layout of processes

I_MPI_ADJUST_GATHERMPI_Gather

  1. Binomial
  2. Topology aware binomial
  3. Shumilin’s
  4. Binomial with segmentation

I_MPI_ADJUST_GATHERVMPI_Gatherv

  1. Linear
  2. Topology aware linear
  3. Knomial

I_MPI_ADJUST_REDUCE_SCATTERMPI_Reduce_scatter

  1. Recursive halving
  2. Pair wise exchange
  3. Recursive doubling
  4. Reduce + Scatterv
  5. Topology aware Reduce + Scatterv

I_MPI_ADJUST_REDUCEMPI_Reduce

  1. Shumilin’s
  2. Binomial
  3. Topology aware Shumilin’s
  4. Topology aware binomial
  5. Rabenseifner’s
  6. Topology aware Rabenseifner’s
  7. Knomial
  8. Topology aware SHM-based flat
  9. Topology aware SHM-based Knomial
  10. Topology aware SHM-based Knary
  11. Topology aware SHM-based binomial

I_MPI_ADJUST_SCANMPI_Scan

  1. Partial results gathering
  2. Topology aware partial results gathering

I_MPI_ADJUST_SCATTERMPI_Scatter

  1. Binomial
  2. Topology aware binomial
  3. Shumilin’s

I_MPI_ADJUST_SCATTERVMPI_Scatterv

  1. Linear
  2. Topology aware linear

I_MPI_ADJUST_SENDRECV_REPLACEMPI_Sendrecv_replace

  1. Generic
  2. Uniform (with restrictions)

I_MPI_ADJUST_IALLGATHERMPI_Iallgather

  1. Recursive doubling
  2. Bruck’s
  3. Ring

I_MPI_ADJUST_IALLGATHERVMPI_Iallgatherv

  1. Recursive doubling
  2. Bruck’s
  3. Ring

I_MPI_ADJUST_IALLREDUCEMPI_Iallreduce

  1. Recursive doubling
  2. Rabenseifner’s
  3. Reduce + Bcast
  4. Ring (patarasuk)
  5. Knomial
  6. Binomial
  7. Reduce scatter allgather
  8. SMP
  9. Nreduce

I_MPI_ADJUST_IALLTOALLMPI_Ialltoall

  1. Bruck’s
  2. Isend/Irecv + Waitall
  3. Pairwise exchange

I_MPI_ADJUST_IALLTOALLVMPI_Ialltoallv

  1. Isend/Irecv + Waitall

I_MPI_ADJUST_IALLTOALLWMPI_Ialltoallw

  1. Isend/Irecv + Waitall

I_MPI_ADJUST_IBARRIERMPI_Ibarrier

  1. Dissemination

I_MPI_ADJUST_IBCAST:`MPI_Ibcast

  1. Binomial
  2. Recursive doubling
  3. Ring
  4. Knomial
  5. SMP
  6. Tree knominal
  7. Tree kary

I_MPI_ADJUST_IEXSCANMPI_Iexscan

  1. Recursive doubling
  2. SMP

I_MPI_ADJUST_IGATHERMPI_Igather

  1. Binomial
  2. Knomial

I_MPI_ADJUST_IGATHERVMPI_Igatherv

  1. Linear
  2. Linear ssend

I_MPI_ADJUST_IREDUCE_SCATTERMPI_Ireduce_scatter

  1. Recursive halving
  2. Pairwise
  3. Recursive doubling

I_MPI_ADJUST_IREDUCEMPI_Ireduce

  1. Rabenseifner’s
  2. Binomial
  3. Knomial

I_MPI_ADJUST_ISCAN:`MPI_Iscan

  1. Recursive Doubling
  2. SMP

I_MPI_ADJUST_ISCATTERMPI_Iscatter

  1. Binomial
  2. Knomial

I_MPI_ADJUST_ISCATTERVMPI_Iscatterv

  1. Linear

下表中描述了集合操作的消息大小计算规则。 下表中n/a表示对应区间<l>-<m>应省略。注意I_MPI_ADJUST_SENDRECV_REPLACE=2(“uniform”)算法只能在所有进程的数据类型和对象计数都相同的情况下使用。

  • MPI_Allgatherrecv_count*recv_type_size
  • MPI_Allgathervtotal_recv_count*recv_type_size
  • MPI_Allreducecount*type_size
  • MPI_Alltoallsend_count*send_type_size
  • MPI_Alltoallvn/a
  • MPI_Alltoallwn/a
  • MPI_Barriern/a
  • MPI_Bcastcount*type_size
  • MPI_Exscancount*type_size
  • MPI_Gather
    • recv_count*recv_type_size if MPI_IN_PLACE is used
    • send_count*send_type_size
  • MPI_Gathervn/a
  • MPI_Reduce_scattertotal_recv_count*type_size
  • MPI_Reducecount*type_size
  • MPI_Scancount*type_size
  • MPI_Scatter
    • send_count*send_type_size if MPI_IN_PLACE is used,
    • recv_count*recv_type_size
  • MPI_Scattervn/a

使用以下设置为MPI_Reduce操作选择第二个算法:I_MPI_ADJUST_REDUCE=2使用以下设置来定义MPI_Reduce_scatter操作的算法:

1
I_MPI_ADJUST_REDUCE_SCATTER="4:0-100,5001-10000;1:101-3200;2:3201-5000;3"

在这种情况下。 算法4用于0~100字节和5001~10000字节的消息大小,算法1用于101~3200字节的消息大小,算法2用于3201~5000字节的消息大小,算法 3 用于所有其他消息。

I_MPI_ADJUST_<opname>_LIST设置此环境变量以指定英特尔 MPI 运行时为指定的<opname>考虑的算法集。此变量在自动调整方案以及用户希望选择特定算法子集的调整方案中很有用。注意设置空字符串会禁用<opname>集合的自动调整。

1
I_MPI_ADJUST_<opname>_LIST=<algid1>[-<algid2>][,<algid3>][,<algid4>-<algid5>]

I_MPI_COLL_INTRANODE设置此环境变量以切换节点内通信类型以进行集合操作。如果有大量的通信器,您可以关闭 SHM 集合以避免内存过度消耗。

1
I_MPI_COLL_INTRANODE=<mode>

  • pt2pt 仅使用基于点对点通信的集合
  • shm 启用共享内存集合。 这是默认值

I_MPI_COLL_INTRANODE_SHM_THRESHOLD

1
I_MPI_COLL_INTRANODE_SHM_THRESHOLD=<nbytes>

<nbytes>定义共享内存集合处理的最大数据块大小。

  • > 0使用指定的大小。 默认值为 16384 字节。

设置此环境变量以定义每个进程可用于数据放置的共享内存区域的大小。 大于这个值的消息将不会被基于 SHM 的集合操作处理,而是会被基于点对点的集合操作处理。 该值必须是 4096 的倍数。

I_MPI_COLL_EXTERNAL

1
I_MPI_COLL_EXTERNAL=<arg>

  • enable | yes | on | 1 启用外部集合操作功能。
  • disable | no | off | 0 禁用外部集合操作功能。 这是默认的

设置此环境变量以启用外部集合操作。 该机制允许启用 HCOLL。 该功能启用以下集合操作:

  • I_MPI_ADJUST_ALLREDUCE=24
  • I_MPI_ADJUST_BARRIER=11
  • I_MPI_ADJUST_BCAST=16
  • I_MPI_ADJUST_REDUCE=13
  • I_MPI_ADJUST_ALLGATHER=6
  • I_MPI_ADJUST_ALLTOALL=5
  • I_MPI_ADJUST_ALLTOALLV=5
  • I_MPI_ADJUST_SCAN=3
  • I_MPI_ADJUST_EXSCAN=3
  • I_MPI_ADJUST_GATHER=5
  • I_MPI_ADJUST_GATHERV=4
  • I_MPI_ADJUST_SCATTER=5
  • I_MPI_ADJUST_SCATTERV=4
  • I_MPI_ADJUST_ALLGATHERV=5
  • I_MPI_ADJUST_ALLTOALLW=2
  • I_MPI_ADJUST_REDUCE_SCATTER=6
  • I_MPI_ADJUST_REDUCE_SCATTER_BLOCK=4
  • I_MPI_ADJUST_IALLGATHER=5
  • I_MPI_ADJUST_IALLGATHERV=5
  • I_MPI_ADJUST_IGATHERV=3
  • I_MPI_ADJUST_IALLREDUCE=9
  • I_MPI_ADJUST_IALLTOALLV=2
  • I_MPI_ADJUST_IBARRIER=2
  • I_MPI_ADJUST_IBCAST=5
  • I_MPI_ADJUST_IREDUCE=4

要强制使用HCOLL,请使用上述I_MPI_ADJUST_<opname>值。 为了获得更好的性能,一旦启用I_MPI_COLL_EXTERNAL就使用自动tuner以获得最佳集合设置。

I_MPI_CBWR在相同数量的进程的情况下,控制跨不同平台、网络和拓扑的浮点运算结果的再现性。

1
I_MPI_CBWR=<arg>

<arg>:CBWR 兼容模式

  • 0:None
    • 不要在库范围模式下使用 CBWR。 可以使用MPI_Comm_dup_with_info显式创建 CNR 安全通信器。 这是默认值。
  • 1:Weak mode
    • 禁用拓扑相关集合操作。集合操作的结果不取决于rank位置。该模式保证了同一集群上不同运行的结果可重复性(独立于rank位置)。
  • 2:Strict mode
    • 在算法选择期间禁用拓扑相关集合、忽略 CPU 架构和互连。 该模式可确保结果在不同集群上的不同运行之间具有可重复性(独立于rank位置、CPU 架构和互连)

有条件数值再现性 (Conditional Numerical Reproducibility, CNR) 提供控制以在集合运算中获得可再现的浮点结果。 借助此功能,英特尔 MPI 集合运算旨在在 MPI 进程数相同的情况下,每次运行返回相同的浮点结果。

在库范围内使用I_MPI_CBWR环境变量控制此功能,其中所有通信器上的所有集合都保证具有可重现的结果。 要以更精确和每个通信器的方式控制浮点运算的可重复性,请将{“I_MPI_CBWR”, “yes”}键值对传递给MPI_Comm_dup_with_info调用。

注意使用环境变量在库范围模式下设置I_MPI_CBWR会导致性能下降。使用MPI_Comm_dup_with_info创建的 CNR 安全通信器始终在严格模式下工作。 例如:

1
2
3
4
5
6
MPI_Info hint;
MPI_Comm cbwr_safe_world, cbwr_safe_copy;
MPI_Info_create(&hint);
MPI_Info_set(hint, “I_MPI_CBWR”, “yes”);
MPI_Comm_dup_with_info(MPI_COMM_WORLD, hint, & cbwr_safe_world);
MPI_Comm_dup(cbwr_safe_world, & cbwr_safe_copy);

在上面的例子中,cbwr_safe_worldcbwr_safe_copy都是 CNR 安全的。 使用cbwr_safe_world及其副本为关键操作获得可重现的结果。 请注意,MPI_COMM_WORLD本身可用于性能关键操作,而没有可重复性限制。

Tuning Environment Variables

I_MPI_TUNING_MODE选择tuning方法。

1
I_MPI_TUNING_MODE=<arg>

  • none:禁用tuning模式。 这是默认值。
  • auto:启用自动tuner.
  • auto:application:使用专注于应用程序的策略(auto 的别名)启用自动tuner。
  • auto:cluster:在没有应用程序特定逻辑的情况下启用自动tuner。 这通常在基准测试(例如 IMB-MPI1)和代理应用程序的帮助下执行。

设置此环境变量以启用自动调优器功能并设置自动调优器策略。

I_MPI_TUNING_BIN:以二进制格式指定调整设置的路径。设置此环境变量以二进制格式加载调整设置。

1
I_MPI_TUNING_BIN=<path>

<path>带有调整设置的二进制文件的路径。 默认情况下,英特尔® MPI 库使用位于<$I_MPI_ONEAPI_ROOT/etc>的二进制调整文件。

I_MPI_TUNING_BIN_DUMP指定用于以二进制格式存储调整设置的文件。

1
I_MPI_TUNING_BIN_DUMP=<filename>

<filename>存储调整设置的二进制文件的文件名。 默认情况下,未指定路径。

I_MPI_TUNING以 JSON 格式加载调整设置。设置此环境变量以加载 JSON 格式的调整设置。

1
I_MPI_TUNING=<path>

<path>带有调整设置的 JSON 文件的路径。

注意 JSON 格式的调整设置由 mpitune 实用程序生成。默认情况下,英特尔® MPI 库以二进制格式加载调整设置。 如果不可能,英特尔 MPI 库会以通过I_MPI_TUNING环境变量指定的 JSON 格式加载调优文件。 因此,要启用 JSON 调整,请关闭默认的二进制调整:I_MPI_TUNING_BIN=""。 如果无法从 JSON 文件以二进制格式加载调整设置,则使用默认调整值。 如果您使用I_MPI_ADJUST系列环境变量,则不需要关闭二进制或 JSON 调整设置。 使用I_MPI_ADJUST环境变量指定的算法始终优先于二进制和 JSON 调整设置。

Autotuning

调整非常依赖于特定平台的规格。 英特尔仔细确定了调整参数,并使用I_MPI_TUNING_MODEI_MPI_TUNING_AUTO系列环境变量使它们可用于自动调整,以找到最佳设置(请参阅调整环境变量和I_MPI_TUNING_AUTO系列环境变量)。 注意I_MPI_TUNING_MODEI_MPI_TUNING_AUTO系列环境变量仅支持 Intel 处理器,不能在其他平台上使用。 自动调优器功能可让您自动找到集体操作的最佳算法。 自动调优器搜索空间可以通过·I_MPI_ADJUST__LIST·变量进行修改。

当前可用于自动调整的集合是:MPI_Allreduce, MPI_Bcast, MPI_Barrier, MPI_Reduce, MPI_Gather, MPI_Scatter, MPI_Alltoall, MPI_Allgatherv, MPI_Reduce_scatter, MPI_Reduce_scatter_block, MPI_Scan, MPI_Exscan, MPI_Iallreduce, MPI_Ibcast, MPI_Ibarrier, MPI_Ireduce, MPI_Igather, MPI_Iscatter, MPI_Ialltoall, MPI_Iallgatherv, MPI_Ireduce_scatter, MPI_Ireduce_scatter_block, MPI_Iscan,MPI_Iexscan

要开始自动调整,请按照下列步骤操作:

  1. 在启用自动调优器的情况下启动应用程序并指定存储结果的转储文件:I_MPI_TUNING_MODE=autoI_MPI_TUNING_BIN_DUMP=<tuning-results.dat>
  2. 使用上一步生成的调整结果启动应用程序:I_MPI_TUNING_BIN=<tuning-results.dat>或使用-tune Hydra选项。
  3. 如果您遇到性能问题,请参阅自动调整的环境变量。
1
2
3
4
5
6
7
1. $ export I_MPI_TUNING_MODE=auto
$ export I_MPI_TUNING_AUTO_SYNC=1
$ export I_MPI_TUNING_AUTO_ITER_NUM=5
$ export I_MPI_TUNING_BIN_DUMP=./tuning_results.dat
$ mpirun -n 128 -ppn 64 IMB-MPI1 allreduce -iter 1000,800 -time 4800
2. $ export I_MPI_TUNING_BIN=./tuning_results.dat
$ mpirun -n 128 -ppn 64 IMB-MPI1 allreduce -iter 1000,800 -time 4800

I_MPI_TUNING_AUTO Family Environment Variables

注意您必须设置I_MPI_TUNING_MODE以使用任何I_MPI_TUNING_AUTO系列环境变量。注意I_MPI_TUNING_AUTO系列环境变量仅支持 Intel 处理器,不能在其他平台上使用。

I_MPI_TUNING_AUTO_STORAGE_SIZE定义每个通信器调整存储的大小。设置此环境变量以更改通信器调整存储的大小。

1
I_MPI_TUNING_AUTO_STORAGE_SIZE=<size>

<size>指定通信器调优存储的大小。 存储的默认大小为 512 Kb。

I_MPI_TUNING_AUTO_ITER_NUM设置此环境变量以指定自动调优器迭代次数。 更大的迭代次数会产生更准确的结果。

1
I_MPI_TUNING_AUTO_ITER_NUM=<number>

<number>定义迭代次数。 默认情况下,它是 1。

注意要检查是否所有可能的算法都被迭代,请确保目标应用程序中特定消息大小的集合调用总数至少等于I_MPI_TUNING_AUTO_ITER_NUM的值乘以算法数

I_MPI_TUNING_AUTO_WARMUP_ITER_NUM指定预热自动调优器迭代次数。

1
I_MPI_TUNING_AUTO_WARMUP_ITER_NUM=<number>

<number>定义迭代次数。 默认情况下,它是 1。

设置此环境变量以指定自动调优器预热迭代的次数。 预热迭代不会影响自动调优器的决策,并允许跳过额外的迭代,例如基础设施准备。

I_MPI_TUNING_AUTO_SYNC在自动调优器的每次迭代中启用内部屏障。

1
I_MPI_TUNING_AUTO_SYNC=<arg>

  • enable | yes | on | 1 将自动调优器与 IMB 测量方法对齐。
  • disable | no | off | 0 不要在自动调优器的每次迭代中使用屏障。这是默认值。

设置此环境变量以控制 IMB 测量逻辑。 由于额外的MPI_Barrier调用,将此变量设置为 1 可能会导致开销。

I_MPI_TUNING_AUTO_COMM_DEFAULT用默认值标记所有通信器。

1
I_MPI_TUNING_AUTO_COMM_DEFAULT=<arg>

  • enable | yes | on | 1
  • disable | no | off | 0

设置此环境变量以使用默认值标记应用程序中的所有通信器。 在这种情况下,所有通信器将具有相同的默认 comm_id 等于 -1。

Process Pinning

使用此功能将特定 MPI 进程固定到节点内的相应 CPU 集,并避免意外的进程迁移。此功能在提供必要内核接口的操作系统上可用。此页面描述了固定过程。您可以使用 Intel MPI 库的 Pinning Simulator 模拟您的 pinning 配置。

处理器标识

以下方案用于识别系统中的逻辑处理器:

  • 系统定义的逻辑枚举
  • 通过三元组(包/套接字、内核、线程)基于三级分层标识的拓扑枚举

一个逻辑 CPU 的编号定义为该 CPU 位在内核关联位掩码中的对应位置。使用随英特尔 MPI 库安装提供的 cpuinfo 实用程序或 cat /proc/cpuinfo 命令找出逻辑 CPU 编号。三级分层标识使用提供有关处理器位置及其顺序的信息的三元组。三元组按层次排序(包、核心和线程)。请参阅一个可能的处理器编号示例,其中有两个socket、四个内核(每个socket两个内核)和八个逻辑处理器(每个内核两个处理器)。

注:逻辑枚举和拓扑枚举不同。

Default Settings

如果您没有为任何进程固定环境变量指定值,则使用下面的默认设置。 有关这些设置的详细信息,请参阅环境变量和与 OpenMP API 的互操作性。

  • I_MPI_PIN=on
  • I_MPI_PIN_RESPECT_CPUSET=on
  • I_MPI_PIN_RESPECT_HCA=on
  • I_MPI_PIN_CELL=unit
  • I_MPI_PIN_DOMAIN=auto:compact
  • I_MPI_PIN_ORDER=bunch

Environment Variables for Process Pinning

I_MPI_PIN打开/关闭进程固定。设置此环境变量以控制英特尔® MPI 库的进程固定功能。

1
I_MPI_PIN=<arg>

  • enable | yes | on | 1 启用进程固定。 这是默认值。
  • disable | no | off | 0 禁用进程固定。

I_MPI_PIN_PROCESSOR_LIST (I_MPI_PIN_PROCS)定义处理器子集和该子集中的 MPI 进程的映射规则。

1
I_MPI_PIN_PROCESSOR_LIST=<value>

环境变量值具有三种语法形式:

  1. <proclist>
  2. [<procset> ][:[grain=<grain> ][,shift=<shift> ][,preoffset=<preoffset> ] [,postoffset=<postoffset> ]
  3. [<procset> ][:map=<map> ]

以下段落详细描述了这些语法形式的值。注意 postoffset 关键字具有偏移别名。注意固定过程的第二种形式包括三个步骤:

  1. 源处理器列表在preoffset*grain值上的循环移位。
  2. shift*grain值的第一步中导出的列表的循环移位。
  3. postoffset*grain值上的第二步导出的列表的循环移位。

注意grainshiftpreoffsetpostoffset参数具有统一的定义样式。此环境变量可用于 Intel 和非 Intel 微处理器,但它对 Intel 微处理器执行的优化可能比对非 Intel 微处理器执行的优化多。

语法 1

1
I_MPI_PIN_PROCESSOR_LIST=<proclist>

  • <proclist>以逗号分隔的逻辑处理器编号和/或处理器范围列表。 具有第 i 个等级的进程被固定到列表中的第 i 个处理器。 该数量不应超过节点上的处理器数量。
    • <l> 具有逻辑编号<l>的处理器。
    • <l>-<m> 具有从<l><m>的逻辑编号的处理器范围。
    • <k>,<l>-<m> 处理器<k>,以及<l><m>

语法 2

1
2
I_MPI_PIN_PROCESSOR_LIST=[<procset>][:[grain=<grain>][,shift=<shift>]
[,preoffset=<preoffset>][,postoffset=<postoffset>]

  • <procset>根据拓扑编号指定处理器子集。 默认值为 allcores。
    • all所有逻辑处理器。 指定此子集以定义节点上的 CPU 数量。
    • allcores所有内核(物理 CPU)。 指定此子集以定义节点上的核心数。 这是默认值。
    • allsocks 所有包/socket。 指定此子集以定义节点上的套接字数。
  • <grain>为定义的<procset>指定固定粒度单元格。 最小的<grain>值是<procset>的单个元素。 最大<grain>值是套接字中<procset>元素的数量。<grain>值必须是<procset>值的倍数。 否则,假定最小<grain>值。默认值是最小的<grain>值。
  • <shift><procset>指定单元的循环调度班次的粒度。<shift>以定义的<grain>单位测量。<shift>值必须是正整数。 否则,不执行移位。 默认值为无移位,等于 1 个正常增量。
  • <preoffset>指定在循环移位之前定义的处理器子集<procset>的循环移位<preoffset>值。 该值以定义的<grain>单位测量。<preoffset>值必须是非负整数。 否则,不执行移位。 默认值为无移位。
  • <postoffset> 指定在<postoffset>值上循环移位后派生的处理器子集<procset>的循环移位。 该值以定义的<grain>单位测量。<postoffset>值必须是非负整数。 否则不执行移位。 默认值为无移位。

下表显示了<grain><shift><preoffset><postoffset>选项的值:

  • <n>指定相应参数的显式值。<n>是非负整数
    • fine指定相应参数的最小值。
    • core指定参数值等于一个核中包含的相应参数单元的数量。
    • cache1指定的参数值等于共享一级缓存的相应参数单元的数量。
    • cache2指定的参数值等于共享一个 L2 缓存的相应参数单元的数量。
    • cache3指定的参数值等于共享一个 L3 缓存的相应参数单元的数量。
    • cache:cache1、cache2 和cache3 中的最大值。
    • socket|sock指定的参数值等于一个物理包/套接字中包含的相应参数单元的数量。
    • half|mid指定等于socket/2的参数值。
    • third指定等于socket/3的参数值。
    • quarter指定等于socket/4的参数值。
    • octavo指定等于 socket/8 的参数值。

语法 3

1
I_MPI_PIN_PROCESSOR_LIST=[<procset>][:map=<map>]

  • <map>用于进程放置的映射模式。
    • bunch 进程被映射到尽可能接近的套接字上。
    • scatter 进程尽可能远程映射,以免共享公共资源:FSB、缓存和核心。
    • spread 进程被连续映射,可能不共享公共资源。

设置I_MPI_PIN_PROCESSOR_LIST环境变量以定义处理器放置。 为避免与不同 shell 版本冲突,环境变量值可能需要用引号括起来。 注意 此环境变量仅在启用I_MPI_PIN时有效。

I_MPI_PIN_PROCESSOR_LIST环境变量具有以下不同的语法变体:

  • 显式处理器列表。 这个逗号分隔的列表是根据逻辑处理器编号定义的。 进程的相对节点rank是处理器列表的索引,因此第 i 个进程固定在第 i 个列表成员上。 这允许在 CPU 上定义任何进程放置。例如,I_MPI_PIN_PROCESSOR_LIST=p0,p1,p2,...,pn的进程映射如下:
1
2
Rank on a node  0  1  2  ... n-1  N
Logical CPU p0 p1 p2 ... pn-1 Pn
  • grain/shift/offset mapping. 此方法提供沿处理器列表的定义粒度的循环移位,步长等于shift*grain,最后在offset*grain上进行单次移位。 这种换档动作是重复换档次数。 例如:grain = 2个逻辑处理器,shift = 3 个grainoffset = 0

  • Predefined mapping scenario. 在这种情况下,流行的进程固定方案被定义为在运行时可选择的关键字。 有两种这样的场景:bunch and scatter.

在bunch场景中,进程尽可能按比例映射到套接字。 这种映射对于部分处理器加载是有意义的。 在这种情况下,进程数小于处理器数。

在scatter场景中,进程尽可能远程映射,以免共享公共资源:FSB、缓存和内核。

要将进程全局固定到每个节点上的 CPU0 和 CPU3,请使用以下命令:

1
$ mpirun -genv I_MPI_PIN_PROCESSOR_LIST=0,3 -n <number-of-processes><executable>

要将进程单独固定到每个节点上的不同 CPU(主机 1 上的 CPU0 和 CPU3,主机 2 上的 CPU0、CPU1 和 CPU3),请使用以下命令:

1
2
$ mpirun -host host1 -env I_MPI_PIN_PROCESSOR_LIST=0,3 -n <number-of-processes> <executable> : \
-host host2 -env I_MPI_PIN_PROCESSOR_LIST=1,2,3 -n <number-of-processes> <executable>

要打印有关进程固定的额外调试信息,请使用以下命令:

1
2
3
$ mpirun -genv I_MPI_DEBUG=4 -m -host host1 \
-env I_MPI_PIN_PROCESSOR_LIST=0,3 -n <number-of-processes> <executable> :\
-host host2 -env I_MPI_PIN_PROCESSOR_LIST=1,2,3 -n <number-of-processes> <executable>

注意如果进程数大于用于固定的 CPU 数,则进程列表将环绕到处理器列表的开头。

I_MPI_PIN_PROCESSOR_EXCLUDE_LIST定义要为预期主机上的固定进程的功能排除的逻辑处理器子集。

1
I_MPI_PIN_PROCESSOR_EXCLUDE_LIST=<proclist>

  • <proclist>以逗号分隔的逻辑处理器编号和/或处理器范围列表。
    • <l> 具有逻辑编号<l>的处理器。
    • <l>-<m> 具有从<l><m>的逻辑编号的处理器范围。
    • <k>,<l>-<m> 处理器<k>,以及<l><m>

设置此环境变量以定义英特尔® MPI 库不用于在预期主机上固定功能的逻辑处理器。 逻辑处理器在/proc/cpuinfo中编号。

I_MPI_PIN_CELL设置此环境变量以定义固定分辨率粒度。I_MPI_PIN_CELL指定 MPI 进程运行时分配的最小处理器单元。

1
I_MPI_PIN_CELL=<cell>

  • <cell>指定分辨率粒度
    • unit基本处理器单元(逻辑 CPU)
    • core 物理处理器内核

设置此环境变量以定义进程运行时使用的处理器子集。 您可以从两种场景中进行选择:

  • 节点中所有可能的 CPU(单位值)
  • 一个节点中的所有核心(核心价值)

环境变量对两种固定类型都有影响:

  • 通过I_MPI_PIN_PROCESSOR_LIST环境变量一对一固定
  • 通过I_MPI_PIN_DOMAIN环境变量进行一对多固定

默认值规则是:

  • 如果您使用I_MPI_PIN_DOMAIN,则单元格粒度为unit。
  • 如果您使用I_MPI_PIN_PROCESSOR_LIST,则以下规则适用:
    • 当进程数大于核数时,单元粒度为unit。
    • 当进程数等于或小于核心数时,单元粒度为core。

注意内核值不受系统中英特尔® 超线程技术的启用/禁用的影响。

I_MPI_PIN_RESPECT_CPUSET以进程亲和掩码为准,这里的尊重可能说的是怎样设置mask的优先级高

1
I_MPI_PIN_RESPECT_CPUSET=<value>

  • enable | yes | on | 1 尊重进程亲和掩码。 这是默认值。
  • disable | no | off | 0 不尊重进程关联掩码。

  • 如果您设置I_MPI_PIN_RESPECT_CPUSET=enable,Hydra 进程启动器在每个预期主机上使用作业管理器的进程关联掩码来确定应用英特尔 MPI 库固定功能的逻辑处理器。

  • 如果您设置I_MPI_PIN_RESPECT_CPUSET=disable,Hydra 进程启动器将使用其自己的进程关联掩码来确定应用英特尔 MPI 库固定功能的逻辑处理器。

Interoperability with OpenMP* API

I_MPI_PIN_DOMAIN:英特尔® MPI 库提供了一个额外的环境变量来控制混合 MPI/OpenMP* 应用程序的进程固定。该环境变量用于定义节点上逻辑处理器的多个非重叠子集(域),以及一组关于 MPI 进程如何通过以下公式绑定到这些域的规则:每个domain一个 MPI 进程。 见下图。

每个 MPI 进程可以创建多个子线程以在相应的域中运行。 进程线程可以自由地从一个逻辑处理器迁移到特定域内的另一个。

  • 如果定义了I_MPI_PIN_DOMAIN环境变量,则忽略I_MPI_PIN_PROCESSOR_LIST环境变量设置。
  • 如果未定义I_MPI_PIN_DOMAIN环境变量,则根据I_MPI_PIN_PROCESSOR_LIST环境变量的当前值固定 MPI 进程。

I_MPI_PIN_DOMAIN环境变量具有以下语法形式:

  • 通过多核术语<mc-shape>的域描述
  • 通过域大小和域成员布局的域描述<size>[:<layout>]
  • 通过位掩码<masklist>的显式域描述

下表描述了这些语法形式

  • 多核形状:I_MPI_PIN_DOMAIN=<mc-shape>
    • <mc-shape>通过多核术语定义域。
    • core 每个域由共享特定核心的逻辑处理器组成。节点上的域数等于节点上的核心数。
    • socket | sock每个域由共享特定套接字的逻辑处理器组成。一个节点上的域数等于节点上的套接字数。这是推荐值。
    • numa每个域由共享特定 NUMA 节点的逻辑处理器组成。一台机器上的域数等于机器上的 NUMA 节点数。
    • node 一个节点上的所有逻辑处理器都安排在一个域中。
    • cache1 共享特定一级缓存的逻辑处理器被安排在一个域中。
    • cache2 共享特定二级缓存的逻辑处理器被安排在一个域中。
    • cache3 共享特定的第 3 级缓存的逻辑处理器被安排在一个域中。
    • cache 选择cache1、cache2 和cache3 中最大的域。

注意如果在一台机器上禁用了 Cluster on Die,则 NUMA 节点的数量等于套接字的数量。 在这种情况下,固定I_MPI_PIN_DOMAIN = numa等效于固定I_MPI_PIN_DOMAIN = socket

  • 显式形状,I_MPI_PIN_DOMAIN=<size>[:<layout>]
  • <size>定义每个域中的逻辑处理器数量(域大小)
    • omp域大小等于OMP_NUM_THREADS环境变量值。如果未设置OMP_NUM_THREADS环境变量,则每个节点都被视为一个单独的域。
    • auto域大小由公式size=#cpu/#proc定义,其中#cpu为节点上的逻辑处理器数,#proc为节点上启动的 MPI 进程数
    • <n>域大小由正十进制数定义<n>
  • <layout>域成员的排序。默认值是紧凑的
  • platform域成员根据其 BIOS 编号(平台相关编号)进行排序
  • compact根据资源(内核、缓存、套接字等)紧凑,域成员的位置尽可能靠近彼此。这是默认值
  • scatter域成员在公共资源(核心、缓存、套接字等)方面尽可能远离彼此

  • 显式域掩码,I_MPI_PIN_DOMAIN=<掩码列表>

  • <masklist>通过逗号分隔的十六进制数字列表(域掩码)定义域
    • [m1,...,mn]对于<masklist>,每个mi是一个定义单个域的十六进制邮件位掩码。使用以下规则:如果相应的 mi 值设置为 1,则第 i 个逻辑处理器被包含在域中。所有剩余的处理器都被放入一个单独的域中。使用 BIOS 编号。

注意 为确保<masklist>中的配置被正确解析,请使用方括号将<masklist>指定的域括起来。例如:I_MPI_PIN_DOMAIN=[55,aa]

注意这些选项可用于 Intel® 和非 Intel 微处理器,但它们对 Intel 微处理器执行的优化可能比对非 Intel 微处理器执行的优化更多。要在域内固定 OpenMP* 进程或线程,应使用相应的 OpenMP 功能(例如,英特尔® 编译器的KMP_AFFINITY环境变量)。注意以下配置实际上与未应用固定相同:

  • 如果您设置I_MPI_PIN_DOMAIN=auto并且一个节点上正在运行单个进程(例如,由于I_MPI_PERHOST=1
  • I_MPI_PIN_DOMAIN=node

如果您不希望进程在多套接字平台上的套接字之间迁移,请将域大小指定为I_MPI_PIN_DOMAIN=socket或更小。您还可以使用I_MPI_PIN_PROCESSOR_LIST,它为每个rank生成一个单 CPU 进程关联掩码(应该在 IBA* HCA 存在的情况下自动调整关联掩码)。

请参阅示例中的对称多处理 (SMP) 节点的以下模型

上图代表了 SMP 节点模型,在 2 个socket上共有 8 个内核。 英特尔® 超线程技术已禁用。 相同颜色的核心对共享 L2 缓存。

Figure3

1
mpirun -n 2 -env I_MPI_PIN_DOMAIN socket ./a.out

在图 3 中,根据套接字的数量定义了两个域。 进程rank 0 可以在第 0 个套接字上的所有内核上迁移。 进程rank 1 可以在第一个socket上的所有内核上迁移。

Figure 4 mpirun -n 4 -env I_MPI_PIN_DOMAIN cache2 ./a.out

在图 4 中,根据常用 L2 缓存的数量定义了四个域。 进程rank 0 在共享 L2 缓存的内核 {0,4} 上运行。 进程rank 1 在共享 L2 缓存的内核 {1,5} 上运行,依此类推。

Figure 5 mpirun -n 2 -env I_MPI_PIN_DOMAIN 4:platform ./a.out

在图 5 中,定义了两个大小为 4 的域。 第一个域包含内核 {0,1,2,3},第二个域包含内核 {4,5,6,7}。 域成员(核心)具有由平台选项定义的连续编号。

Figure 6 mpirun -n 4 -env I_MPI_PIN_DOMAIN auto:scatter ./a.out

在图6中,域大小=2(定义为CPU数=8/进程数=4),scatter布局。 定义了四个域 {0,2}、{1,3}、{4,6}、{5,7}。 域成员不共享任何公共资源。

Figure 7 setenv OMP_NUM_THREADS=2 mpirun -n 4 -env I_MPI_PIN_DOMAIN omp:platform ./a.out

在图 7 中,域大小=2(由OMP_NUM_THREADS=2定义),platform布局。 定义了四个域 {0,1}、{2,3}、{4,5}、{6,7}。 域成员(核心)具有连续编号。

Figure 8 mpirun -n 2 -env I_MPI_PIN_DOMAIN [55,aa] ./a.out

在图 8(I_MPI_PIN_DOMAIN=<masklist>的示例)中,第一个域由 55 掩码定义。它包含所有具有偶数 {0,2,4,6} 的内核。第二个域由 AA 掩码定义。它包含所有奇数为 {1,3,5,7} 的内核。

I_MPI_PIN_ORDER设置此环境变量以定义 MPI 进程到I_MPI_PIN_DOMAIN环境变量指定的域的映射顺序。

1
I_MPI_PIN_ORDER=<订单>

  • <order>指定顺序
  • range根据处理器的 BIOS 编号对域进行排序。这是一个平台相关的编号。
  • scatter域是有序的,以便相邻域尽可能共享最少的公共资源。
  • compact 域是有序的,以便相邻域尽可能共享公共资源。
  • spread 域是连续排序的,可能不共享公共资源。
  • bunch 进程按比例映射到套接字,域在套接字上的排序尽可能接近。这是默认值。

此环境变量的最佳设置是特定于应用程序的。如果相邻的 MPI 进程更喜欢共享公共资源,例如核心、缓存、套接字、FSB,请使用compact或bunch。否则,使用scatter或spread。根据需要使用范围值。有关这些值的详细信息和示例,请参阅本主题中I_MPI_PIN_ORDER的参数表和示例部分。选项scattercompactspreadbunch可用于Intel® 和非Intel 微处理器,但它们对Intel 微处理器执行的优化可能比对非Intel 微处理器执行的优化多。

对于以下配置:

  • 具有四个内核的两个socket节点和用于相应内核对的共享 L2 缓存。
  • 您希望使用以下设置在节点上运行的 4 个 MPI 进程。

Compact order:I_MPI_PIN_DOMAIN=2 I_MPI_PIN_ORDER=compact

Scatter order:I_MPI_PIN_DOMAIN=2 I_MPI_PIN_ORDER=scatter

Spread order:I_MPI_PIN_DOMAIN=2 I_MPI_PIN_ORDER=spread
注意对于I_MPI_PIN_ORDER=spread,如果没有足够的 CPU 来放置所有域,则顺序将切换为“compact”。

Bunch order:I_MPI_PIN_DOMAIN=2 I_MPI_PIN_ORDER=bunch

GPU 支持

除了 GPU 固定之外,英特尔 MPI 库还支持 GPU 缓冲区(见下文)。

GPU 固定

使用此功能在 MPI rank之间分配 Intel GPU 设备。要启用此功能,请设置I_MPI_OFFLOAD_TOPOLIB=level_zero。 此功能要求在节点上安装 LevelZero* 库。 设备固定信息在I_MPI_DEBUG=3处的英特尔 MPI 调试输出中打印出来。默认设置:

  • I_MPI_OFFLOAD_CELL=tile
  • I_MPI_OFFLOAD_DOMAIN_SIZE=-1
  • I_MPI_OFFLOAD_DEVICES=all

默认情况下,所有可用资源都在 MPI rank 之间尽可能平均地分配给给定的 rank 位置; 即资源的分配考虑了rank和资源位于哪个NUMA节点上。 理想情况下,rank将仅在等级所在的同一 NUMA 节点上拥有资源。下面的所有示例都代表具有两个 NUMA 节点和两个带有两个图块的 GPU 的机器配置。图 1显示了四个 MPI 等级

Debug output I_MPI_DEBUG=3:

1
2
3
4
5
6
[0] MPI startup(): ===== GPU pinning on host1 =====
[0] MPI startup(): Rank Pin tile
[0] MPI startup(): 0 {0}
[0] MPI startup(): 1 {1}
[0] MPI startup(): 2 {2}
[0] MPI startup(): 3 {3}

fabric控制的环境变量

通信结构控制I_MPI_FABRICS选择要使用的特定fabric。

1
I_MPI_FABRICS=ofi | shm:ofi | shm

  • <fabric>定义网络结构。
    • shm共享内存传输(仅用于节点内通信)。
    • ofi OpenFabrics 接口 (OFI) 网络结构,例如英特尔® True Scale Fabric、英特尔® Omni-Path 架构、InfiniBand 和以太网(通过 OFI API)。

设置此环境变量以选择特定的结构组合。常规模式的默认值为shm:ofi,多端点模式的默认值为ofi。在多端点模式下,默认值ofi无法更改。

此选项不适用于 slurm 和 pdsh 引导服务器。不推荐使用 DAPL、TMI 和 OFA 结构。

共享内存控制I_MPI_SHM选择要使用的共享内存传输。

1
I_MPI_SHM=<transport>

  • <transport>定义共享内存传输解决方案。
    • disable | no | off | 0不要使用共享内存传输。
    • auto自动选择共享内存传输解决方案。
    • bdw_sse共享内存传输解决方案针对英特尔® 微架构代号 Broadwell 进行了调整。 SSE4.2。使用指令集。
    • bdw_avx2共享内存传输解决方案针对英特尔® 微架构代号 Broadwell 进行了调整。使用了 AVX2 指令集。
    • skx_sse共享内存传输解决方案针对基于英特尔® 微架构代号 Skylake 的英特尔® 至强® 处理器进行了调整。使用了 CLFLUSHOPT 和 SSE4.2 指令集。
    • skx_avx2共享内存传输解决方案针对基于英特尔® 微架构代号 Skylake 的英特尔® 至强® 处理器进行了调整。使用了 CLFLUSHOPT 和 AVX2 指令集。
    • skx_avx512共享内存传输解决方案针对基于英特尔® 微架构代号 Skylake 的英特尔® 至强® 处理器进行了调整。使用了 CLFLUSHOPT 和 AVX512 指令集。
    • knl_ddr共享内存传输解决方案针对英特尔® 微架构代号 Knights Landing 进行了调整。
    • knl_mcdram共享内存传输解决方案针对英特尔® 微架构代号 Knights Landing 进行了调整。共享内存缓冲区可能部分位于多通道 DRAM (MCDRAM) 中。
    • clx_sse共享内存传输解决方案针对基于英特尔® 微架构代号 Cascade Lake 的英特尔® 至强® 处理器进行了调整。使用了 CLFLUSHOPT 和 SSE4.2 指令集。
    • clx_avx2共享内存传输解决方案针对基于英特尔® 微架构代号 Cascade Lake 的英特尔® 至强® 处理器进行了调整。使用了 CLFLUSHOPT 和 AVX2 指令集。
    • clx_avx512共享内存传输解决方案针对基于英特尔® 微架构代号 Cascade Lake 的英特尔® 至强® 处理器进行了调整。使用了 CLFLUSHOPT 和 AVX512 指令集。
    • clx-ap共享内存传输解决方案针对基于英特尔® 微架构代号 Cascade Lake Advanced Performance 的英特尔® 至强® 处理器进行了调整。
    • icx共享内存传输解决方案针对基于英特尔® 微架构代号 Ice Lake 的英特尔® 至强® 处理器进行了优化。

设置此环境变量以选择特定的共享内存传输解决方案。

自动选择的传输:

  • icx用于基于英特尔® 微架构的英特尔® 至强® 处理器,代号为 Ice Lake
  • clx-ap用于基于英特尔® 微架构的英特尔® 至强® 处理器,代号为 Cascade Lake Advanced Performance
  • bdw_avx2用于英特尔® 微架构代号 Haswell、Broadwell 和 Skylake
  • skx_avx2用于基于英特尔® 微体系结构代号 Skylake 的英特尔® 至强® 处理器
  • ckx_avx2用于基于英特尔® 微体系结构代号 Cascade Lake 的英特尔® 至强® 处理器
  • knl_mcdram用于英特尔® 微架构,代号为 Knights Landing 和 Knights Mill
  • bdw_sse适用于所有其他平台

I_MPI_SHM的值取决于I_MPI_FABRICS的值,如下所示:如果I_MPI_FABRICSofi,则I_MPI_SHM被禁用。如果I_MPI_FABRICSshm:ofi,则I_MPI_SHM默认为auto或采用指定值。

I_MPI_SHM_CELL_FWD_SIZE更改共享内存前向单元的大小。

1
I_MPI_SHM_CELL_FWD_SIZE=<nbytes>

  • <nbytes>共享内存转发单元的大小(以字节为单位)
  • > 0 默认的<nbytes>值取决于所使用的传输方式,通常应该在 64K 到 1024K 的范围内。

转发单元是用于发送少量数据的缓存中消息缓冲区单元。建议使用较低的值。设置此环境变量以定义共享内存传输中前向单元的大小。

I_MPI_SHM_CELL_BWD_SIZE更改共享内存后向单元的大小。

1
I_MPI_SHM_CELL_BWD_SIZE=<nbytes>

  • <nbytes>共享内存后向单元的大小(以字节为单位)
  • > 0默认的<nbytes>值取决于所使用的传输方式,通常应该在 64K 到 1024K 的范围内。

后向单元是用于发送大量数据的缓存外消息缓冲区单元。建议使用更高的值。设置此环境变量以定义共享内存传输中 backwrad 单元的大小。

I_MPI_SHM_CELL_EXT_SIZE更改共享内存扩展单元的大小。

1
I_MPI_SHM_CELL_EXT_SIZE=<nbytes>

  • <nbytes>共享内存扩展单元的大小(以字节为单位)
  • > 0默认的<nbytes>值取决于所使用的传输方式,通常应该在 64K 到 1024K 的范围内。

当前向和后向单元用完时,扩展信元用于不平衡的应用中。扩展单元没有特定的所有者——它在计算节点上的所有等级之间共享。设置此环境变量以定义共享内存传输中扩展单元的大小。

I_MPI_SHM_CELL_FWD_NUM更改共享内存传输中前向单元的数量(每列)。设置此环境变量以定义共享内存传输中的前向单元数。

1
I_MPI_SHM_CELL_FWD_NUM=<num>

  • <num>共享内存转发单元的数量
  • > 0默认值取决于所使用的传输,通常应在从 4 到 16范围内。

I_MPI_SHM_CELL_BWD_NUM更改共享内存传输中的后向单元数(每列)。设置此环境变量以定义共享内存传输中向后单元的数量。

1
I_MPI_SHM_CELL_BWD_NUM=<num>

  • <num>共享内存后向单元的数量
  • > 0默认值取决于所使用的传输,通常应在从 4 到 64范围内。

I_MPI_SHM_CELL_EXT_NUM_TOTAL更改共享内存传输中扩展单元的总数。设置此环境变量以定义共享内存传输中扩展单元的数量。

1
I_MPI_SHM_CELL_EXT_NUM_TOTAL=<num>

  • <num>共享内存后向单元的数量
  • > 0默认值取决于所使用的传输方式,通常范围为 2K 到 8K。

I_MPI_SHM_CELL_FWD_HOLD_NUM更改共享内存传输中的保留单元数量(每列)。

1
I_MPI_SHM_CELL_FWD_HOLD_NUM=<num>

  • <num>共享内存保持前向单元的数量
  • > 0默认值取决于使用的传输并且必须小于

I_MPI_SHM_CELL_FWD_NUM设置此环境变量以定义一个 rank 可以同时容纳的共享内存传输中的前向单元格数。推荐值是 1 到 8 范围内的 2 的幂。

I_MPI_SHM_MCDRAM_LIMIT更改绑定到多通道 DRAM (MCDRAM) 的共享内存的大小(每列大小)。设置此环境变量以定义共享内存传输允许每列使用多少 MCDRAM 内存。此变量仅在I_MPI_SHM=knl_mcdram时生效。

1
I_MPI_SHM_MCDRAM_LIMIT=<nbytes>

  • <nbytes>每列绑定到 MCDRAM 的共享内存的大小,1048576 这是默认值。

I_MPI_SHM_SEND_SPIN_COUNT控制用于发送消息的共享内存传输的自旋计数值。

1
I_MPI_SHM_SEND_SPIN_COUNT=<计数>

  • <count>定义旋转计数值。典型值范围在 1 到 1000 之间。

如果接收方入口缓冲区已满,则发送方可能会被阻塞,直到达到此旋转计数值。发送小消息时不起作用。

I_MPI_SHM_RECV_SPIN_COUNT控制用于接收消息的共享内存传输的自旋计数值。

1
I_MPI_SHM_RECV_SPIN_COUNT=<计数>

  • <count>定义旋转计数值。典型值范围在 1 到 1000000 之间。

如果接收是非阻塞的,则此自旋计数仅用于预期和意外消息的安全重新排序。它对接收小消息没有影响。

I_MPI_SHM_FILE_PREFIX_4K更改创建共享内存文件的 4 KB 页大小文件系统 (tmpfs) 的安装点。

1
I_MPI_SHM_FILE_PREFIX_4K=<path>

  • <path>定义 4 KB 页大小文件系统 (tmpfs) 的现有挂载点的路径。默认情况下,未设置路径。

设置此环境变量以定义共享内存文件的新路径。默认情况下,共享内存文件创建在/dev/shm/。此变量影响共享内存传输缓冲区和 RMA 窗口。

1
I_MPI_SHM_FILE_PREFIX_4K=/dev/shm/intel/

I_MPI_SHM_FILE_PREFIX_2M更改创建共享内存文件的 2 MB 页大小文件系统 (hugetlbfs) 的挂载点。

1
I_MPI_SHM_FILE_PREFIX_2M=<路径>

  • <path>定义 2 MB 页大小的文件系统 (hugetlbfs) 的现有挂载点的路径。默认情况下,未设置路径。

设置此环境变量以在英特尔 MPI 库上启用 2 MB 大页面。该变量影响共享内存传输缓冲区。如果窗口大小大于或等于 2 MB,它也可能影响 RMA 窗口。

1
I_MPI_SHM_FILE_PREFIX_2M=/dev/hugepages

配置大页面子系统需要 root 权限。请联系您的系统管理员以获得许可。

I_MPI_SHM_FILE_PREFIX_1G更改创建共享内存文件的 1 GB 页大小文件系统 (hugetlbfs) 的挂载点。

1
I_MPI_SHM_FILE_PREFIX_1G=<路径>

  • <path>定义 1 GB 页面大小文件系统 (hugetlbfs) 的现有挂载点的路径。默认情况下,未设置路径。

设置此环境变量以在英特尔 MPI 库上启用 1 GB 大页面。该变量影响共享内存传输缓冲区。如果窗口大小大于或等于 1 GB,它也可能影响 RMA 窗口。

1
I_MPI_SHM_FILE_PREFIX_1G=/dev/hugepages1G

配置大页面子系统需要 root 权限。 请联系您的系统管理员以获得许可。

OFI*-capable Network Fabrics Control

I_MPI_OFI_PROVIDER定义要加载的 OFI 提供程序的名称。

1
I_MPI_OFI_PROVIDER=<name>

  • <name>要加载的 OFI 提供程序的名称

设置此环境变量以定义要加载的 OFI 提供程序的名称。 如果不指定此变量,OFI 库会自动选择提供者。 您可以使用I_MPI_OFI_PROVIDER_DUMP环境变量检查所有可用的提供程序。 如果您为可用的提供程序设置了错误的名称,请使用FI_LOG_LEVEL=debug获取正确设置名称的提示。

I_MPI_OFI_PROVIDER_DUMP控制从 OFI 库打印有关所有 OFI 提供者及其属性的信息的能力。设置此环境变量以控制从 OFI 库打印有关所有 OFI 提供程序及其属性的信息的能力。

1
I_MPI_OFI_PROVIDER_DUMP=<arg>

  • <arg>二元指标
    • enable | yes | on | 1 从 OFI 库打印所有 OFI 提供商及其属性的列表
    • disable | no | off | 0 没有行动。 这是默认值

I_MPI_OFI_DRECV控制 OFI 结构中直接接收的能力。

1
I_MPI_OFI_DRECV=<arg>

  • <arg>二元指标
    • enable | yes | on | 1 Enable direct receive. This is the default value
    • disable | no | off | 0 禁用直接接收

使用直接接收功能仅阻止MPI_Recv调用。 在使用直接接收功能之前,请确保将其用于单线程 MPI 应用程序,并通过设置I_MPI_FABRICS=ofi检查是否已选择 OFI 作为网络结构。

I_MPI_OFI_LIBRARY_INTERNAL控制随英特尔® MPI 库提供的 libfabric* 的使用。设置此环境变量以禁用或启用英特尔 MPI 库中的 libfabric。 必须在获取vars.[c]sh脚本之前设置该变量。

1
I_MPI_OFI_LIBRARY_INTERNAL=<arg>

  • <arg>二元指标
    • enable | yes | on | 1 使用英特尔 MPI 库中的 libfabric
    • disable | no | off | 0 不要使用来自英特尔 MPI 库的 libfabric
1
2
$ export I_MPI_OFI_LIBRARY_INTERNAL=1
$ source <installdir> /bin/vars.sh

Environment Variables for Memory Policy Control

英特尔® MPI 库支持在英特尔® 至强融核™ 处理器(代号为 Knights Landing)上具有高带宽 (HBW) 内存 (MCDRAM) 的非统一内存访问 (NUMA) 节点。 英特尔® MPI 库可以将 MPI 进程的内存附加到特定 NUMA 节点的内存。 本节描述了这种内存布局控制的环境变量。

I_MPI_HBW_POLICY设置使用 HBW 内存的 MPI 进程内存放置策略。

1
I_MPI_HBW_POLICY=<user memory policy>[,<mpi memory policy>][,<win_allocate policy>]

在语法中:

  • <user memory policy> - 用于为用户应用程序分配内存的内存策略(必需)
  • <mpi memory policy> - 用于分配内部 MPI 内存的内存策略(可选)
  • <win_allocate policy> - 用于为 RMA 操作的窗口段分配内存的内存策略(可选)

每个列出的策略可能具有以下值:

  • <value>使用的内存分配策略。
    • hbw_preferred 为每个进程分配本地 HBW 内存。 如果 HBW 内存不可用,则分配本地动态随机存取内存。
    • hbw_bind 只为每个进程分配本地 HBW 内存。
    • hbw_interleave 以循环方式在本地节点上分配 HBW 内存和动态随机存取内存。

使用此环境变量为具有 HBW 内存的机器上的 MPI 进程内存放置指定策略。 默认情况下,英特尔 MPI 库为本地 DDR 中的进程分配内存。 仅当您指定I_MPI_HBW_POLICY变量时,才能使用 HBW 内存。

以下示例演示了不同的内存布局配置:

  • I_MPI_HBW_POLICY=hbw_bind,hbw_preferred,hbw_bind
    仅将在用户应用程序和窗口段中分配的本地 HBW 内存用于 RMA 操作。 首先使用英特尔® MPI 库中内部分配的本地 HBW 内存。 如果 HBW 内存不可用,请使用英特尔 MPI 库中内部分配的本地 DDR。
  • I_MPI_HBW_POLICY=hbw_bind,,hbw_bind
    • 仅将在用户应用程序和窗口段中分配的本地 HBW 内存用于 RMA 操作。 使用英特尔 MPI 库中内部分配的本地 DDR。
  • I_MPI_HBW_POLICY=hbw_bind,hbw_preferred
    • 仅使用在用户应用程序中分配的本地 HBW 内存。 首先使用英特尔 MPI 库中内部分配的本地 HBW 内存。 如果 HBW 内存不可用,请使用英特尔 MPI 库中内部分配的本地 DDR。 将窗口段中分配的本地 DDR 用于 RMA 操作。

I_MPI_BIND_NUMA为内存分配设置 NUMA 节点。设置此环境变量以指定内存分配过程中涉及的 NUMA 节点集。

1
I_MPI_BIND_NUMA=<value>

  • <value> 指定用于内存分配的 NUMA 节点。
    • localalloc 在本地节点上分配内存。 这是默认值。
    • Node_1,…,Node_k在指定的 NUMA 节点上根据I_MPI_BIND_ORDER分配内存。

I_MPI_BIND_ORDER设置这个环境变量来定义内存分配方式。设置该环境变量来定义I_MPI_BIND_NUMA中指定的NUMA 节点之间的内存分配方式。 如果未设置I_MPI_BIND_NUMA,该变量无效。

1
I_MPI_BIND_ORDER=<value>

  • <value>指定分配方式。
    • compactI_MPI_BIND_NUMA中指定的 NUMA 节点中,为尽可能接近的进程分配内存(就 NUMA 节点而言)。 这是默认值。
    • scatter使用循环方式在I_MPI_BIND_NUMA中指定的 NUMA 节点之间分配内存。

I_MPI_BIND_WIN_ALLOCATE设置此环境变量以控制窗口段的内存分配。

1
I_MPI_BIND_WIN_ALLOCATE=<value>

  • <value>指定窗口段的内存分配行为。
    • localalloc在本地节点上分配内存。 这是默认值。
    • hbw_preferred 为每个进程分配本地 HBW 内存。 如果 HBW 内存不可用,则分配本地动态随机存取内存。
    • hbw_bind只为每个进程分配本地 HBW 内存。
    • hbw_interleave以循环方式在本地节点上分配HBW内存和动态随机存取内存。
    • <NUMA node id>在给定的 NUMA 节点上分配内存。

设置此环境变量以在MPI_Win_allocate_sharedMPI_Win_allocate函数的帮助下创建在 HBW 内存中分配的窗口段。

MPI_Info

您可以借助MPI_Info对象控制窗口段的内存分配,该对象作为参数传递给MPI_Win_allocateMPI_Win_allocate_shared函数。在应用程序中,如果使用numa_bind_policy键指定这样的对象,则根据numa_bind_policy的值分配窗口段。可能的值与I_MPI_BIND_WIN_ALLOCATE的相同。

演示MPI_Info使用的代码片段:

1
2
3
4
5
6
MPI_Info info;
...
MPI_Info_create( &info );
MPI_Info_set( info, "numa_bind_policy", "hbw_preferred" );
...
MPI_Win_allocate_shared( size, disp_unit, info, comm, &baseptr, &win );

当您为窗口段指定内存放置策略时,英特尔 MPI 库根据以下优先级识别配置:

  1. MPI_Info的设置。
  2. I_MPI_HBW_POLICY的设置,如果你指定了<win_allocate policy>
  3. I_MPI_BIND_WIN_ALLOCATE的设置。

Environment Variables for Asynchronous Progress Control

release_mtdebug_mt库配置支持此功能。要指定配置,请运行以下命令:

1
$ source <installdir>/bin/vars.sh release_mt

I_MPI_ASYNC_PROGRESS控制进程线程的使用。

1
I_MPI_ASYNC_PROGRESS=<arg>

  • <arg>二元指标
    • disable | no | off | 0 禁用每个进程的异步进度线程。 这是默认值。
    • enable | yes | on | 1 启用进度线程。

设置此环境变量以启用异步进度。 如果禁用,则忽略·I_MPI_ASYNC_PROGRESS_*`。

I_MPI_ASYNC_PROGRESS_THREADS控制进度线程的数量。设置此环境变量以控制每个rank的进度线程。

1
I_MPI_ASYNC_PROGRESS_THREADS=<arg>

  • <nthreads> 定义进度线程的数量。 默认值为 1。

I_MPI_ASYNC_PROGRESS_PIN控制异步进度线程固定。设置此环境变量以控制本地进程的所有进程线程的固定。

1
I_MPI_ASYNC_PROGRESS_PIN=<arg>

  • <arg> 逗号分隔的逻辑处理器列表
  • <CPU list>将本地进程的所有进程线程固定到列出的 CPU。 默认情况下,N 个进程线程被固定到最后 N 个逻辑处理器。
1
2
I_MPI_ASYNC_PROGRESS_THREADS=3
I_MPI_ASYNC_PROGRESS_PIN=”0,1,2,3,4,5”

在每个节点有三个 MPI 进程的情况下,第一个进程的进度线程被固定到 0、1,第二个进程被固定到 2、3,第三个进程被固定到 4、5。

I_MPI_ASYNC_PROGRESS_ID_KEY设置用于显式定义通信器的进度线程 ID 的MPI_info对象键。

1
I_MPI_ASYNC_PROGRESS_ID_KEY=<arg>

  • <key>MPI_info 对象键。 默认值为thread_id。

设置此环境变量以控制用于定义通信器的进度线程 ID 的MPI_info对象键。 进度线程 id 用于进度线程之间的工作分配。 默认情况下,通过第一个进度线程通信。从计算线程的固定中排除进程线程的选定处理器。

如需更多信息和示例,请参阅英特尔® MPI 库开发人员指南的异步进度控制部分。

Environment Variables for Multi-EP

注意此功能仅支持release_mtdebug_mt库配置。要指定配置,请运行以下命令:$ source <install-dir>/bin/vars.sh release_mt

I_MPI_THREAD_SPLIT使用此环境变量来控制I_MPI_THREAD_SPLIT编程模型。

1
I_MPI_THREAD_SPLIT=<value>

  • value二元指标
    • 0 | no | off | disable禁用MPI_THREAD_SPLIT模型支持。这是默认值。
    • 1 | yes | on | enable 启用MPI_THREAD_SPLIT模型支持。

I_MPI_THREAD_RUNTIME使用此环境变量来控制线程运行时支持。

1
I_MPI_THREAD_RUNTIME=<value>

  • value线程运行时
  • generic启用运行时支持(例如,pthreads、TBB)。 如果在运行时无法检测到 OpenMP*,则这是默认值。
  • openmp启用 OpenMP 运行时支持。 如果在运行时检测到 OpenMP,则这是默认值。

I_MPI_THREAD_MAX使用此环境变量来设置每个进程同时使用的最大线程数。

1
I_MPI_THREAD_MAX=<int>

  • <int> 每个rank的最大线程数。如果I_MPI_THREAD_RUNTIME设置为openmp,则默认值为omp_get_max_threads()

I_MPI_THREAD_ID_KEY使用此环境变量设置用于显式定义逻辑线程号thread_id的 MPI 信息对象键。

1
I_MPI_THREAD_ID_KEY=<string>

  • <string>定义MPI_info对象键。 默认值为thread_id

Other Environment Variables

I_MPI_DEBUG当 MPI 程序开始运行时打印调试信息。

1
I_MPI_DEBUG=<level>[,<flags>]

  • <level>指示提供的调试信息的级别。
    • 0 不输出调试信息。 这是默认值。
    • 1,2 输出 libfabric* 版本和提供程序。
    • 3 输出有效的MPI rank、pid 和节点映射表。
    • 4 输出进程锁定信息。
    • 5 特定于英特尔® MPI 库的输出环境变量。
    • > 5 添加额外级别的调试信息。
  • <flags> 以逗号分隔的调试标志列表
    • pid 显示每个调试消息的进程 ID。
    • tid 为多线程库的每条调试消息显示线程 ID。
    • time 显示每个调试消息的时间。
    • datetime 显示每条调试消息的时间和日期。
    • host显示每条调试消息的主机名。
    • level 显示每个调试消息的级别。
    • scope 显示每个调试消息的范围。
    • line 显示每条调试消息的源代码行号。
    • file 显示每条调试消息的源文件名。
    • nofunc 不显示例程名称。
    • norank 不显示rank。
    • nousrwarn 禁止针对不当用例(例如,不兼容的控件组合)发出警告。
    • flock 同步来自不同进程或线程的调试输出。
    • nobuf 不要将缓冲 I/O 用于调试输出。

设置此环境变量以打印有关应用程序的调试信息。注意为所有rank设置相同的<level>值。您可以通过设置I_MPI_DEBUG_OUTPUT环境变量来指定调试信息的输出文件名。每个打印的行具有以下格式:

1
[<identifier>] <message>

  • <identifier>默认情况下是 MPI 进程rank。 如果在<level>编号前添加“+”号,<identifier>将采用以下格式:rank#pid@hostname。 这里,pid是 UNIX* 进程 ID,hostname是主机名。 如果添加“-”符号,则根本不打印<identifier>
  • <message>包含调试输出。

以下示例演示了可能的命令行以及相应的输出:

1
2
3
$ mpirun -n 1 -env I_MPI_DEBUG=2 ./a.out
...
[0] MPI startup(): shared memory data transfer mode

以下命令相等并产生相同的输出:

1
2
3
$ mpirun -n 1 -env I_MPI_DEBUG=2,pid,host ./a.out
...
[0#1986@mpicluster001] MPI startup(): shared memory data transfer mode

注意 使用 -g 选项进行编译会添加大量打印的调试信息。

I_MPI_DEBUG_OUTPUT设置调试信息的输出文件名。

1
I_MPI_DEBUG_OUTPUT=<arg>

  • stdout 输出到标准输出。 这是默认值。
  • stderr 输出到标准错误。
  • <file_name> 指定调试信息的输出文件名(最大文件名长度为 256 个符号)。

如果要将调试信息的输出与应用程序生成的输出分开,请设置此环境变量。 如果您使用 %r、%p 或 %h 等格式,则会相应地将rank、进程 ID 或主机名添加到文件名中。

I_MPI_DEBUG_COREDUMP在 MPI 应用程序执行期间出现故障时控制核心转储文件的生成。

1
I_MPI_DEBUG_COREDUMP=<arg>

  • enable|yes|on|1 启用核心转储文件生成。
  • disable|no|off|0 不生成核心转储文件。 默认值。

设置此环境变量以在分段错误导致终止的情况下启用核心转储文件转储。 可用于发布和调试版本。

I_MPI_STATS使用应用程序性能快照从您的应用程序收集 MPI 统计信息。

1
I_MPI_STATS=<level>

  • <level>表明收集的统计数据的级别
    • 1,2,3,4,5 指定级别以指示应用程序性能快照 (APS) 收集的 MPI 统计信息量。官方 APS 文档中提供了对级别的完整描述。

设置此变量以使用应用程序性能快照从 MPI 应用程序收集与 MPI 相关的统计信息。 该变量创建一个包含统计数据的新文件夹aps_result_<date>-<time>。 要分析收集的数据,请使用 aps 实用程序。 例如:

1
2
3
$ export I_MPI_STATS=5
$ mpirun -n 2 ./myApp
$ aps-report aps_result_20171231_235959

I_MPI_STARTUP_MODE为英特尔® MPI 库进程启动算法选择一种模式。

1
I_MPI_STARTUP_MODE=<arg>

  • pmi_shm 使用共享内存来减少 PMI 调用的次数。 默认情况下启用此模式。
  • pmi_shm_netmod 除 PMI 和共享内存外,还使用netmod基础结构进行地址交换逻辑。

pmi_shmpmi_shm_netmod模式减少了应用程序启动时间。 使用更高的-ppn值可以更清楚地观察到模式的效率,而使用-ppn 1则根本没有改善。

I_MPI_PMI_LIBRARY指定 PMI 库的第三方实现的名称。设置I_MPI_PMI_LIBRARY以指定第三方 PMI 库的名称。 设置此环境变量时,请提供库的完整名称及其完整路径。 当前支持的 PMI 版本:PMI1PMI2

1
I_MPI_PMI_LIBRARY=<name>

  • <name>第三方PMI库全称

I_MPI_PMI_VALUE_LENGTH_MAX在客户端控制 PMI 中值缓冲区的长度。

1
I_MPI_PMI_VALUE_LENGTH_MAX=<length>

  • <length>以字节为单位定义缓冲区长度的值。
  • <n> > 0 默认值为 -1,这意味着不覆盖从PMI_KVS_Get_value_length_max()函数接收的值。

设置这个环境变量来控制客户端PMI中值缓冲区的长度。 缓冲区的长度将是I_MPI_PMI_VALUE_LENGTH_MAXPMI_KVS_Get_value_length_max()中的较小者。

I_MPI_OUTPUT_CHUNK_SIZE设置stdout/stderr输出缓冲区的大小。

1
I_MPI_OUTPUT_CHUNK_SIZE=<size>

  • <size> 以千字节为单位定义输出块大小
  • <n> > 0 默认块大小值为 1 KB

设置此环境变量以增加用于拦截来自进程的标准输出和标准错误流的缓冲区的大小。 如果<size>值不大于零,则忽略环境变量设置并显示警告消息。 将此设置用于从不同进程创建大量输出的应用程序。 使用mpiexec.hydra-ordered-output选项,此设置有助于防止输出出现乱码。

注意 在执行mpiexec.hydra/mpirun命令之前,在 shell 环境中设置I_MPI_OUTPUT_CHUNK_SIZE环境变量。 不要使用-genv-env选项来设置<size>值。这些选项仅用于将环境变量传递给 MPI 进程环境。

I_MPI_REMOVED_VAR_WARNING如果设置了已删除的环境变量,则打印出警告。

1
I_MPI_REMOVED_VAR_WARNING=<arg>

  • enable | yes | on | 1 打印警告。 这是默认值
  • disable | no | off | 0 不要打印警告

I_MPI_VAR_CHECK_SPELLING如果设置了未知环境变量,则打印警告。如果设置了不受支持的环境变量,请使用此环境变量打印警告。如果删除或打印错误的环境变量,将打印警告。

1
I_MPI_VAR_CHECK_SPELLING=<arg>

  • enable | yes | on | 1 把警告打印出来。这是默认值
  • disable | no | off | 0 不要打印警告

I_MPI_LIBRARY_KIND指定英特尔MPI库配置。

1
I_MPI_LIBRARY_KIND=<value>

  • release 多线程优化库(带有全局锁)。这是默认值
  • debug 多线程调试库(带全局锁)
  • release_mt多线程优化库(线程拆分模型的每个对象锁)
  • debug_mt 多线程调试库(对于线程拆分模型具有每个对象锁)

使用此变量为vars.[c]sh脚本设置参数。 该脚本建立英特尔® MPI 库环境并使您能够指定适当的库配置。 要确保设置了所需的配置,请检查LD_LIBRARY_PATH变量。

1
$ export I_MPI_LIBRARY_KIND=debug

设置这个变量相当于直接向vars.[c]sh脚本传递一个参数:

1
$ . <installdir>/bin/vars.sh release

I_MPI_PLATFORM选择预期的优化平台。设置此环境变量以使用预定义的平台设置。 默认值是每个节点的本地平台。

1
I_MPI_PLATFORM=<platform>

  • <platform> 预期的优化平台(字符串值)
    • auto 仅用于异构运行以确定跨所有节点的适当平台。由于跨所有节点的集体操作,可能会减慢 MPI 初始化时间。
    • ivb针对英特尔® 至强® 处理器 E3、E5 和 E7 V2 系列以及以前代号为 Ivy Bridge 的其他英特尔® 架构处理器进行优化。
    • hsw针对英特尔至强处理器 E3、E5 和 E7 V3 系列以及以前代号为 Haswell 的其他英特尔® 架构处理器进行优化。
    • bdw针对英特尔至强处理器 E3、E5 和 E7 V4 系列以及以前代号为 Broadwell 的其他英特尔架构处理器进行优化。
    • knl优化英特尔® 至强融核™ 处理器和协处理器,以前代号为 Knights Landing。
    • skx针对英特尔至强处理器 E3 V5 和英特尔至强可扩展家族系列以及其他以前代号为 Skylake 的英特尔架构处理器进行优化。
    • clx针对第二代英特尔至强可扩展处理器和其他以前代号为 Cascade Lake 的英特尔® 架构处理器进行优化。
    • clx-ap针对第二代英特尔至强可扩展处理器和其他以前代号为 Cascade Lake AP 的英特尔架构处理器进行优化。注意:如果实际平台不是 Intel,则忽略显式 clx-ap 设置。

该变量可用于 Intel 和非 Intel 微处理器,但它可能对 Intel 微处理器使用比对非 Intel 微处理器使用的其他优化。 注意auto[:min]auto:maxauto:most值可能会增加 MPI 作业启动时间。

I_MPI_MALLOC控制私有内存的英特尔® MPI 库自定义分配器。

1
I_MPI_MALLOC=<arg>

  • 1 启用私有内存的英特尔 MPI 库自定义分配器。 为MPI_Alloc_mem/MPI_Free_mem使用私有内存的英特尔 MPI 自定义分配器。
  • 0 禁用私有内存的英特尔 MPI 库自定义分配器。将系统提供的内存分配器用于MPI_Alloc_mem/MPI_Free_mem

使用此环境变量来启用或禁用MPI_Alloc_mem/MPI_Free_mem专用内存的英特尔 MPI 库自定义分配器。默认情况下,I_MPI_MALLOCreleasedebug英特尔 MPI 库配置启用,而为release_mtdebug_mt配置禁用。注意如果专用内存的英特尔 MPI 库自定义分配器不支持该平台,则使用系统提供的内存分配器并忽略I_MPI_MALLOC变量。

I_MPI_SHM_HEAP控制共享内存的英特尔® MPI 库自定义分配器。

1
I_MPI_SHM_HEAP=<arg>

  • 1MPI_Alloc_mem/MPI_Free_mem使用共享内存的英特尔 MPI 自定义分配器。
  • 0 不要将共享内存的英特尔 MPI 自定义分配器用于MPI_Alloc_mem/MPI_Free_mem

使用此环境变量为MPI_Alloc_mem/MPI_Free_mem启用或禁用共享内存的英特尔 MPI 库自定义分配器。默认情况下,禁用I_MPI_SHM_HEAP。如果启用,它可以提高共享内存传输的性能,因为在这种情况下,可以只进行一次内存复制操作,而不是两次拷入/拷出内存复制操作。如果同时启用了I_MPI_SHM_HEAPI_MPI_MALLOC,则首先使用共享内存分配器。仅当所需的共享内存量不可用时才使用私有内存分配器。

默认情况下,共享内存段在/dev/shm/挂载点上的tmpfs文件系统上分配。从 Linux 内核 4.7 开始,可以在共享内存上启用透明大页面。如果使用英特尔 MPI 库共享内存堆,建议在您的系统上启用透明大页面。要在/dev/shm上启用透明大页面,请联系您的系统管理员或执行以下命令:

1
sudo mount -o remount,huge=advise /dev/shm

为了使用另一个tmpfs挂载点而不是/dev/shm/,请使用I_MPI_SHM_FILE_PREFIX_4KI_MPI_SHM_FILE_PREFIX_2MI_MPI_SHM_FILE_PREFIX_1G

注意如果您的应用程序不直接使用MPI_Alloc_mem/MPI_Free_mem,您可以通过预加载libmpi_shm_heap_proxy.so库来覆盖标准malloc/calloc/realloc/free过程:

1
2
export LD_PRELOAD=$I_MPI_ROOT/lib/libmpi_shm_heap_proxy.so
export I_MPI_SHM_HEAP=1

在这种情况下,malloc/calloc/reallocMPI_Alloc_mem的代理,freeMPI_Free_mem的代理。注意如果共享内存的英特尔 MPI 库自定义分配器不支持该平台,则I_MPI_SHM_HEAP变量将被忽略。

I_MPI_SHM_HEAP_VSIZE更改可用于英特尔 MPI 库自定义共享内存分配器的虚拟共享内存的大小(每列)。

1
I_MPI_SHM_HEAP_VSIZE=<size>

  • <size> 共享内存堆中使用的共享内存的大小(每列)(以兆字节为单位)。
  • > 0 如果为MPI_Alloc_mem/MPI_Free_mem启用共享内存堆,则默认值为 4096。

共享内存的英特尔 MPI 库自定义分配器适用于固定大小的虚拟共享内存。 共享内存段是在MPI_Init上分配的,以后不能扩大。I_MPI_SHM_HEAP_VSIZE=0完全禁用英特尔 MPI 库共享内存分配器。

I_MPI_SHM_HEAP_CSIZE更改共享内存的英特尔 MPI 库自定义分配器中缓存的共享内存的大小(每列)。

1
I_MPI_SHM_HEAP_CSIZE=<size>

  • <size> 英特尔 MPI 库共享内存分配器中使用的共享内存大小(每列)(以兆字节为单位)。
  • > 0 这取决于可用的共享内存大小和等级数。 通常大小小于 256。

I_MPI_SHM_HEAP_CSIZE的小值可能会减少整体共享内存消耗。 此变量的较大值可能会加速MPI_Alloc_mem/MPI_Free_mem

I_MPI_SHM_HEAP_OPT更改共享内存的英特尔 MPI 库自定义分配器的优化模式。

1
I_MPI_SHM_HEAP_OPT=<mode>

  • rank 在这种模式下,每个rank都有自己专用的共享内存量。 这是I_MPI_SHM_HEAP=1时的默认值
  • numa 在这种模式下,来自 NUMA 节点的所有列使用相同数量的共享内存。

当每个rank使用相同数量的内存时,建议使用I_MPI_SHM_HEAP_OPT=rank,当rank使用显着不同的内存量时,建议使用I_MPI_SHM_HEAP_OPT=numa。 通常,I_MPI_SHM_HEAP_OPT=rank的工作速度比I_MPI_SHM_HEAP_OPT=numa快,但numa优化模式可能会消耗较小的共享内存量。

I_MPI_WAIT_MODE控制超额订阅模式的英特尔® MPI 库优化。建议在超额认购模式下使用该变量。

1
I_MPI_WAIT_MODE=<arg>

  • 0 优化 MPI 应用程序在正常模式下工作(1 个 CPU 上的 1 个rank)。 如果计算节点上的进程数小于或等于节点上的 CPU 数,则这是默认值。
  • 1 优化 MPI 应用工作在超额订阅模式(1 CPU 上多列)。 如果计算节点上的进程数大于节点上的 CPU 数,则这是默认值。

I_MPI_THREAD_YIELD在 MPI 忙等待时间控制英特尔® MPI 库线程行为。

1
I_MPI_THREAD_YIELD=<arg>

  • 0 在忙等待(自旋等待)期间对线程挂起不做任何事情。 这是I_MPI_WAIT_MODE=0时的默认值
  • 1 在忙等待期间为I_MPI_PAUSE_COUNT执行暂停处理器指令。
  • 2 在忙等待期间执行shied_yield()系统调用以获得线程挂起。 这是I_MPI_WAIT_MODE=1时的默认值
  • 3 在忙等待期间执行usleep()系统调用I_MPI_THREAD_SLEEP微秒数,用以线程挂起。

I_MPI_THREAD_YIELD=0I_MPI_THREAD_YIELD=1在正常模式下和I_MPI_THREAD_YIELD=2I_MPI_THREAD_YIELD=3在超额订阅模式下。

I_MPI_PAUSE_COUNT在 MPI 繁忙等待时间内,控制英特尔® MPI 库暂停计数以实现线程挂起自定义。

1
I_MPI_PAUSE_COUNT=<arg>

  • >=0 在 MPI 繁忙等待时间内,线程挂起定制的暂停计数。 默认值为 0。通常情况下,该值小于 100。

该变量在I_MPI_THREAD_YIELD=1时适用。I_MPI_PAUSE_COUNT的小值可能会提高性能,而较大的值可能会降低能耗。

I_MPI_THREAD_SLEEP控制英特尔® MPI 库线程睡眠微秒超时时间,以在 MPI 忙等待过程中自定义线程挂起。

1
I_MPI_THREAD_SLEEP=<arg>

  • >=0 线程睡眠微秒超时数。 默认值为 0。通常情况下,该值小于 100。

I_MPI_THREAD_YIELD=3时,该变量适用。I_MPI_PAUSE_COUNT的小值可能会提高正常模式下的性能,而较大的值可能会提高超额订阅模式下的性能

I_MPI_EXTRA_FILESYSTEM控制对并行文件系统的本机支持。使用此环境变量来启用或禁用对并行文件系统的本机支持。

1
I_MPI_EXTRA_FILESYSTEM=<arg>

  • enable | yes | on | 1启用对并行文件系统的本机支持。
  • disable | no | off | 0 禁用对并行文件系统的本机支持。 这是默认值。

I_MPI_EXTRA_FILESYSTEM_FORCE 强制文件系统识别逻辑。

1
I_MPI_EXTRA_FILESYSTEM_FORCE=<ufs|nfs|gpfs|panfs|lustre>

1. OpenMP基本介绍

OpenMP是一个编译器指令和库函数的集合,主要是为共享式存储计算机上的并行程序设计使用的。目前支持OpenMP的语言主要有Fortran,C/C++。

1.1 fork/join并行执行模式的概念

OpenMP在并行执行程序时,采用的是fork/join式并行模式,共享存储式并行程序就是使用fork/join式并行的。在开始时,只有一个叫做主线程的运行线程存在 。在运行过程中,当遇到需要进行并行计算的时候,派生出(Fork)线程来执行并行任务 。在并行代码结束执行,派生线程退出或挂起,控制流程回到单独的主线程中(Join)。

如图,标准并行模式执行代码的基本思想是,程序开始时只有一个主线程,程序中的串行部分都由主线程执行,并行的部分是通过派生其他线程来执行,但是如果并行部分没有结束时是不会执行串行部分的,先看一个简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void test()
{
int a = 0;
clock_t t1 = clock();
for (int i = 0; i < 100000000; i++)
{
a = i+1;
}
clock_t t2 = clock();
printf("Time = %d\n", t2-t1);
}
int main(int argc, char* argv[])
{
clock_t t1 = clock();
#pragma omp parallel for
for ( int j = 0; j < 2; j++ ){
test();
}
clock_t t2 = clock();
printf("Total time = %d\n", t2-t1);
test();
return 0;
}

main()函数中,没有执行完for循环中的代码之前,后面的clock_t t2 = clock();这行代码是不会执行的,如果和调用线程创建函数相比,它相当于先创建线程,并等待线程执行完,所以这种并行模式中在主线程里创建的线程并没有和主线程并行运行。

OpenMP编程模型

共享内存模型

OpenMP是为多处理器或多核共享内存机器设计的。底层架构可以是共享内存 UMA 或 NUMA。

Uniform Memory Access 一致内存访问

Ununiform Memory Access 非一致内存访问

因为OpenMP是为共享内存并行编程而设计的,所以它在很大程度上局限于单节点并行性。通常,节点上处理元素(核心)的数量决定了可以实现多少并行性。

在 HPC 中使用 OpenMP 的动机

  • OpenMP本身的并行性仅限于单个节点。
  • 对于高性能计算(HPC - High Performance Computing)应用程序,OpenMP 与 MPI 相结合以实现分布式内存并行。这通常被称为混合并行编程。
    • OpenMP 用于每个节点上的计算密集型工作。
    • MPI 用于实现节点之间的通信和数据共享。

这使得并行性可以在集群的整个范围内实现Hybrid OpenMP-MPI Parallelism。

基于线程的并行性

OpenMP 程序仅通过使用线程来实现并行性。执行线程是操作系统可以调度的最小处理单元。一种可以自动运行的子程序,这个概念可能有助于解释什么是线程。线程存在于单个进程的资源中。没有这个进程,它们就不复存在。通常,线程的数量与机器处理器/核心的数量相匹配。但是,线程的实际使用取决于应用程序。

显式并行性

OpenMP 是一个显式的(而不是自动的)编程模型,为程序员提供了对并行化的完全控制。并行化可以像获取串行程序和插入编译器指令一样简单…或者像插入子程序来设置多个并行级别、锁甚至嵌套锁一样复杂

Fork - Join 模型

OpenMP 使用并行执行的 fork-join 模型:

  • 所有 OpenMP 程序都开始于一个主线程。主线程按顺序执行,直到遇到第一个并行区域结构。
  • FORK:主线程然后创建一组并行线程。
  • 之后程序中由并行区域结构封装的语句在各个团队线程中并行执行。
  • JOIN:当团队线程完成并行区域结构中的语句时,它们将进行同步并终止,只留下主线程。

并行区域的数量和组成它们的线程是任意的。

数据范围

  • 因为 OpenMP 是共享内存编程模型,所以在默认情况下,并行区域中的大多数数据都是共享的。
  • 一个并行区域中的所有线程都可以同时访问共享数据。
  • OpenMP 为程序员提供了一种方法,可以在不需要默认共享范围的情况下显式地指定数据的“作用域”。

动态线程

该 API 为运行时环境提供了动态更改线程数量的功能,这些线程用于执行并行区域。如有可能,旨在促进更有效地利用资源。实现可能支持这个特性,也可能不支持。

I/O

OpenMP 没有指定任何关于并行 I/O 的内容。如果多个线程试图从同一个文件进行写/读操作,这一点尤其重要。如果每个线程都对不同的文件执行 I/O,那么问题就不那么重要了。完全由程序员来确保在多线程程序的上下文中正确地执行 I/O。

内存模型:经常刷新?

OpenMP 提供了线程内存的“宽松一致性”和“临时”视图(用他们的话说)。换句话说,线程可以“缓存”它们的数据,并且不需要始终与实际内存保持精确的一致性。当所有线程以相同的方式查看共享变量非常重要时,程序员负责确保所有线程根据需要刷新该变量。

2. OpenMP编程

2.1 OpenMP指令和库函数介绍

下面来介绍OpenMP的基本指令和常用指令的用法,在C/C++中,OpenMP指令使用的格式为

1
#pragma omp 指令 [子句[子句]…]

前面提到的parallel for就是一条指令,有些书中也将OpenMP的“指令”叫做“编译指导语句”,后面的子句是可选的。例如:
1
#pragma omp parallel private(i, j)

parallel就是指令,private是子句。为叙述方便把包含#pragma和OpenMP指令的一行叫做语句,如上面那行叫parallel语句。

2.2 OpenMP指令列表

这里我们先列举出OpenMP常用的指令和函数,并附上一些简单的说明。如果你看不懂,没关系,后面我们会对每个指令有详细的例子介绍。

  • parallel:用在一个代码段之前,表示这段代码将被多个线程并行执行
  • for:用于for循环之前,将循环分配到多个线程中并行执行,必须保证每次循环之间无相关性。
  • parallel forparallelfor语句的结合,也是用在一个for循环之前,表示for循环的代码将被多个线程并行执行。
  • sections,用在可能会被并行执行的代码段之前
  • parallel sectionsparallelsections两个语句的结合
  • critical,用在一段代码临界区之前
  • single,用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行。
  • flush,用来保证线程的内存临时视图和实际内存保持一致,即各个线程看到的共享变量是一致的
  • barrier,用于并行区内代码的线程同步,所有线程执行到barrier时要停止,直到所有线程都执行到barrier时才继续往下执行。
  • atomic,用于指定一块内存区域被制动更新
  • master,用于指定一段代码块由主线程执行
  • ordered, 用于指定并行区域的循环按顺序执行
  • threadprivate,用于指定一个变量是线程私有的
  • copyprivate,配合single指令,将指定线程的专有变量广播到并行域内其他线程的同名变量中;
  • copyin n,用来指定一个threadprivate类型的变量需要用主线程同名变量进行初始化;
  • default,用来指定并行域内的变量的使用方式,缺省是shared。

2.3 OpenMP库函数

OpenMP除上述指令外,还有一些库函数,OpenMP运行时库函数原本用以设置和获取执行环境相关的信息.其也包含一系列用以同步的API.要使用运行时函数库所包含的函数,应该在相应的源文件中包含OpenMP头文件omp.h.OpenMP的运行时库函数的使用类似于相应编程语言内部的函数调用.

由编译指导语句和运行时库函数可见,OpenMP同时结合了两种并行编程的方式,通过编译指导语句,可以将串行的程序逐步地改造成一个并行程序,达到增量更新程序的目的,从而减少程序编写人员的一定负担。同时,这样的方式也能将串行程序和并行程序保存在同一个源代码文件当中。OpenMP在运行的时候,需要运行函数库的支持,并会获取一些环境变量来控制运行的过程。环境变量是动态函数库中用来控制函数运行的一些参数.

OpenMP API 包括越来越多的运行时库函数。这些函数有多种用途,如下表所示:

Routine Purpose
opm_set_num_threads 设置将在下一个并行区域中使用的线程数
opm_get_num_threads 返回当前在团队中执行并行区域的线程数,该区域是调用该线程的地方
opm_get_max_threads 返回可通过调用 opm_get_num_threads 函数返回的最大值
opm_get_thread_num 返回在团队中执行此调用的线程的线程号
opm_get_thread_limit 返回程序可用的 OpenMP 线程的最大数量
opm_get_num_procs 返回程序可用的处理器数量
opm_in_parallel 用于确定正在执行的代码段是否并行
opm_set_dynamic 启用或禁用(由运行时系统)可用于执行并行区域的线程数的动态调整
opm_get_dynamic 用于确定是否启用动态线程调整
opm_set_nested 用于启用或禁用嵌套并行性
opm_get_nested 用于确定是否启用嵌套并行性
opm_set_schedule 在 OpenMP 指令中将“runtime”用作调度类型时,设置循环调度策略
opm_get_schedule 当 OpenMP 指令中使用“runtime”作为调度类型时,返回循环调度策略
opm_set_max-active_levels 设置嵌套并行区域的最大数目
opm_get_max-active_levels 返回嵌套并行区域的最大数目
opm_get_level 返回嵌套并行区域的当前级别
opm_get_ancestor_thread_num 对于当前线程的给定嵌套级别,返回祖先线程的线程数
opm_get_team_size 对于当前线程的给定嵌套级别,返回线程团队的大小
opm_get_active_level 返回包含调用的任务的嵌套活动并行区域的数目
opm_in_final 如果程序在最后一个任务区域执行,则返回true;否则返回false
opm_init_lock 初始化与锁变量关联的锁
opm_destory_lock 将给定的锁变量与任何锁分离
opm_set_lock 获得锁的所有权
opm_unset_lock 释放锁
opm_test_lock 尝试设置锁,但如果锁不可用,则不会阻塞
opm_init_nest_lock 初始化与锁变量关联的嵌套锁
opm_destory_nest_lock 将给定的嵌套锁变量与任何锁分离
opm_set_nest_lock 获取嵌套锁的所有权
opm_unset_nest_lock 释放嵌套锁
opm_test_nest_lock 尝试设置嵌套锁,但如果锁不可用,则不会阻塞
opm_get_wtime 提供便携式挂钟定时程序
opm_get_wtick 返回一个双精度浮点值,该值等于连续时钟滴答之间的秒数

对于C/C++,所有运行时库函数都是实际的子程序。对于Fortran来说,有些是函数,有些是子程序。例如:

1
2
#include <omp.h>
int omp_get_num_threads(void)

注意,对于C/C++,通常需要包含<omp.h>头文件。

对于锁程序/函数:

  • 锁变量只能通过锁程序访问
  • 对于Fortran,锁变量的类型应该是integer,并且要足够大,以便容纳一个地址。
  • 对于C/C++,lock 变量的类型必须是omp_lock_tomp_nest_lock_t,这取决于所使用的函数。

实现注意事项:

  • 实现可能支持也可能不支持所有 OpenMP API 特性。例如,如果支持嵌套并行,那么它可能只是名义上的,因为嵌套并行区域可能只有一个线程。
  • 有关详细信息,请参阅您的实现文档—或者亲自试验一下,如果您在文档中找不到它,请自己查找。

2.3 OpenMP子句

  • private:指定每个线程都有它自己的变量私有副本。
  • firstprivate:指定每个线程都有它自己的变量私有副本,并且变量要被继承主线程中的初值。
  • lastprivate:主要是用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量。
  • reduce:用来指定一个或多个变量是私有的,并且在并行处理结束后这些变量要执行指定的运算。
  • nowait:忽略指定中暗含的等待
  • num_threads:指定线程的个数
  • schedule:指定如何调度for循环迭代
  • shared:指定一个或多个变量为多个线程间的共享变量
  • ordered:用来指定for循环的执行要按顺序执行
  • copyprivate:用于single指令中的指定变量为多个线程的共享变量
  • copyin:用来指定一个threadprivate的变量的值要用主线程的值进行初始化。
  • default:用来指定并行处理区域内的变量的使用方式,缺省是shared

2.4 环境变量

OpenMP 提供了几个环境变量,用于在运行时控制并行代码的执行。这些环境变量可以用来控制这些事情:

  • 设置线程数
  • 指定如何划分循环交互
  • 将线程绑定到处理器
  • 启用/禁用嵌套的并行性;设置嵌套并行度的最大级别
  • 启用/禁用动态线程
  • 设置线程堆栈大小
  • 设置线程等待策略

设置 OpenMP 环境变量的方法与设置任何其他环境变量的方法相同,并且取决于您使用的是哪种 shell。例如:

1
2
csh/tcsh: setenv OMP_NUM_THREADS 8
sh/bash: export OMP_NUM_THREADS=8

OpenMP 提供了以下环境变量来控制并行代码的执行。所有环境变量名都是大写的。分配给它们的值不区分大小写。

OMP_SCHEDULE只适用于DO, PARALLEL DO (Fortran)和 for, parallel for (C/C++)指令,它们的 schedule 子句设置为运行时。此变量的值决定如何在处理器上调度循环的迭代。例如:

1
2
setenv OMP_SCHEDULE "guided, 4" 
setenv OMP_SCHEDULE "dynamic"

OMP_NUM_THREADS设置执行期间使用的最大线程数。例如:

1
setenv OMP_NUM_THREADS 8

OMP_DYNAMIC启用或禁用可用于并行区域执行的线程数量的动态调整。有效值为 TRUE 或 FALSE。例如:

1
setenv OMP_DYNAMIC TRUE

OMP_PROC_BIND启用或禁用线程绑定到处理器。有效值为 TRUE 或 FALSE。例如:

1
setenv OMP_PROC_BIND TRUE

OMP_NESTED启用或禁用嵌套并行性。有效值为 TRUE 或 FALSE。例如:

1
setenv OMP_NESTED TRUE

OMP_STACKSIZE控制已创建(非主)线程的堆栈大小。例子:

1
2
3
4
5
6
7
setenv OMP_STACKSIZE 2000500B 
setenv OMP_STACKSIZE "3000 k "
setenv OMP_STACKSIZE 10M
setenv OMP_STACKSIZE " 10 M "
setenv OMP_STACKSIZE "20 m "
setenv OMP_STACKSIZE " 1G"
setenv OMP_STACKSIZE 20000

OMP_WAIT_POLICY为 OpenMP 实现提供有关等待线程的所需行为的提示。一个兼容的 OpenMP 实现可能遵守也可能不遵守环境变量的设置。有效值分为 ACTIVE 和 PASSIVE。ACTIVE 指定等待的线程大部分应该是活动的,即,在等待时消耗处理器周期。PASSIVE 指定等待的线程大部分应该是被动的,即,而不是在等待时消耗处理器周期。ACTIVE 和 PASSIVE 行为的细节是由实现定义的。例子:

1
2
3
4
setenv OMP_WAIT_POLICY ACTIVE 
setenv OMP_WAIT_POLICY active
setenv OMP_WAIT_POLICY PASSIVE
setenv OMP_WAIT_POLICY passive

OMP_MAX_ACTIVE_LEVELS控制嵌套的活动并行区域的最大数目。该环境变量的值必须是非负整数。如果 OMP_MAX_ACTIVE_LEVELS 的请求值大于实现所能支持的嵌套活动并行级别的最大数量,或者该值不是一个非负整数,则该程序的行为是由实现定义的。例子:

1
setenv OMP_MAX_ACTIVE_LEVELS 2

OMP_THREAD_LIMIT设置用于整个 OpenMP 程序的 OpenMP 线程的数量。这个环境变量的值必须是正整数。如果 OMP_THREAD_LIMIT 的请求值大于实现所能支持的线程数,或者该值不是正整数,则程序的行为是由实现定义的。例子:

1
setenv OMP_THREAD_LIMIT 8

2.5 编译 OpenMP 程序

OpenMP 版本依赖的 GCC 版本

OpenMP 版本 GCC版本
OpenMP 5.0 >= GCC 9.1
OpenMP 4.5 >= GCC 6.1
OpenMP 4.0 >= GCC 4.9.0
OpenMP 3.1 >= GCC 4.7.0
OpenMP 3.0 >= GCC 4.4.0
OpenMP 2.5 >= GCC 4.2.0

linux 下编译命令示例:

1
g++ Test.cpp -o omptest -fopenmp

2.6 OpenMP API 概述

三大构成

OpenMP 3.1 API 由三个不同的组件组成:

  • 编译器指令
  • 运行时库函数
  • 环境变量

后来的一些 API 包含了这三个相同的组件,但是增加了指令、运行时库函数和环境变量的数量。应用程序开发人员决定如何使用这些组件。在最简单的情况下,只需要其中的几个。实现对所有 API 组件的支持各不相同。例如,一个实现可能声明它支持嵌套并行,但是 API 清楚地表明它可能被限制在一个线程上——主线程。不完全符合开发人员的期望?

编译器指令

编译器指令在源代码中以注释的形式出现,编译器会忽略它们,除非您另外告诉它们 — 通常通过指定适当的编译标志,如后面的编译部分所述。OpenMP 编译器指令用于各种目的:

  • 生成一个并行区域
  • 在线程之间划分代码块
  • 在线程之间分配循环迭代
  • 序列化代码段
  • 线程之间的工作同步

编译器指令有以下语法:

1
sentinel directive-name [clause, ...] 

例如:

1
#pragma omp parallel default(shared) private(beta, pi)

后面将详细讨论编译器指令。

运行时库函数 Run-time Library Routines:

OpenMP API 包括越来越多的运行时库函数。这些程序用于各种目的:

  • 设置和查询线程的数量
  • 查询线程的唯一标识符(线程ID)、父线程的标识符、线程团队大小
  • 设置和查询动态线程特性
  • 查询是否在一个并行区域,以及在什么级别
  • 设置和查询嵌套并行性
  • 设置、初始化和终止锁以及嵌套锁
  • 查询 wall clock time 和分辨率

对于 C/C++,所有运行时库函数都是实际的子程序。对于Fortran来说,有些是函数,有些是子程序。例如:

1
2
#include <omp.h>
int omp_get_num_threads(void)

注意,对于C/C++,通常需要包含<omp.h >头文件。

运行时库函数将在运行时库函数一节中作为概述进行简要讨论,更多细节将在附录A中讨论。

2.7 OpenMP 指令

C/C++ 指令格式

格式

1
#pragma omp	directive-name	[clause, ...]	newline

所有 OpenMP C/C++ 指令都需要。 一个有效的 OpenMP 指令。必须出现在 pragma 之后和任何子句之前。 可选的。除非另有限制,子句可以按任何顺序重复。 必需的。在此指令所包含的结构化块之前。

一般规则

  • 区分大小写。
  • 指令遵循 C/C++ 编译器指令标准的约定。
  • 每个指令只能指定一个指令名。
  • 每个指令最多应用于一个后续语句,该语句必须是一个结构化块。
  • 长指令行可以通过在指令行的末尾使用反斜杠(“\”)来转义换行符,从而在后续的行中“继续”。

指令范围

静态(词法)范围

  • 在指令后面的结构化块的开始和结束之间以文本形式封装的代码。
  • 指令的静态范围不跨越多个程序或代码文件。

孤立的指令

一个 OpenMP 指令,独立于另一个封闭指令,称为孤立型指令。它存在于另一个指令的静态(词法)范围之外。将跨越程序和可能的代码文件。

动态范围

指令的动态范围包括静态(词法)范围和孤立指令的范围。

为什么这很重要?

OpenMP 为指令如何相互关联(绑定)和嵌套指定了许多范围规则。如果忽略 OpenMP 绑定和嵌套规则,可能会导致非法或不正确的程序。有关详细信息,请参阅指令绑定和嵌套规则。

并行区域结构

目的

并行区域是由多个线程执行的代码块。这是基本的 OpenMP 并行结构。

格式

1
2
3
4
5
6
7
8
9
10
11
#pragma omp parallel [clause ...]  newline 
if (scalar_expression)
private (list)
shared (list)
default (shared | none)
firstprivate (list)
reduction (operator: list)
copyin (list)
num_threads (integer-expression)

structured_block

注意

  • 当一个线程执行到一个并行指令时,它创建一个线程组并成为该线程组的主线程。主线程是该团队的成员,在该团队中线程号为0。
  • 从这个并行区域开始,代码被复制,所有线程都将执行该代码。
  • 在并行区域的末端有一个隐含的屏障。只有主线程在此之后继续执行。
  • 如果任何线程在一个并行区域内终止,则团队中的所有线程都将终止,并且在此之前所做的工作都是未定义的。

有多少线程

并行区域内的线程数由以下因素决定,按优先级排序:

  • IF子句的计算
  • NUM_THREADS子句的设置
  • 使用omp_set_num_threads()库函数
  • 设置OMP_NUM_THREADS环境变量
  • 实现缺省值 — 通常是一个节点上的 cpu 数量,尽管它可以是动态的(参见下一小节)
  • 线程的编号从0(主线程)到N-1。

动态线程

使用omp_get_dynamic()库函数来确定是否启用了动态线程。如果支持的话,启用动态线程的两种方法是:

  • omp_set_dynamic()库函数
  • OMP_NESTED环境变量设置为 TRUE

如果不支持,则在另一个并行区域内嵌套一个并行区域,从而在默认情况下创建一个由单个线程组成的新团队。

子句

IF 子句:如果存在,它的值必须为非零,以便创建一个线程组。否则,该区域将由主线程串行执行。

限制条件

  • 并行区域必须是不跨越多个程序或代码文件的结构化块。
  • 从一个并行区域转入或转出是非法的。
  • 只允许一个 IF 子句。
  • 只允许一个 NUM_THREADS 子句。
  • 程序不能依赖于子句的顺序。

并行区域例子

简单的“Hello World”程序。每个线程执行包含在并行区域中的所有代码。OpenMP 库函数用于获取线程标识符和线程总数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <omp.h>
int main(int argc, char *argv[]) {
int nthreads, tid;
/* Fork a team of threads with each thread having a private tid variable */
#pragma omp parallel private(tid)
{
/* Obtain and print thread id */
tid = omp_get_thread_num();
printf("Hello World from thread = %d\n", tid);

/* Only master thread does this */
if (tid == 0) {
nthreads = omp_get_num_threads();
printf("Number of threads = %d\n", nthreads);
}
} /* All threads join master thread and terminate */
return 0;
}

工作共享结构

工作共享结构将封闭代码区域的执行划分给遇到它的团队成员。工作共享结构不会启动新线程。在进入工作共享结构时没有隐含的屏障,但是在工作共享结构的末尾有一个隐含的屏障。

工作共享结构的类型:

  • DO / for - 整个团队的循环迭代。表示一种“数据并行性”。
  • SECTIONS - 把工作分成单独的、不连续的部分。每个部分由一个线程执行。可以用来实现一种“函数并行性”。
  • SINGLE - 序列化一段代码。

限制条件

为了使指令能够并行执行,必须将工作共享结构动态地封装在一个并行区域中。团队的所有成员都必须遇到工作共享结构,或者根本不遇到。团队的所有成员必须以相同的顺序遇到连续的工作共享结构。

DO / for 指令

DO / for 指令指定紧随其后的循环迭代必须由团队并行执行。这假定已经启动了并行区域,否则它将在单个处理器上串行执行。

1
2
3
4
5
6
7
8
9
10
11
12
#pragma omp for [clause ...]  newline 
schedule (type [,chunk])
ordered
private (list)
firstprivate (list)
lastprivate (list)
shared (list)
reduction (operator: list)
collapse (n)
nowait

for_loop
  • schedule:描述循环迭代如何在团队中的线程之间进行分配。默认的调度是依赖于实现的。有关如何使一种调度比其他调度更优的讨论,请参见http://openmp.org/forum/viewtopic.php?f=3&t=83
    • 静态(STATIC) - 循环迭代被分成小块,然后静态地分配给线程。如果没有指定 chunk,则迭代是均匀地(如果可能)在线程之间连续地划分。
    • 动态(DYNAMIC) - 循环迭代分成小块,并在线程之间动态调度;当一个线程完成一个块时,它被动态地分配给另一个块。默认块大小为1。
    • 引导(GUIDED) - 当线程请求迭代时,迭代被动态地分配给块中的线程,直到没有剩余的块需要分配为止。与 DYNAMIC 类似,只是每次将一个工作包分配给一个线程时,块的大小就会减小。
      • 初始块的大小与 number_of_iteration / number_of_threads 成比例
      • 后续块与number_of_iterations_remaining / number_of_threads 成比例
      • chunk 参数定义最小块大小。默认块大小为1。
    • 运行时(RUNTIME) - 环境变量 OMP_SCHEDULE 将调度决策延迟到运行时。为这个子句指定块大小是非法的。
    • 自动(AUTO) - 调度决策被委托给编译器或运行时系统。
  • nowait: 如果指定,那么线程在并行循环结束时不会同步。
  • ordered:指定循环的迭代必须像在串行程序中一样执行。
  • collapse:指定在一个嵌套循环中有多少个循环应该折叠成一个大的迭代空间,并根据 schedule 子句进行划分。折叠迭代空间中的迭代的顺序是确定的,就好像它们是按顺序执行的一样。可能会提高性能。
  • 其他子句稍后将在数据范围属性子句一节中详细描述。
限制条件
  • DO 循环不能是 DO WHILE 循环,也不能是没有循环控制的循环。此外,循环迭代变量必须是整数,并且对于所有线程,循环控制参数必须相同。
  • 程序的正确性不能依赖于哪个线程执行特定的迭代。
  • 在与 DO / for 指令关联的循环中跳转(转到)是非法的。
  • 块大小必须指定为循环不变的整数表达式,因为在不同线程求值期间不存在同步。
  • ORDERED、COLLAPSE、SCHEDULE 子句可以出现一次。
  • 有关其他限制,请参阅 OpenMP 规范文档。
DO / for 指令示例

简单的 vector 相加程序,数组 A、B、C 和变量 N 将由所有线程共享。变量 i 对每个线程都是私有的;每个线程都有自己唯一的副本。循环迭代将在 CHUNK 大小的块中动态分布。线程在完成各自的工作后将不会同步 (NOWAIT)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <omp.h>
#define N 1000
#define CHUNKSIZE 100

int main(int argc, char *argv[]) {
int i, chunk;
float a[N], b[N], c[N];

/* Some initializations */
for (i = 0; i < N; i++)
a[i] = b[i] = i * 1.0;
chunk = CHUNKSIZE;

#pragma omp parallel shared(a,b,c,chunk) private(i)
{

#pragma omp for schedule(dynamic,chunk) nowait
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];

} /* end of parallel region */

return 0;
}

sections 指令

目的

sections 指令是一个非迭代的工作共享结构。它指定所包含的代码段将被分配给团队中的各个线程。

独立的 section 指令嵌套在 sections 指令中。每个部分由团队中的一个线程执行一次。不同的部分可以由不同的线程执行。如果一个线程执行多个部分的速度足够快,并且实现允许这样做,那么它就可以执行多个部分。

1
2
3
4
5
6
7
8
9
10
11
12
#pragma omp sections [clause ...]  newline 
private (list)
firstprivate (list)
lastprivate (list)
reduction (operator: list)
nowait
{
#pragma omp section newline
structured_block
#pragma omp section newline
structured_block
}
子句

除非使用了 NOWAIT/nowait 子句,否则在 sections 指令的末尾有一个隐含的屏障(译者注:an implied barrier 意思应该是线程会相互等待)。稍后将在数据范围属性子句一节中详细描述子句。

限制条件

跳转(转到)或跳出 section 代码块是非法的。section 指令必须出现在一个封闭的 sections 指令的词法范围内(没有独立部分)。

sections 指令示例

下面一个简单的程序演示不同的工作块将由不同的线程完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <omp.h>
#define N 1000

int main() {
int i;
float a[N], b[N], c[N], d[N];

/* Some initializations */
for (i = 0; i < N; i++) {
a[i] = i * 1.5;
b[i] = i + 22.35;
}

#pragma omp parallel shared(a,b,c,d) private(i)
{
#pragma omp sections nowait
{
#pragma omp section
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];

#pragma omp section
for (i = 0; i < N; i++)
d[i] = a[i] * b[i];
} /* end of sections */
} /* end of parallel region */
return 0;
}

single 指令

目的

single 指令指定所包含的代码仅由团队中的一个线程执行。在处理非线程安全的代码段(如 I/O )时可能很有用

格式
1
2
3
4
5
6
#pragma omp single [clause ...]  newline 
private (list)
firstprivate (list)
nowait

structured_block
子句

除非指定了 nowait 子句,否则团队中不执行 single 指令的线程将在代码块的末尾等待。稍后将在数据范围属性子句一节中详细描述子句。

限制条件

进入或跳出一个 single 代码块是非法的。

合并并行工作共享结构

OpenMP 提供了三个简单的指令:

  • parallel for
  • parallel sections
  • PARALLEL WORKSHARE (fortran only)

在大多数情况下,这些指令的行为与单独的并行指令完全相同,并行指令后面紧跟着一个单独的工作共享指令。大多数适用于这两个指令的规则、条款和限制都是有效的。有关详细信息,请参阅 OpenMP API。

下面显示了一个使用 parallel for 组合指令的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <omp.h>
#define N 1000
#define CHUNKSIZE 100

int main() {
int i, chunk;
float a[N], b[N], c[N];

/* Some initializations */
for (i = 0; i < N; i++)
a[i] = b[i] = i * 1.0;
chunk = CHUNKSIZE;

#pragma omp parallel for shared(a,b,c,chunk) private(i) schedule(static,chunk)
for (i = 0; i < N; i++)
c[i] = a[i] + b[i];
return 0;
}

任务结构

目的

任务结构定义了一个显式任务,该任务可以由遇到的线程执行,也可以由团队中的任何其他线程延迟执行。任务的数据环境由数据共享属性子句确定。任务执行取决于任务调度 — 详细信息请参阅 OpenMP 3.1 规范文档

格式

1
2
3
4
5
6
7
8
9
10
11
#pragma omp task [clause ...]  newline 
if (scalar expression)
final (scalar expression)
untied
default (shared | none)
mergeable
private (list)
firstprivate (list)
shared (list)

structured_block

同步结构

考虑一个简单的例子,两个线程都试图同时更新变量x:

1
2
3
4
5
6
7
8
9
THREAD1	
update(x)
{
    x = x + 1
}

x = 0
update(x)
print(x)

1
2
3
4
5
6
7
8
9
THREAD2
update(x)
{
    x = x + 1
}

x = 0
update(x)
print(x)

一种可能的执行顺序:

  • 线程1初始化 x 为0并调用
  • 线程1将 x 加1,x 现在等于1
  • 线程2初始化 x 为0并调用 update(x),x现在等于0
  • 线程1输出 x,它等于0而不是1
  • 线程2将 x 加1,x 现在等于1
  • 线程2打印 x 为1

为了避免这种情况,必须在两个线程之间同步 x 的更新,以确保产生正确的结果。OpenMP 提供了各种同步结构,这些构造控制每个线程相对于其他团队线程的执行方式。

master 指令

目的

master 指令指定了一个区域,该区域只由团队的主线程执行。团队中的所有其他线程都将跳过这部分代码。这个指令没有隐含的障碍( implied barrier )。

格式
1
2
#pragma omp master  newline
structured_block
限制条件

进入或跳出一个 master 代码块是非法的。

critical 指令

目的

critical 指令指定了一个只能由一个线程执行的代码区域。

格式
1
2
#pragma omp critical [ name ]  newline
structured_block
注意事项
  • 如果一个线程当前在一个 critical 区域内执行,而另一个线程到达该 critical 区域并试图执行它,那么它将阻塞,直到第一个线程退出该 critical 区域。
  • 可选的名称使多个不同的临界区域存在:
    • 名称充当全局标识符。具有相同名称的不同临界区被视为相同的区域。
    • 所有未命名的临界段均视为同一段。
限制条件

进入或跳出一个 critical 代码块是非法的。

Fortran only: The names of critical constructs are global entities of the program. If a name conflicts with any other entity, the behavior of the program is unspecified.

critical 结构示例

团队中的所有线程都将尝试并行执行,但是由于 x 的增加由 critical 结构包围,在任何时候只有一个线程能够读/增量/写 x。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <omp.h>

int main() {
int x;
x = 0;

#pragma omp parallel shared(x)
{
#pragma omp critical
x = x + 1;
} /* end of parallel region */
return 0;
}

barrier 指令

目的

barrier 指令同步团队中的所有线程。当到达 barrier 指令时,一个线程将在该点等待,直到所有其他线程都到达了 barrier 指令。然后,所有线程继续并行执行 barrier 之后的代码。

格式
1
#pragma omp barrier  newline

f

限制条件

团队中的所有线程(或没有线程)都必须执行 barrier 区域。对于团队中的每个线程,遇到的 work-sharing 区域和 barrier 区域的顺序必须是相同的。

taskwait 指令

目的

taskwait 结构指定自当前任务开始以来生成的子任务完成时的等待时间。

格式
1
#pragma omp taskwait  newline
限制条件

因为 taskwait 结构是一个独立的指令,所以它在程序中的位置有一些限制。taskwait 指令只能放置在允许使用基本语言语句的地方。taskwait 指令不能代替 if、while、do、switch 或 label 后面的语句。有关详细信息,请参阅 OpenMP 3.1 规范文档。

atomic 指令

目的

atomic 结构确保以原子方式访问特定的存储位置,而不是将其暴露给多个线程同时读写,这些线程可能会导致不确定的值。本质上,这个指令提供了一个最小临界( mini-CRITICAL )区域。

格式
1
2
#pragma omp atomic  [ read | write | update | capture ] newline
statement_expression
限制条件

该指令仅适用于紧接其后的单个语句。原子语句必须遵循特定的语法。查看最新的OpenMP规范。

flush 指令

目的

flush 指令标识了一个同步点,在这个点上,内存数据必须一致。这时,线程可见的变量被写回内存。请参阅最新的 OpenMP 规范以获取详细信息。

格式
1
#pragma omp flush (list)  newline
注意事项

可选 list 参数包含一个将被刷新的已命名变量列表,以避免刷新所有变量。对于列表中的指针,请注意指针本身被刷新,而不是它指向的对象。实现必须确保线程可见变量的任何修改在此之后对所有线程都是可见的,例如编译器必须将值从寄存器恢复到内存,硬件可能需要刷新写缓冲区,等等。对于下面的指令,将使用 flush 指令。如果存在 nowait 子句,则该指令无效。

  • barrier
  • parallel - 进入和退出
  • critical - 进入和退出
  • ordered - 进入和退出
  • for - 退出
  • sections - 退出
  • single - 退出

ordered 指令

目的

ordered 指令指定封闭的循环迭代将以串行处理器上执行顺序执行。如果之前的迭代还没有完成,线程在执行它们的迭代块之前需要等待。在带有 ordered 子句的 for 循环中使用。ordered 指令提供了一种“微调”的方法,其中在循环中应用了排序。否则,它不是必需的。

格式
1
2
3
4
5
6
#pragma omp for ordered [clauses...]
(loop region)

#pragma omp ordered newline
structured_block
(endo of loop region)
限制条件
  • 一个 ordered 指令只能在以下指令的动态范围内出现:
    • for 或者 parallel for (C/C++)。
  • 在一个有序的区段中,任何时候都只允许一个线程。
  • 进入或跳出一个 ordered 代码块是非法的。
  • 一个循环的迭代不能多次执行同一个有序指令,也不能一次执行多个有序指令。
  • 包含有序指令的循环必须是带有 ordered 子句的循环。

threadprivate 指令

目的

threadprivate 指令指定复制变量,每个线程都有自己的副本。

可用于通过执行多个并行区域将全局文件作用域变量(C/C++/Fortran)或公共块(Fortran)局部化并持久化到一个线程。

格式

1
#pragma omp threadprivate (list)

注意事项

  • 指令必须出现在列出的变量/公共块的声明之后。每个线程都有自己的变量/公共块的副本,所以一个线程写的数据对其他线程是不可见的。
  • 在第一次进入一个并行区域时,应该假设 threadprivate 变量和公共块中的数据是未定义的,除非在并行指令中指定了 copyin 子句。
  • threadprivate 变量不同于 private 变量(稍后讨论),因为它们能够在代码的不同并行区域之间持久存在。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <omp.h>
int a, b, i, tid;
float x;

#pragma omp threadprivate(a, x)

int main() {
/* 显式关闭动态线程 Explicitly turn off dynamic threads */
omp_set_dynamic(0);

printf("1st Parallel Region:\n");
#pragma omp parallel private(b,tid)
{
tid = omp_get_thread_num();
a = tid;
b = tid;
x = 1.1 * tid + 1.0;
printf("Thread %d: a,b,x= %d %d %f\n", tid, a, b, x);
} /* end of parallel region */

printf("************************************\n");
printf("Master thread doing serial work here\n");
printf("************************************\n");

printf("2nd Parallel Region:\n");
#pragma omp parallel private(tid)
{
tid = omp_get_thread_num();
printf("Thread %d: a,b,x= %d %d %f\n", tid, a, b, x);
} /* end of parallel region */
return 0;
}

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1st Parallel Region:
Thread 4: a,b,x= 4 4 5.400000
Thread 7: a,b,x= 7 7 8.700000
Thread 2: a,b,x= 2 2 3.200000
Thread 3: a,b,x= 3 3 4.300000
Thread 6: a,b,x= 6 6 7.600000
Thread 1: a,b,x= 1 1 2.100000
Thread 5: a,b,x= 5 5 6.500000
Thread 0: a,b,x= 0 0 1.000000
************************************
Master thread doing serial work here
************************************
2nd Parallel Region:
Thread 1: a,b,x= 1 0 2.100000
Thread 6: a,b,x= 6 0 7.600000
Thread 4: a,b,x= 4 0 5.400000
Thread 5: a,b,x= 5 0 6.500000
Thread 2: a,b,x= 2 0 3.200000
Thread 7: a,b,x= 7 0 8.700000
Thread 0: a,b,x= 0 0 1.000000
Thread 3: a,b,x= 3 0 4.300000

限制条件

只有在动态线程机制“关闭”并且不同并行区域中的线程数量保持不变的情况下,threadprivate 对象中的数据才能保证持久。动态线程的默认设置是未定义的。

数据范围属性子句

也称为数据共享属性子句。

OpenMP 编程的一个重要考虑是理解和使用数据作用域。因为 OpenMP 是基于共享内存编程模型的,所以大多数变量在默认情况下是共享的。

全局变量包括:

  • Fortran: COMMON blocks, SAVE variables, MODULE variables
  • 文件作用域变量,static

私有变量包括:

  • 循环索引变量
  • 从并行区域调用的子程序中的堆栈变量
  • Fortran: Automatic variables within a statement block

OpenMP 数据范围属性子句用于显式定义变量的范围。它们包括:

  • private
  • firstprivate
  • lastprivate
  • shared
  • default
  • reduction
  • copyin

数据范围属性子句与几个指令(parallel、DO/for 和 sections)一起使用,以控制所包含变量的范围。这些结构提供了在并行结构执行期间控制数据环境的能力。它们定义了如何将程序的串行部分中的哪些数据变量传输到程序的并行区域(以及向后传输)。它们定义哪些变量将对并行区域中的所有线程可见,哪些变量以私有形式分配给所有线程。数据范围属性子句仅在其词法/静态范围内有效。

private 子句

目的

private 子句将在其列表中的变量声明为每个线程的私有变量。

格式
1
private (list)
注意事项

私有变量的行为如下:

  • 为团队中的每个线程声明一个相同类型的新对象
  • 所有对原始对象的引用都被替换为对新对象的引用
  • 应该假定每个线程都没有初始化

shared 子句

目的

shared 子句声明其列表中的变量在团队中的所有线程之间共享。

格式
1
shared (list)
注意事项

共享变量只存在于一个内存位置,所有线程都可以读写该地址,程序员有责任确保多个线程正确地访问共享变量(例如通过临界区)

default 子句

目的

default 子句允许用户为任何并行区域的词法范围内的所有变量指定默认作用域。

格式
1
default (shared | none)
注意事项

使用 private、shared、firstprivate、lastprivate 和 reduction 子句可以避免使用特定变量。C/C++ OpenMP 规范不包括将 private 或 firstprivate 作为可能的默认值。但是,实际的实现可能会提供这个选项。使用 none 作为默认值要求程序员显式地限定所有变量的作用域。

firstprivate 子句

目的

firstprivate 子句将 private 子句的行为与它的列表中变量的自动初始化相结合。

格式
1
firstprivate (list)
注意事项

在进入并行或工作共享结构之前,将根据其原始对象的值初始化列出的变量。

lastprivate 子句

目的

lastprivate 子句将 private 子句的行为与从最后一个循环迭代或部分到原始变量对象的复制相结合。

格式
1
lastprivate (list)
注意事项

复制回原始变量对象的值是从封闭结构的最后一次(顺序)迭代或部分获得的。例如,为 DO 部分执行最后一次迭代的团队成员,或者执行 sections 上下文的最后一部分的团队成员,使用其自身的值执行副本。

copyin 子句

目的

copyin 子句提供了为团队中的所有线程分配相同值的 threadprivate 变量的方法。

格式
1
copyin  (list)
注意事项

列表包含要复制的变量的名称。在Fortran中,列表既可以包含公共块的名称,也可以包含已命名变量的名称。主线程变量用作复制源。在进入并行结构时,将使用其值初始化团队线程。

copyprivate 子句

目的

copyprivate 子句可用于将单个线程获得的值直接传播到其他线程中私有变量的所有实例。与 single 指令相关联

格式
1
copyprivate  (list)

reduction 子句

目的

reduction 子句对出现在其列表中的变量执行约简操作。为每个线程创建并初始化每个列表变量的私有副本。在约简结束时,将约简变量应用于共享变量的所有私有副本,并将最终结果写入全局共享变量。

格式
1
reduction (operator: list)
Example: REDUCTION - Vector Dot Product:

并行循环的迭代将以相同大小的块分配给团队中的每个线程(调度静态),在并行循环构造的末尾,所有线程将添加它们的“result”值来更新主线程的全局副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <omp.h>

int main() {
int i, n, chunk;
float a[100], b[100], result;

/* Some initializations */
n = 100;
chunk = 10;
result = 0.0;
for (i = 0; i < n; i++) {
a[i] = i * 1.0;
b[i] = i * 2.0;
}

#pragma omp parallel for default(shared) private(i) \
schedule(static,chunk) reduction(+:result)
for (i = 0; i < n; i++)
result = result + (a[i] * b[i]);

printf("Final result= %f\n", result);
return 0;
}

限制条件
  • 列表项的类型必须对约简操作符有效。
  • 列表项/变量不能声明为共享或私有。
  • 约简操作可能与实数无关。
  • 有关其他限制,请参见 OpenMP 标准 API。

指令绑定和嵌套规则

本节主要是作为管理 OpenMP 指令和绑定的规则的快速参考。用户应该参考他们的实现文档和 OpenMP 标准以了解其他规则和限制。除非另有说明,规则适用于 Fortran 和 C/C++ OpenMP 实现。

注意:Fortran API 还定义了许多数据环境规则。这些没有在这里复制。

指令绑定

  • DO/for、sections、single、master 和 barrier 指令绑定到动态封闭的 parallel (如果存在的话)。如果当前没有并行区域被执行,指令就没有效果。
  • 有序指令绑定到动态封闭的 DO/for 。
  • atomic 指令强制对所有线程中的 atomic 指令进行独占访问,而不仅仅是当前的团队。
  • critical 指令强制对所有线程中的 critical 指令进行独占访问,而不仅仅是当前的团队。
  • 指令永远不能绑定到最接近的封闭并行之外的任何指令。

指令嵌套

  • 工作共享区域不能紧密嵌套在工作共享、显式任务、关键区域、有序区域、原子区域或主区域内。
  • 屏障区域不能紧密嵌套在工作共享、显式任务、关键区域、有序区域、原子区域或主区域中。
  • 主区域不能紧密嵌套在工作共享、原子或显式任务区域内。
  • 有序区域可能不会紧密嵌套在临界、原子或显式任务区域内。
  • 一个有序区域必须与一个有序子句紧密嵌套在一个循环区域(或并行循环区域)内。
  • 临界区不能嵌套(紧密嵌套或以其他方式嵌套)在具有相同名称的临界区内。注意,此限制不足以防止死锁。
  • 并行、刷新、临界、原子、taskyield 和显式任务区域可能不会紧密嵌套在原子区域内。

2.8 线程堆栈大小和线程绑定

线程堆栈大小

OpenMP 标准没有指定一个线程应该有多少堆栈空间。因此,默认线程堆栈大小的实现将有所不同。
默认的线程堆栈大小很容易耗尽。它也可以在编译器之间不可移植。以过去版本的LC编译器为例:

Compiler Approx. Stack Limit Approx. Array Size (doubles)
Linux icc, ifort 4 MB 700 x 700
Linux pgcc, pgf90 8 MB 1000 x 1000
Linux gcc, gfortran 2 MB 500 x 500
  • 超出其堆栈分配的线程可能存在或不存在段错误。当数据被破坏时,应用程序可以继续运行。
  • 静态链接代码可能受到进一步的堆栈限制。
  • 用户的登录shell还可以限制堆栈大小

如果您的 OpenMP 环境支持 OpenMP 3.0 OMP_STACKSIZE 环境变量(在前一节中介绍过),那么您可以使用它在程序执行之前设置线程堆栈大小。例如:

1
2
3
4
5
6
7
setenv OMP_STACKSIZE 2000500B
setenv OMP_STACKSIZE "3000 k "
setenv OMP_STACKSIZE 10M
setenv OMP_STACKSIZE " 10 M "
setenv OMP_STACKSIZE "20 m "
setenv OMP_STACKSIZE " 1G"
setenv OMP_STACKSIZE 20000

否则,在LC上,您应该能够对Linux集群使用下面的方法。该示例显示将线程堆栈大小设置为12 MB,作为预防措施,将shell堆栈大小设置为无限制。

csh/tcsh:

1
2
setenv KMP_STACKSIZE 12000000
limit stacksize unlimited

ksh/sh/bash:

1
2
export KMP_STACKSIZE=12000000
ulimit -s unlimited

线程绑定

  • 在某些情况下,如果一个程序的线程被绑定到处理器/核心,那么它的性能会更好。
  • “绑定”一个线程到一个处理器意味着操作系统将调度一个线程始终在同一个处理器上运行。否则,可以将线程调度为在任何处理器上执行,并在每个时间片的处理器之间来回“弹回”。也称为“线程关联性”或“处理器关联性”。
  • 将线程绑定到处理器可以更好地利用缓存,从而减少昂贵的内存访问。这是将线程绑定到处理器的主要动机。

根据平台、操作系统、编译器和 OpenMP 实现的不同,可以通过几种不同的方式将线程绑定到处理器。OpenMP 3.1 版 API 提供了一个环境变量来“打开”或“关闭”处理器绑定。例如:

1
2
setenv OMP_PROC_BIND  TRUE
setenv OMP_PROC_BIND FALSE

在更高的级别上,进程也可以绑定到处理器。

3. OpenMP详细代码示例

3.1 hello_openmp.cpp

1
2
3
4
5
6
7
8
9
10
11
12
/* 尝试着在编译选项里使用和不使用-openmp 这个编译选项分别编译并执行代码 */
#include <iostream>

int main()
{
#ifdef _OPENMP // 如果定义了这个宏
std::cout << "Hello, OpenMP!" << std::endl;
#else
std::cout << "OpenMP is not enabled." << std::endl;
#endif
return 0;
}

3.2 header_and_env.cpp

1
2
3
4
5
6
7
8
9
10
11
12
/*  引入<omp.h>头文件,OpenMP的几乎所有函数定义都在这个头文件中。 */
#include <iostream>
#include <omp.h> // 包含OpenMP头文件

int main()
{
// omp_get_max_threads() 等其它函数都定义在omp.h头文件中
// omp_get_max_threads() 获取本机的CPU线程数
std::cout << "OpenMP will use " << omp_get_max_threads() <<
" threads maximum." << std::endl;
return 0;
}

3.3 parallel.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
1. 尝试将环境变量改为 OMP_NUM_THREADS=2 和 OMP_NUM_THREADS=3 再编译运行程序试试
2. 尝试在 #pragma omp parallel 后添加num_threads(5) 试试
*/
#include <stdio.h>
#include <omp.h>

int main()
{
#pragma omp parallel // OpenMP 并行区域
{
// 花括号里的内容会被N个线程同时执行,N定义在环境变量OMP_NUM_THREADS中
printf("Hello from thread %d of %d\n", omp_get_thread_num(), omp_get_num_threads());
}
return 0;
}

3.4 parallel_cout.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
程序说明: 我们会发现打印在控制台的内容是乱的,这是因为在一个线程还没输出完成时,另一个线程就抢着要输出了!
*/
#include <iostream>
#include <sstream>
#include <omp.h>

int main()
{
#pragma omp parallel
{
std::cout << "Hello from thread " << omp_get_thread_num() << " of " << omp_get_num_threads() << std::endl;
}
return 0;
}

3.5 nested.cpp

如果嵌套并行可用,则在并行区里还会继续创建线程,level 2会输出4次。否则的话level 2输出2次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
1. 看看打印输出是否跟您想的一样,如果不一样,为什么?
2. 试着禁止嵌套并行;
*/
#include <stdio.h>
#include <omp.h>

int main()
{
omp_set_nested(1); // 允许嵌套并行可用

#pragma omp parallel num_threads(2)
{
printf("Level 1, thread %d of %d\n", omp_get_thread_num(),omp_get_num_threads());

#pragma omp parallel num_threads(2)
{
printf("Level 2, thread %d of %d\n", omp_get_thread_num(),omp_get_num_threads());
}
}
return 0;
}

3.6 parallel-for.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
for循环的并行
*/
#include <iostream>

int main()
{
const int size = 50;
int a[size];

#pragma omp parallel for
for (int i = 0; i < size; i++)
a[i] = i; // 这里的代码是并行执行的

for (int i = 0; i < size; i++) // 这里是串行执行的,#pragma omp parallel for作用范围只有紧接着的for循环,当然这个for循环是可以嵌套的.
std::cout << a[i] << std::endl;

return 0;
}

3.7 scoping.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
1. 变量的作用范围在并行程序设计中非常重要
2. 如果在并行区域再加一个私有的a变量,想想会发生什么?
*/
#include <iostream>
#include <omp.h>

int main()
{
int a = -1;
#pragma omp parallel
{
// a在并行块内部是共享(默认)的,所有线程都有权操作它(操作的都是同一个变量,没有备份),而且并行快结束后,块内代码对其的修改有效
int b; // 在并行区域外不可见,每个线程有一个备份拷贝
a = omp_get_thread_num() + 100;
b = omp_get_thread_num() + 200;
}
std::cout << "a = " << a << std::endl; // 理论上这里的输出是[100+0,100+threads)之间随机的, 得看哪个线程最后执行完
// b = 0; // 对外不可见,这里会发生错误,所以注释
return 0;
}

3.8 firstprivate.cpp

firstprivate的作用是,让i默认使用并行区域外i的值来初始化并行区域内的私有i,但是初始化后并行去内部的i就跟外面的没有关系了,各个线程仍然持有一个i的私有备份,运行结束时,原有的i值保持i=10不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <omp.h>

int main()
{
int i = 10;

#pragma omp parallel private(i)
{
// 私有变量i是覆盖了并行区域外的共享变量i,所以这里并没有初始化,值应该为0
printf("thread %d, i = %d\n", omp_get_thread_num(), i);
i = 200 + omp_get_thread_num();
}
printf("i = %d\n", i);
return 0;
}

3.9 lastprivate.cpp

跟firstprivate相反,lastprivate主要是用来指定将线程中的私有变量的值在并行处理结束后复制回主线程中的对应变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main()
{
const int size = 1000;
int i = -1, a[size];

#pragma omp parallel for lastprivate(i)
for (i = 0; i < size; i++)
a[i] = i;

std::cout << "i = " << i << std::endl;
return 0;
}

3.10 single-master-critical.cpp

  1. omp critical -> execute by one thread at a time
  2. omp single -> execute by any one thread
  3. omp master -> execute by the master thread (id == 0)
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <omp.h>

int main()
{
#pragma omp parallel num_threads(8)
{
#pragma omp critical
std::cout << "Hello from thread " << omp_get_thread_num() << " of " <<
omp_get_num_threads() << std::endl;
}
return 0;
}

3.11 mutex.cpp

锁是多线程计算里非常重要的概念,他是保证数据一致性的基础,没有锁的并行计算会导致非常奇怪的结果。

举个栗子:当多个线程操作一个数据时,一个线程在读取一个数的时候另两个线程在对这个数作写操作,那这个读线程到底应该拿哪一个数值去做计算呢?回答当然是,拿最后更改这个值的线程的值去计算,但是这个值就是对的么?

很多时候即使是并行的,但是对于一个简单操作来讲,它也是需要有先后循序的,比如这个线程在操作这个变量的时候要求别的线程要等待这个线程操作完成,这时候就使用锁将该变量锁住。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <omp.h>
#include <unistd.h>

int main()
{
omp_lock_t lock;
omp_init_lock(&lock);

#pragma omp parallel num_threads(4)
{
omp_set_lock(&lock); // mutual exclusion (mutex)
std::cout << "Thread " << omp_get_thread_num() << " has acquired the lock. Sleeping 2 seconds..." << std::endl;
sleep(2);
std::cout << "Thread " << omp_get_thread_num() <<" is releasing the lock..." << std:: endl; omp_unset_lock(&lock);
}
omp_destroy_lock(&lock);
return 0;
}

3.12 barrier.cpp

  1. 同步也是并行计算中特别重要的概念,跟上面讲的锁一样;
  2. 特别是在时间相关的计算领域里,如含时的有限差分等等;
  3. 因为在具体程序中,每个线程执行的任务不一样,即使执行的任务一样,也不能保证每个线程执行任务消费的时间都完全一致。有的线程已经执行了5行代码,有的线程才执行到第0行。而含时的迭代需要所有线程都执行完t步骤后,才能继续执行t+1时间步,不然会导致错误的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <omp.h>

int main()
{
#pragma omp parallel
{
printf("Hello from thread %d of %d\n", omp_get_thread_num(),
omp_get_num_threads());
#pragma omp barrier // 所有的线程都执行到这里时才能继续往后执行
printf("Thread %d of %d have passed the barrier\n",
omp_get_thread_num(), omp_get_num_threads());
}
return 0;
}

3.13 atomic.cpp

原子变量跟锁有相近似的作用,都是保证变量或者事务的一致性;举个栗子:银行转账,A给B转账过程中突然停电,A账户前丢失了B却没有收到,这是谁的责任?当然是银行的责任。当然,银行的程序员们可不是吃白米饭的,转账的操作就是一个原子操作,只有成功或失败,没有成功了一半这个说法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <omp.h>

inline double two_body_energy(int i, int j)
{
return (2.0 * i + 3.0 * j) / 10.0; // some dummy return value
}

int main()
{
const int nbodies = 1000;
double energy = 0.0;

#pragma omp parallel
for (int i = 0; i < nbodies; i++) {
for (int j = i+1; j < nbodies; j++) {
double eij = two_body_energy(i, j);
#pragma omp atomic
energy += eij;
}
}

std::cout << "energy = " << energy << std::endl;
return 0;
}

3.14 reduction.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <omp.h>

double
two_body_energy(int i, int j)
{
return (2.0 * i + 3.0 * j) / 10.0; // some dummy return value
}

int main()
{
const int nbodies = 1000;
double energy = 0.0;

// + 是进行加运算
// energy 是要进行归约的变量
#pragma omp parallel for reduction(+:energy)
for (int i = 0; i < nbodies; i++) {
for (int j = i+1; j < nbodies; j++) {
double eij = two_body_energy(i, j);
energy += eij;
}
}

std::cout << "energy = " << energy << std::endl;
return 0;
}

3.15 scheduling.cpp

schedule只能用于循环并行构造中,其作用是用于控制循环并行结构的任务调度。一个简单的理解,一个for循环假设有10次迭代,使用4个线程去执行,那么哪些线程去执行哪些迭代呢?可以通过schedule去控制迭代的调度和分配,从而适应不同的使用情况,提高性能。

  • static -> 大部分的编译器实现,在没有使用schedule子句的时候,系统就是采用static方式调度的。
    • 对于schedule(static,size)的含义,OpenMP会给每个线程分配size次迭代计算。这个分配是静态的,“静态”体现在这个分配过程跟实际的运行是无关的,可以从逻辑上推断出哪几次迭代会在哪几个线程上运行。具体而言,对于一个N次迭代,使用M个线程,那么,[0,size-1]的size次的迭代是在第一个线程上运行,[size, size + size -1]是在第二个线程上运行,依次类推。那么,如果M太大,size也很大,就可能出现很多个迭代在一个线程上运行,而某些线程不执行任何迭代。需要说明的是,这个分配过程就是这样确定的,不会因为运行的情况改变,比如,我们知道,进入OpenMP后,假设有M个线程,这M个线程开始执行的时间不一定是一样的,这是由OpenMP去调度的,并不会因为某一个线程先被启动,而去改变for的迭代的分配,这就是静态的含义。
  • dynamic -> 每个线程运行结束时获得新的计算任务
    • 动态调度迭代的分配是依赖于运行状态进行动态确定的,所以哪个线程上将会运行哪些迭代是无法像静态一样事先预料的。对于dynamic,没有size参数的情况下,每个线程按先执行完先分配的方式执行1次循环,比如,刚开始,线程1先启动,那么会为线程1分配一次循环开始去执行(i=0的迭代),然后,可能线程2启动了,那么为线程2分配一次循环去执行(i=1的迭代),假设这时候线程0和线程3没有启动,而线程1的迭代已经执行完,可能会继续为线程1分配一次迭代,如果线程0或3先启动了,可能会为之分配一次迭代,直到把所有的迭代分配完。所以,动态分配的结果是无法事先知道的,因为我们无法知道哪一个线程会先启动,哪一个线程执行某一个迭代需要多久等等,这些都是取决于系统的资源、线程的调度等等。
  • guided -> 类似动态钓鱼,但是 chunk size是自适应的。
    • 类似于动态调度,但每次分配的循环次数不同,开始比较大,以后逐渐减小。size表示每次分配的迭代次数的最小值,由于每次分配的迭代次数会逐渐减少,较少到size时,将不再减少。如果不知道size的大小,那么默认size为1,即一直减少到1。具体是如何减少的,以及开始比较大(具体是多少?),参考相关手册的信息。
  • auto -> 编译器动态决定采用那种策略
    • runtime表示根据环境变量确定上述调度策略中的某一种,默认也是静态的(static)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <omp.h>

#define CHUNK_SIZE 5

// scheduling:

int main()
{
const int niter = 25;

#pragma omp parallel for schedule(static, CHUNK_SIZE)
for (int i = 0; i < niter; i++) {
int thr = omp_get_thread_num();
printf("iter %d of %d on thread %d\n", i, niter, thr);
}

return 0;
}

3.16 ordered.cpp

在循环代码中某些代码的执行需要按规定的顺序执行,比如在一个循环中,一部分的工作可以并行执行,而特定的部分需要按照串行的工作流程依次执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <omp.h>

int main()
{
const int niter = 10;
#pragma omp parallel for ordered // 这里必须这么写
for (int i = 0; i < niter; i++) {
int thr = omp_get_thread_num();
printf("unordered iter %d of %d on thread %d\n", i, niter, thr);
#pragma omp ordered // 这里是需要顺序执行的部分
printf("ordered iter %d of %d on thread %d\n", i, niter, thr);
}
return 0;
}

3.17 loop-dependencies.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <omp.h>

void work( int N, int M, double **A, double **B, double **C )
{
int i, j;
double alpha = 1.2;

#pragma omp for collapse(2) ordered(2)
for (i = 1; i < N-1; i++)
{
for (j = 1; j < M-1; j++)
{
A[i][j] = foo(i, j);
#pragma omp ordered depend(source)

B[i][j] = alpha * A[i][j];

#pragma omp ordered depend(sink: i-1,j) depend(sink: i,j-1)
C[i][j] = 0.2 * (A[i-1][j] + A[i+1][j] + A[i][j-1] + A[i][j+1] + A[i][j]);
}
}
}

3.18 sections.cpp

  1. 有些需要并行的任务并不是一个for循环之类的,而是一个个代码块,这种情况下就可以使用sections的情形;
  2. sections下包含多个section,section相互之间只并行执行的,但是section内部是串行执行的;
  3. 多个sections之间也是串行执行的
  4. 如果#pragma omp parallel sections 写成#pragma omp sections,则各个section之间是串行执行的
  5. 如果变成两个线程的话,会有一个线程执行两个section
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <omp.h>
#include <unistd.h>

int main()
{
#pragma omp parallel sections num_threads(4)
{
#pragma omp section // 独立线程
{
int thr = omp_get_thread_num();
printf("section 1, thread %d - sleeping 1 second\n", thr);
sleep(1);
printf("section 1 done\n");
}
#pragma omp section // 独立线程
{
int thr = omp_get_thread_num();
printf("section 2, thread %d - sleeping 2 second\n", thr);
sleep(2);
printf("section 2 done\n");
}
#pragma omp section // 独立线程
{
int thr = omp_get_thread_num();
printf("section 3, thread %d - sleeping 3 second\n", thr);
sleep(3);
printf("section 3 done\n");
}
// printf("not in omp section"); // error - code must be in section
}
return 0;
}

3.19 threadprivate.cpp

threadprivate指令用来指定全局的对象被各个线程各自复制了一个私有的拷贝,即各个线程具有各自私有的全局对象。threadprivate和private的区别在于threadprivate声明的变量通常是全局范围内有效的,而private声明的变量只在它所属的并行构造中有效。用作threadprivate的变量的地址不能是常数。对于C++的类(class)类型变量,用作threadprivate的参数时有些限制,当定义时带有外部初始化时,必须具有明确的拷贝构造函数。程序示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int g;
#pragma omp threadprivate(g) //一定要先声明
int main(int argc, char *argv[])
{
/* Explicitly turn off dynamic threads */
omp_set_dynamic(0);

#pragma omp parallel
{
g = omp_get_thread_num();
printf("tid: %d\n",g); //随机依次输出0~3
} // End of parallel region


#pragma omp parallel
{
int temp = g*g;
printf("tid : %d, tid*tid: %d\n",g, temp); //不同线程中全局变量值不同
} // End of parallel region
}

注意:在使用threadprivate的时候,要用omp_set_dynamic(0)关闭动态线程的属性,才能保证结果正确。

3.20 Copyin.cpp

copyin子句用于将主线程中threadprivate变量的值拷贝到执行并行区域的各个线程的threadprivate变量中,从而使得team内的子线程都拥有和主线程同样的初始值。程序示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <omp.h> 
int A = 100;

#pragma omp threadprivate(A)
int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel for
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // 并行区域外的打印的“Globa A”的值总是和前面的thread 0的结果相等,因为退出并行区域后,只有master线程即0号线程运行。
#pragma omp parallel for copyin(A)
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // #2
return 0;
}

3.21 Copyprivate.cpp

copyprivate子句用于将线程私有副本变量的值从一个线程广播到执行同一并行区域的其他线程的同一变量。copyprivate只能用于single指令(single指令:用在一段只被单个线程执行的代码段之前,表示后面的代码段将被单线程执行)的子句中,在一个single块的结尾处完成广播操作。copyprivate只能用于private/firstprivate或threadprivate修饰的变量。程序示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int counter = 0;

#pragma omp threadprivate(counter)
int increment_counter()
{
counter++;
return(counter);
}

#pragma omp parallel
{
int count;
#pragma omp single copyprivate(counter)
{
counter = 50;
}

count = increment_counter();
printf("ThreadId: %ld, count = %ld/n", omp_get_thread_num(), count);
}
// count 都是51

3.22 nowait.cpp

栅障(Barrier)是OpenMP用于线程同步的一种方法。线程遇到栅障是必须等待,直到并行区中的所有线程都到达同一点。注意:在任务分配for循环和任务分配section结构中,我们已经隐含了栅障,在parallel,for,sections,single结构的最后,也会有一个隐式的栅障。

隐式的栅障会使线程等到所有的线程继续完成当前的循环、结构化块或并行区,再继续执行后面的工作。可以使用nowait去掉这个隐式的栅障.去掉隐式栅障,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma omp parallel //并行区内
{
#pragma omp for nowait // 任务分配for循环
for(k=0;k<m;k++){
fun1(k);
}
#pragma omp sections private(y,z)
{
#pragme omp section//任务分配section
{y=sectionA(x);}
#pragme omp section
{z=sectionB(x);}
}
}

因为第一个 任务分配for循环和第二个任务分配section代码块之间不存在数据相关。加上显示栅障,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma omp parallel shared(x,y,z) num_threads(2)//使用的线程数为2
{
int tid=omp_get_thread_num();
if(tid==0)
y=fun1();//第一个线程得到y
else
z=fun2();//第二个线程得到z
#pragma omp barrier //显示加上栅障,保证y和z在使用前已有值

#pragma omp for
for(k=0;k<100;k++)
x[k]=y+z;
}

单线程和多线程交错执行: 当开发人员为了减少开销而把并行区设置的很大时,有些代码很可能只执行一次,并且由一个线程执行,这样单线程和多线程需要交错执行

举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#pragma omp parallel //并行区
{
int tid=omp_get_thread_num();//每个线程都调用这个函数,得到线程号
//这个循环被划分到多个线程上进行
#pragma omp for nowait
for(k=0;k<100;k++)
x[k]=fun1(tid);//这个循环的结束处不存在使所有线程进行同步的隐式栅障
#pragma omp master
y=fn_input_only(); //只有主线程会调用这个函数
#pragma omp barrier //添加一个显示的栅障对所有的线程同步,从而确保x[0-99]和y处于就绪状态
//这个循环也被划分到多个线程上进行

#pragma omp for nowait
for(k=0;k<100;k++)
x[k]=y+fn2(x[k]); //这个线程没有栅障,所以不会相互等待
//一旦某个线程执行完上面的代码,不需要等待就可以马上执行下面的代码
#pragma omp single //注意:single后面意味着有隐式barrier
fn_single_print(y);
//所有的线程在执行下面的函数前会进行同步
#pragma omp master
fn_print_array(x);//只有主线程会调用这个函数
}

3.23 task

从功能上说:

  1. The TASK construct defines an explicit task, which may be executed by the encountering thread, or deferred for execution by any other thread in the team.
  2. The data environment of the task is determined by the data sharing attribute clauses.
  3. Task execution is subject to task scheduling - see the OpenMP 3.0 specification document for details.

任务构造定义一个显式的任务,可能会被遇到的线程马上执行,也可能被延迟给线程组内其他线程来执行。任务的执行,依赖于OpenMP的任务调度。

语法:

1
2
3
4
5
6
7
8
9
10
#pragma omp task [clause ...]  newline 
if (scalar expression)
final (scalar expression)
untied
default (shared | none)
mergeable
private (list)
firstprivate (list)
shared (list)
structured_block

task,简单的理解,就是定义一个任务,线程组内的某一个线程来执行此任务。和工作共享结构很类似,我们都知道,for也是某一个线程执行某一个迭代,如果把每一个迭代看成一个task,那么就是task的工作方式了,在for只能用于循环迭代的基础上,OpenMP提供了sections构造,用于构造一个sections,然后里面定义一堆的section,每一个section被一个线程去执行,这样,每一个section也类似于for的每一次迭代,只是使用sections会更灵活,更简单,但是其实,for和sections在某种程度上是可以转换的,用下面的例子来看一个使用sections和for指令分别执行“三个”任务的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <omp.h>
#define TASK_COUNT 3

void task1(int p)
{
printf("task1, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}

void task2(int p)
{
printf("task2, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}

void task3(int p)
{
printf("task3, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}

int main(int argc, _TCHAR* argv[])
{

#pragma omp parallel num_threads(2)
{
#pragma omp sections
{
#pragma omp section
task1(10);
#pragma omp section
task2(20);
#pragma omp section
task3(1000);
}
}

#pragma omp parallel num_threads(2)
{
#pragma omp for
for(int i = 0;i < TASK_COUNT; i ++)
{
if(i == 0)
task1(10);
else if (i == 1)
task2(20);
else if (i == 2)
task3(1000);
}
}
return 0;
}

当然,这个程序不是这里要讨论的重点,只是为了说明for和sections的一些类似之处,或者其实可以理解为sections其实是for的展开形式,适合于少量的“任务”,并且适合于没有迭代关系的“任务”。很显然,上面的例子适合用sections去解决,因为本身是三个任务,不存在迭代的关系,三个任务和循环迭代变量没有什么关联。

接下来,分析下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <omp.h>

void task(int p)
{
printf("task, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}

#define N 3
void init(int*a)
{
for(int i = 0;i < N;i++)
a[i] = i + 1;
}

int main(int argc, _TCHAR* argv[])
{
int a[N];
init(a);

#pragma omp parallel num_threads(2)
{
#pragma omp sections
{
#pragma omp section
task(a[0]);
#pragma omp section
task(a[1]);
#pragma omp section
task(a[2]);
}
}

#pragma omp parallel num_threads(2)
{
#pragma omp for
for(int i = 0;i < N; i++)
{
task(a[i]);
}
}
return 0;
}

这个例子,很容易理解了,把一个数组内的每一个元素“并行”的传递给task()函数,执行一个“任务”。同样,for和sections都能解决,但是如果N太大了,比如N是100,那sections就为难了,这里要说明的是:sections不能使用嵌套的形式,比如:

1
2
3
4
5
for(int i = 0;i < N;i++)
{
#pragma omp section
task(a[0]);
}

这样是不行的,section只能显式的,直接的在sections里面书写,可以理解为”静态“的。继续研究这个例子,假设现在的需求是对如下的代码进行并行化:

1
2
3
4
for(int i = 0;i < N; i=i+a[i])
{
task(a[i]);
}

对于这样的需求,OpenMP的for指令也是无法完成的,因为for指令在进行并行执行之前,就需要”静态“的知道任务该如何划分,而上面的i=i+a[i],在运行之前,是无法知道有那些迭代,需要如何进行划分,因为其迭代的循环依赖于数组a里面保存的值。那么对于这样的循环,该如何并行?最关键的是,从语义上,这个循环是明显可以进行并行的。这就是之所以OpenMP3.0提供task的原因了。

在此,先总结一下for和sections指令的”缺陷“:无法根据运行时的环境动态的进行任务划分,必须是预先能知道的任务划分的情况。

使用task解决上面遗留的问题的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <omp.h>

void task(int p)
{
printf("task, Thread ID: %d, task: %d\n", omp_get_thread_num(), p);
}

#define N 50
void init(int*a)
{
for(int i = 0;i < N;i++)
a[i] = i + 1;
}

int main(int argc, _TCHAR* argv[])
{
int a[N];
init(a);

#pragma omp parallel num_threads(2)
{
#pragma omp single
{
for(int i = 0;i < N; i=i+a[i])
{
#pragma omp task
task(a[i]);
}
}
}
return 0;
}

这里之所以用single表示只有一个线程会执行下面的代码,否则会执行两次(这里用的线程数量为2),这是single的子句的理解,就不在此分析了。看其中task的代码,其实很简单,OpenMP遇到了task之后,就会使用当前的线程或者延迟一会后接下来的线程来执行task定义的任务。task的作用,就是定义了一个显式的任务。

那么,task和前面的for和sections的区别在于:task是“动态”定义任务的,在运行过程中,只需要使用task就会定义一个任务,任务就会在一个线程上去执行,那么其它的任务就可以并行的执行。可能某一个任务执行了一半的时候,或者甚至要执行完的时候,程序可以去创建第二个任务,任务在一个线程上去执行,一个动态的过程,不像sections和for那样,在运行之前,已经可以判断出可以如何去分配任务。而且,task是可以进行嵌套定义的,可以用于递归的情况等等。

总结task的使用:task主要适用于不规则的循环迭代(如上面的循环)和递归的函数调用。都是无法使用for来完成的情况。

显示任务和隐式任务(implicit&explicit)

task的作用就是创建一个显式的任务,那么什么是隐式的任务呢?OpenMP的任务分为显式和隐式两种,根据我的个人理解,分析下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
#pragma omp parallel num_threads(2)
{
#pragma omp single
{
for(int i = 0;i < N; i=i+a[i])
{
#pragma omp task
task(a[i]);
}
task(1000);
}
}

其中task(1000);就属于一个隐式的任务。因为执行完for后,会执行这一个任务,而上面的任务可能也会同时执行。

task的嵌套

任务构造结构可以嵌套在另一个task结构中,但是内部的task结构并不属于外部的task区域的一部分。

1
2
3
4
5
6
#pragma omp task
{
task(a[i]);
#pragma omp task
task(a[i]);
}

简单的理解,OpenMP遇到task指令就会定义一个显式的任务,就会在当前的线程或者延迟等待其它线程去执行,而不是将嵌套task的部分当作外部task的一部分。

task指令的子句

如果给一个task使用了if子句,如果if子句的表达式是false,会生成一个不延迟的任务,这样,遇到这个task的当前线程必须挂起当前的task区域,直到当前的任务完成之后才会恢复。个人理解,当前线程挂起,那么这个task是不是由其它的线程去执行呢,还是就是当前的这个线程执行这个任务?

如果给task使用了final子句,如果final表达式的值为true,生成的任务是一个终结任务。所有任务遇到终结任务执行的时候会生成终结和包含的任务。PS:不太理解!

3.25 flush.cpp

当并行区域里存在一共享变量,并且对其进行修改时,需要用flush更新变量,确保并行的多线程对共享变量的读操作是最新值.

1
2
3
4
5
6
7
done=0;
#pragma omp flush(done)
if(!done)
{
...
done=1;
}

OpenMP中数据属性相关子句详解

private/firstprivate/lastprivate/threadprivate之间的比较

private/firstprivate/lastprivate/threadprivate,首先要知道的是,它们分为两大类,一类是private/firstprivate/lastprivate子句,另一类是threadprivate,为指令。

private

private子句将一个或多个变量声明为线程的私有变量。每个线程都有它自己的变量私有副本,其他线程无法访问。即使在并行区域外有同名的共享变量,共享变量在并行区域内不起任何作用,并且并行区域内不会操作到外面的共享变量。
注意:

  1. private variables are undefined on entry and exit of the parallel region.即private变量在进入和退出并行区域是“未定义“的。
  2. The value of the original variable (before the parallel region) is undefined after the parallel region!在并行区域之前定义的原来的变量,在并行区域后也是”未定义“的。
  3. A private variable within the parallel region has no storage association with the same variable outside of the region. 并行区域内的private变量和并行区域外同名的变量没有存储关联。

说明:private的很容易理解错误。下面用例子来说明上面的注意事项,

A. private变量在进入和退出并行区域是”未定义“的。

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, _TCHAR* argv[])
{
int A=100;

#pragma omp parallel for private(A)
for(int i = 0; i<10;i++)
{
printf("%d\n",A);
}

return 0;
}

初学OpenMP很容易认为这段代码是没有问题的。其实,这里的A在进入并行区域的时候是未定义的,所以在并行区域直接对其进行读操作,会导致运行时错误。
其实,在VS中编译这段代码,就会有编译警告:

1
warning C4700: uninitialized local variable 'A' used

很清楚的指向”printf”这句,A是没有初始化的变量。所以,运行时候会出现运行时崩溃的错误。

这段代码能说明,private在进入并行区域是未定义的,至于退出并行区域就不容易举例说明了,本身,这里的三个注意事项是交叉理解的,说明的是一个含义,所以,看下面的例子来理解。

B. 在并行区域之前定义的原来的变量,在并行区域后也是”未定义“的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, _TCHAR* argv[])
{
int B;

#pragma omp parallel for private(B)
for(int i = 0; i<10;i++)
{
B = 100;
}

printf("%d\n",B);

return 0;
}

这里的B在并行区域内进行了赋值等操作,但是在退出并行区域后,是未定义的。理解”在并行区域之 前定义的 原来的变量,在并行区域 后也是” 未定义“的“这句话的时候,要注意,不是说所有的在并行区域内定义的原来的变量,使用了private子句后,退出并行区域后就一定是未定义的,如果原来的变量,本身已经初始化,那么,退出后,不会处于未定义的状态,就是下面的第三个注意事项要说明的问题。

C. 并行区域内的private变量和并行区域外同名的变量没有存储关联

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(int argc, _TCHAR* argv[])
{
int C = 100;

#pragma omp parallel for private(C)
for(int i = 0; i<10;i++)
{
C = 200;
printf("%d\n",C);
}
printf("%d\n",C);
return 0;
}

这里,在退出并行区域后,printf的C的结果是100,和并行区域内对其的操作无关。

总结来说,上面的三点是交叉的,第三点包含了所有的情况。所以,private的关键理解是:A private variable within the parallel region has no storage association with the same variable outside of the region. 简单点理解,可以认为,并行区域内的private变量和并行区域外的变量没有任何关联。如果非要说点关联就是,在使用private的时候,在之前要先定义一下这个变量,但是,到了并行区域后,并行区域的每个线程会产生此变量的副本,而且是没有初始化的。

下面是综合上面的例子,参考注释的解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, _TCHAR* argv[])
{
int A=100,B,C=0;

#pragma omp parallel for private(A) private(B)
for(int i = 0; i<10;i++)
{
B = A + i; // A is undefined! Runtime error!
printf("%d\n",i);
}
/*--End of OpemMP paralle region. --*/

C = B; // B is undefined outside of the parallel region!
printf("A:%d\n", A);
printf("B:%d\n", B);

return 0;
}

firstprivate

private子句的私有变量不能继承同名变量的值,firstprivate则用于实现这一功能-继承并行区域额之外的变量的值,用于在进入并行区域之前进行一次初始化。

Firstprivate(list): All variables in the list areinitialized with the value the original object had before entering the parallelconstruct.

分析下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, _TCHAR* argv[])
{
int A;

#pragma omp parallel for firstprivate(A)
for(int i = 0; i<10;i++)
{
printf("%d: %d\n",i, A); // #1
}
printf("%d\n",A); // #2
return 0;
}

用VS编译发现,也会报一个“warning C4700: uninitialized local variable ‘A’ used”的警告,但是这里其实两个地方用到了A。实际上,这个警告是针对第二处的,可以看出,VS并没有给第一处OpenMP并行区域内的A有警告,这是由于使用firstprivate的时候,会对并行区域内的A使用其外的同名共享变量就行初始化,当然,如果严格分析,外面的变量其实也是没有初始化的,理论上也是可以认为应该报警告,但是,具体而言,这是跟VS的实现有关的,另外,在debug下,上面的程序会崩溃,release下,其实是可以输出值的,总之,上面的输出是无法预料的。
再看下面的例子,和前面private的例子很类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for firstprivate(A)
for(int i = 0; i<10;i++)
{
printf("%d: %d\n",i, A); // #1
}

printf("%d\n",A); // #2

return 0;
}

这里,如果使用private,那么并行区域内是有问题的,因为并行区域内的A是没有初始化的,导致无法预料的输出或崩溃。但是,使用了firstprivate后,这样,进入并行区域的时候,每一个线程的A的副本都会利用并行区域外的同名共享变量A的值进行一次初始化,所以,输出的A都是100.
继续探讨这里的“进行一次初始化”,为了理解“一次”的含义,看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <omp.h>
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for firstprivate(A)
for(int i = 0; i<10;i++)
{
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
A = i;
}

printf("%d\n",A); // #2

return 0;
}

这里,每次输出后,改变A的值,需要注意的是,这里的“进行一次初始化”是针对team内的每一个线程进行一次初始化,对于上面的程序,在4核的CPU上运行,并行区域内有四个线程,所以每一个线程都会有A的一个副本,因而,上面的程序输出结果可能如下:

1
2
3
4
5
6
7
8
9
10
11
Thread ID: 0, 0: 100
Thread ID: 0, 1: 0
Thread ID: 0, 2: 1
Thread ID: 2, 6: 100
Thread ID: 2, 7: 6
Thread ID: 1, 3: 100
Thread ID: 2, 4: 3
Thread ID: 1, 5: 4
Thread ID: 3, 8: 100
Thread ID: 3, 9: 8
100

其实,这个结果是很容易理解的,不可能是每一个for都有一个变量的副本,而是每一个线程,所以这个结果在预料之中。

仍然借助上面这个例子,帮助理解private和firstprivate,从而引出lastprivate,private对于并行区域的每一个线程都有一个副本,并且和并行区域外的变量没有关联;firstprivate解决了进入并行区的问题,即在进入并行区域的每个线程的副本变量使用并行区域外的共享变量进行一个初始化的工作,那么下面有一个问题就是,如果希望并行区域的副本变量,在退出并行区的时候,能反过来赋值给并行区域外的共享变量,那么就需要依靠lastprivate了。

lastprivate

如果需要在并行区域内的私有变量经过计算后,在退出并行区域时,需要将其值赋给同名的共享变量,就可以使用lastprivate完成。

Lastprivate(list):The thread that executes the sequentially last iteration or section updates thevalue of the objects in the list.

从上面的firstprivate的最后一个例子可以看出,并行区域对A进行了赋值,但是退出并行区域后,其值仍然为原来的值。

这里首先有一个问题是:退出并行区域后,需要将并行区域内的副本的值赋值为同名的共享变量,那么,并行区域内有多个线程,是哪一个线程的副本用于赋值呢?

是否是最后一个运行完毕的线程?否!OpenMP规范中指出,如果是循环迭代,那么是将最后一次循环迭代中的值赋给对应的共享变量;如果是section构造,那么是最后一个section语句中的值赋给对应的共享变量。注意这里说的最后一个section是指程序语法上的最后一个,而不是实际运行时的最后一个运行完的。

在理解这句话之前,先利用一个简单的例子来理解一下lastprivate的作用:

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for lastprivate(A)
for(int i = 0; i<10;i++)
{
A = 10;
}
printf("%d\n",A);
return 0;
}

这里,很容易知道结果为10,而不是100.这就是lastprivate带来的效果,退出后会有一个赋值的过程。
理解了lastprivate的基本含义,就可以继续来理解上面的红色文字部分的描述了,即到底是哪一个线程的副本用于对并行区域外的变量赋值的问题,下面的例子和前面firstprivate的例子很类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <omp.h>
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for lastprivate(A)
for(int i = 0; i<10;i++)
{
printf("Thread ID: %d, %d\n",omp_get_thread_num(), i); // #1
A = i;
}
printf("%d\n",A); // #2
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
Thread ID: 0, 0
Thread ID: 0, 1
Thread ID: 0, 2
Thread ID: 3, 8
Thread ID: 3, 9
Thread ID: 2, 6
Thread ID: 1, 3
Thread ID: 1, 4
Thread ID: 1, 5
Thread ID: 2, 7
9

从结果可以看出,最后并行区域外的共享变量的值并不是最后一个线程退出的值,多次运行发现,并行区域的输出结果可能发生变化,但是最终的输出都是9,这就是上面的OpenMP规范说明的问题,退出并行区域的时候,是根据“逻辑上”的最后一个线程用于对共享变量赋值,而不是实际运行的最后一个线程,对于for而言,就是最后一个循环迭代所在线程的副本值,用于对共享变量赋值。

另外,firstprivate和lastprivate分别是利用共享变量对线程副本初始化(进入)以及利用线程副本对共享变量赋值(退出),private是线程副本和共享变量无任何关联,那么如果希望进入的时候初始化并且退出的时候赋值呢?事实上,可以对同一个变量使用firstprivate和lastprivate的,下面的例子即可看出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <omp.h>
int main(int argc, _TCHAR* argv[])
{
int A = 100;

#pragma omp parallel for firstprivate(A) lastprivate(A)
for(int i = 0; i<10;i++)
{
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
A = i;
}
printf("%d\n",A); // #2
return 0;
}

说明:不能对一个变量同时使用两次private,或者同时使用private和firstprivate/lastprivate,只能firstprivate和lastprivate一起使用。
关于lastprivate,还需要说明的一点是,如果是类(class)类型的变量使用在lastprivate参数中,那么使用时有些限制,需要一个可访问的,明确的缺省构造函数,除非变量也被使用作为firstprivate子句的参数;还需要一个拷贝赋值操作符,并且这个拷贝赋值操作符对于不同对象的操作顺序是未指定的,依赖于编译器的定义。

另外,firstprivate和private可以用于所有的并行构造块,但是lastprivate只能用于for和section组成的并行块之中。

threadprivate

首先,threadprivate和上面几个子句的区别在于,threadprivate是指令,不是子句。threadprivate指定全局变量被OpenMP所有的线程各自产生一个私有的拷贝,即各个线程都有自己私有的全局变量。一个很明显的区别在于,threadprivate并不是针对某一个并行区域,而是整个于整个程序,所以,其拷贝的副本变量也是全局的,即在不同的并行区域之间的同一个线程也是共享的。

threadprivate只能用于全局变量或静态变量,这是很容易理解的,根据其功能。

根据下面的例子,来进一步理解threadprivate的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <omp.h>
int A = 100;
#pragma omp threadprivate(A)

int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel for
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // #2

#pragma omp parallel for
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}
printf("Global A: %d\n",A); // #2
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Thread ID: 0, 0: 101
Thread ID: 0, 1: 102
Thread ID: 0, 2: 103
Thread ID: 3, 8: 101
Thread ID: 3, 9: 102
Thread ID: 1, 3: 101
Thread ID: 1, 4: 102
Thread ID: 1, 5: 103
Thread ID: 2, 6: 101
Thread ID: 2, 7: 102
Global A: 103
Thread ID: 2, 6: 103
Thread ID: 2, 7: 104
Thread ID: 0, 0: 104
Thread ID: 0, 1: 105
Thread ID: 0, 2: 106
Thread ID: 1, 3: 104
Thread ID: 1, 4: 105
Thread ID: 1, 5: 106
Thread ID: 3, 8: 103
Thread ID: 3, 9: 104
Global A: 106

分析结果,发现,第二个并行区域是在第一个并行区域的基础上继续递增的;每一个线程都有自己的全局私有变量。另外,观察在并行区域外的打印的“Globa A”的值可以看出,这个值总是前面的thread 0的结果,这也是预料之中的,因为退出并行区域后,只有master线程运行。

threadprivate指令也有自己的一些子句,就不在此分析了。另外,如果使用的是C++的类,对于类的构造函数也会有类似于lastprivate的一些限制。

总结

private/firstprivate/lastprivate都是子句,用于表示并行区域内的变量的数据范围属性。其中,private表示并行区域team内的每一个线程都会产生一个并行区域外同名变量的共享变量,且和共享变量没有任何关联;firstprivaet在private的基础上,在进入并行区域时(或说每个线程创建时,或副本变量构造时),会使用并行区域外的共享变量进行一次初始化工作;lastprivate在private的基础上,在退出并行区域时,会使用并行区域内的副本的变量,对共享变量进行赋值,由于有多个副本,OpenMP规定了如何确定使用哪个副本进行赋值。另外,private不能和firstprivate/lastprivate混用于同一个变量,firstprivate和lastprivate可以对同一变量使用,效果为两者的结合。

threadprivate是指令,和private的区别在于,private是针对并行区域内的变量的,而threadprivate是针对全局的变量的。

shared/default/copyin/copyprivate子句的使用

shared

shared子句可以用于声明一个或多个变量为共享变量。所谓的共享变量,是在一个并行区域的team内的所有线程只拥有变量的一个内存地址,所有线程访问同一地址。所以,对于并行区域内的共享变量,需要考虑数据竞争条件,要防止竞争,需要增加对应的保护,这是程序员需要自行考虑的。

下面的例子是一个求和的并行实现,使用共享变量,由于没有采取保护,会有数据竞争:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define COUNT	10000

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
#pragma omp parallel for shared(sum)
for(int i = 0; i < COUNT;i++)
{
sum = sum + i;
}
printf("%d\n",sum);
return 0;
}

多次运行,结果可能不一样。

另外,需要注意,循环迭代变量在循环构造区域里是私有的,声明在循环构造区域内的自动变量都是私有的。这一点其实也是比较容易理解的,很难想象,如果循环迭代变量也是共有的,OpenMP该如何去执行,所以也只能是私有的了。即使使用shared来修饰循环迭代变量,也不会改变循环迭代变量在循环构造区域中是私有的这一特点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define COUNT	10

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
#pragma omp parallel for shared(sum, i)
for(i = 0; i < COUNT;i++)
{
sum = sum + i;
}
printf("%d\n",i);
printf("%d\n",sum);
return 0;
}

这个例子能侧面能说明问题,这里的最后输出i是0,并不是0到COUNT之内的一个可能的值,尽管这里使用shared修饰变量i。注意,这里的规则只是针对循环并行区域,对于其他的并行区域没有这样的要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define COUNT	10

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
#pragma omp parallel shared(sum) private(i)
for(i = 0; i < COUNT;i++)
{
sum = sum + i;
}
printf("%d\n",i);
printf("%d\n",sum);
return 0;
}

这里输出的i为0,如果改为shared,那么就是10了。当然,这段代码和上面的求和的代码含义上就是不一样的。

另外,这里顺便一个问题是,在循环并行区域内,循环迭代变量是不可修改的。这也是上面的例子,为何不采用下面的写法:

1
2
3
4
5
6
7
	int i = 0;
#pragma omp parallel for shared(sum) shared(i)
for(i = 0; i < COUNT;i++)
{
i++;
sum = sum + i;
}

这里,i++会报错,原因是在循环并行区域内,迭代变量i是可读不可写的。

default

default指定并行区域内变量的属性,C++的OpenMP中default的参数只能为shared或none,对于Fortran,可以为private等参数,具体参考手册。

default(shared):表示并行区域内的共享变量在不指定的情况下都是shared属性

default(none):表示必须显式指定所有共享变量的数据属性,否则会报错,除非变量有明确的属性定义(比如循环并行区域的循环迭代变量只能是私有的)

另外,如果一个并行区域,没有使用default子句,会是什么情况?实际测试,个人认为,没有使用default,那么其默认行为为default(shared)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define COUNT	10

int main(int argc, _TCHAR* argv[])
{
int sum = 0;
int i = 0;
#pragma omp parallel for
for(i = 0; i < COUNT;i++)
{
sum = sum + i;
}

printf("%d\n",i);
printf("%d\n",sum);
return 0;
}

这里,sum为shared属性,而i的属性不会改变,仍然只能为私有。这里的效果和加上default(shared)是一样的。如果使用default(none),那么编译会报错“没有给sum指定数据共享属性”,不会为变量i报错,因为i是有明确的含义的,只能为私有。

copyin

copyin子句用于将主线程中threadprivate变量的值拷贝到执行并行区域的各个线程的threadprivate变量中,从而使得team内的子线程都拥有和主线程同样的初始值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <omp.h>  
int A = 100;
#pragma omp threadprivate(A)

int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel for
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}

printf("Global A: %d\n",A); // #2

#pragma omp parallel for copyin(A)
for(int i = 0; i<10;i++)
{
A++;
printf("Thread ID: %d, %d: %d\n",omp_get_thread_num(), i, A); // #1
}

printf("Global A: %d\n",A); // #2

return 0;
}

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Thread ID: 0, 0: 101
Thread ID: 0, 1: 102
Thread ID: 0, 2: 103
Thread ID: 3, 8: 101
Thread ID: 3, 9: 102
Thread ID: 1, 3: 101
Thread ID: 1, 4: 102
Thread ID: 1, 5: 103
Thread ID: 2, 6: 101
Thread ID: 2, 7: 102
Global A: 103

Thread ID: 2, 6: 104
Thread ID: 2, 7: 105
Thread ID: 1, 3: 104
Thread ID: 1, 4: 105
Thread ID: 1, 5: 106
Thread ID: 3, 8: 104
Thread ID: 3, 9: 105
Thread ID: 0, 0: 104
Thread ID: 0, 1: 105
Thread ID: 0, 2: 106

运行此程序,得到的结果和不使用copyin的结果是不一样的。不使用copyin的情况下,进入第二个并行区域的时候,不同线程的私有副本A的初始值是不一样的,这里使用了copyin之后,发现所有的线程的初始值都使用主线程的值初始化,然后继续运算。

为了更好的理解copyin,分析下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

#include <omp.h>
int A = 100;
#pragma omp threadprivate(A)

int main(int argc, _TCHAR* argv[])
{
#pragma omp parallel
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}

printf("Global A: %d\n",A);

#pragma omp parallel copyin(A) // copyin
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}

printf("Global A: %d\n",A);

#pragma omp parallel // Will not copy, to check the result.
{
printf("Initial A = %d\n", A);
A = omp_get_thread_num();
}

printf("Global A: %d\n",A);

return 0;

得到输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Initial A = 100
Initial A = 100
Initial A = 100
Initial A = 100
Global A: 0
Initial A = 0
Initial A = 0
Initial A = 0
Initial A = 0
Global A: 0
Initial A = 0
Initial A = 3
Initial A = 2
Initial A = 1
Global A: 1

简单理解,在使用了copyin后,所有的线程的threadprivate类型的副本变量都会与主线程的副本变量进行一次“同步”。

另外,copyin中的参数必须被声明成threadprivate的,对于类类型的变量,必须带有明确的拷贝赋值操作符。而且,对于第一个并行区域,是默认含有copyin的功能的(比如上面的例子的前面的四个A的输出都是100)。copyin的一个可能需要用到的情况是,比如程序中有多个并行区域,每个线程希望保存一个私有的全局变量,但是其中某一个并行区域执行前,希望与主线程的值相同,就可以利用copyin进行赋值。

copyprivate

copyprivate子句用于将线程私有副本变量的值从一个线程广播到执行同一并行区域的其他线程的同一变量。

说明:copyprivate只能用于single指令的子句中,在一个single块的结尾处完成广播操作。copyprivate只能用于private/firstprivate或threadprivate修饰的变量。

根据下面的程序,可以理解copyprivate的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <omp.h>  
int A = 100;
#pragma omp threadprivate(A)

int main(int argc, _TCHAR* argv[])
{
int B = 100;
int C = 1000;
#pragma omp parallel firstprivate(B) copyin(A) // copyin(A) can be ignored!
{
#pragma omp single copyprivate(A) copyprivate(B)// copyprivate(C) // C is shared, cannot use copyprivate!
{
A = 10;
B = 20;
}
printf("Initial A = %d\n", A); // 10 for all threads
printf("Initial B = %d\n", B); // 20 for all threads
}

printf("Global A: %d\n",A); // 10
printf("Global A: %d\n",B); // 100. B is still 100! Will not be affected here!

return 0;
}

reduction子句

reduction的作用: A private copy for each list variable is created for each thread. At the end of the reduction, the reduction variable is applied to all private copies of the shared variable, and the final result is written to the global shared variable.

reduction子句为变量指定一个操作符,每个线程都会创建reduction变量的私有拷贝,在OpenMP区域结束处,将使用各个线程的私有拷贝的值通过制定的操作符进行迭代运算,并赋值给原来的变量。

reduction的语法为recutioin(operator:list)和其他的数据属性子句不一样的是多了一个operator参数。由于最后会进行迭代运算,所以不是所有的运算符都能作为reduction的参数,而且,迭代运算需要一个初始值,不是所有的操作符需要有相同的初始值,一般而言,常见的reduction操作符的初始值为:+(0),*(1),-(0),&~(0),|(0),^(0),&&(1),||(0),当然,这不是必须的,比如叠加运算的初始值,可以是任意值,只是表达的含义不一样而已,但是对于某些操作符,有些初始值是没有什么意义的,比如乘法迭代如果初始值为0没有什么意义,结果肯定是0了!

典型的使用reduction的例子,就是迭加(求和)操作了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

#include <omp.h>
#define COUNT 10

int main(int argc, _TCHAR* argv[])
{
int sum = 100; // Assign an initial value.
#pragma omp parallel for reduction(+:sum)
for(int i = 0;i < COUNT; i++)
{
sum += i;
}
printf("Sum: %d\n",sum);

return 0;
}

这个例子就是对0到COUNT进行求和,由于初始值为100,所以还会加一个100,如果只是为了求和,只需要初始值为0即可。使用reduction可以避免数据竞争的发生,将上面例子的COUNT修改为一个比较大的值,如果不使用reduction,会发现有数据竞争导致结果不一致,使用reduction后,每次都能得到正确的结果。

reduction的使用是比较简单的,主要还是需要理解上面说到的“初始值”,第一个理解是这里的100这样的初始值,这是并行区域外的初始值,会在最后计算到迭代结果中,那么还有一个隐含的初始值,就是我们知道,使用了reduction,那么每个线程都会构造一个reduction变量的线程副本,那么其值为多少呢?从上面的例子可以看出,其初始值就是0,如果初始值都是100,那么结果应该是100会被加线程数目的次数。初始值的确定方法就是上面提到的:+(0),*(1),-(0),&~(0),|(0),^(0),&&(1),||(0)。

所以,理解reduction的工作过程:

  1. 进入并行区域后,team内的每个新的线程都会对reduction变量构造一个副本,比如上面的例子,假设有四个线程,那么,进入并行区域的初始化值分别为:sum0=100,sum1 = sum2 = sum3 = 0。为何sum0为100呢?因为主线程不是一个新的线程,所以不需要再为主线程构造一个副本(没有找到官方这样的说法,但是从理解上,应该就是这样工作的,只会有一个线程使用到并行区域外的初始值,其余的都是0)。
  2. 每个线程使用自己的副本变量完成计算。
  3. 在退出并行区域时,对所有的线程的副本变量使用指定的操作符进行迭代操作,对于上面的例子,即sum’ = sum0’+sum1’+sum2’+sum3’.
  4. 将迭代的结果赋值给原来的变量(sum),sum=sum’.

注意:

  • reduction只能用于标量类型(int、float等等);
  • reduction只用于一个区域构造或者工作共享构造的结构中,并且,在这个区域中,reduction的变量只能被用于类似如下的语句:
1
2
3
4
5
6
7
x = x op expr 
x = expr op x (except subtraction)
x binop = expr
x++
++x
x--
--x

说明:经过测试,其实不符合这一规则的时候,编译运行都不会有问题,有些甚至也是可以解释清楚为什么结果是这样的,但是无论如何,一般使用reduction的时候,都是一些迭代的情况,语义应该是很清楚的情况。看下面的一个“错误”的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define COUNT 10

int main(int argc, _TCHAR* argv[])
{
int sum = 100; // Assign an initial value.
#pragma omp parallel for reduction(+:sum)
for(int i = 0;i < COUNT; i++)
{
sum += i;
sum = 1;
}
printf("Sum: %d\n",sum);

return 0;
}

输出结果为104(4核机器)。这个例子,sum=1;这个表达式是不应该出现的,但是如果就这么写,编译运行都没问题,而且,这个结果甚至也算是预料中的。每一个线程计算结束后,其sum的值都是1,四个线程,然后初始值是100,所以最后结果是104。:) 无论如何,即使可以解释得通,相信也没有这样使用的场合,至少,不要依赖于这样的实现的结果。从这个错误的例子,反过来,我发现上面的关于”理解reduction的工作过程“似乎不太完全正确,其中第一步,进入并行区域后,初始值为”sum0=100,sum1 = sum2 = sum3 = 0“,如果这样,只是一个初始值,那么计算后,在这个例子里,所有线程的sum都是1,结果应该为4才对。所以看来,实际的理解应该是,主线程也会创建一个副本变量,其初始值也为0,在最后迭代的时候,是用sum原来的值和每个线程的副本进行计算。过程大概如下:

  1. sum=100
  2. 进入并行区域,创建4个线程的4个副本:sum0=sum1=sum2=sum3=0;
  3. 计算完成后,得到sum0’,sum1’,sum2’,sum3’
  4. 计算sum,sum=sum op sum 0‘ op sum1’ op sum2‘ op sum3’。

总之,具体编译器是如何实现的并不重要,关键是理解reduction是如何工作的。

OpenMP参考文档

基础知识

首先,我们都知道现在的 CPU 多核技术,都会有几级缓存,老的 CPU 会有两级内存(L1 和 L2),新的CPU会有三级内存(L1,L2,L3 ),如下图所示:

其中:

  • L1 缓存分成两种,一种是指令缓存,一种是数据缓存。L2 缓存和 L3 缓存不分指令和数据。
  • L1 和 L2 缓存在每一个 CPU 核中,L3 则是所有 CPU 核心共享的内存。
  • L1、L2、L3 的越离CPU近就越小,速度也越快,越离 CPU 远,速度也越慢。

再往后面就是内存,内存的后面就是硬盘。我们来看一些他们的速度:

  • L1 的存取速度:4 个CPU时钟周期
  • L2 的存取速度:11 个CPU时钟周期
  • L3 的存取速度:39 个CPU时钟周期
  • RAM内存的存取速度 :107 个CPU时钟周期

我们可以看到,L1 的速度是 RAM 的 27 倍,但是 L1/L2 的大小基本上也就是 KB 级别的,L3 会是 MB 级别的。例如:Intel Core i7-8700K ,是一个 6 核的 CPU,每核上的 L1 是 64KB(数据和指令各 32KB),L2 是 256K,L3 有 2MB(我的苹果电脑是 Intel Core i9-8950HK,和Core i7-8700K 的Cache大小一样)。

我们的数据就从内存向上,先到 L3,再到 L2,再到 L1,最后到寄存器进行 CPU 计算。为什么会设计成三层?这里有下面几个方面的考虑:

  • 一个方面是物理速度,如果要更大的容量就需要更多的晶体管,除了芯片的体积会变大,更重要的是大量的晶体管会导致速度下降,因为访问速度和要访问的晶体管所在的位置成反比,也就是当信号路径变长时,通信速度会变慢。这部分是物理问题。
  • 另外一个问题是,多核技术中,数据的状态需要在多个CPU中进行同步,并且,我们可以看到,cache 和RAM 的速度差距太大,所以,多级不同尺寸的缓存有利于提高整体的性能。

这个世界永远是平衡的,一面变得有多光鲜,另一面也会变得有多黑暗。建立这么多级的缓存,一定就会引入其它的问题,这里有两个比较重要的问题,一个是比较简单的缓存的命中率的问题。另一个是比较复杂的缓存更新的一致性问题。尤其是第二个问题,在多核技术下,这就很像分布式的系统了,要对多个地方进行更新。

缓存的命中

在说明这两个问题之前。我们需要要解一个术语 Cache Line。缓存基本上来说就是把后面的数据加载到离自己近的地方,对于 CPU 来说,它是不会一个字节一个字节的加载的,因为这非常没有效率,一般来说都是要一块一块的加载的,对于这样的一块一块的数据单位,术语叫 Cache Line,

一般来说,一个主流的 CPU 的 Cache Line 是 64 Bytes(也有的CPU用32Bytes和128Bytes),64 Bytes也就是 16 个 32 位的整型,这就是 CPU 从内存中捞数据上来的最小数据单位。

比如:Cache Line是最小单位(64Bytes),所以先把 Cache 分布多个 Cache Line,比如:L1 有 32KB,那么,32KB/64B = 512 个 Cache Line。

一方面,缓存需要把内存里的数据放到放进来,英文叫 CPU Associativity。Cache 的数据放置的策略决定了内存中的数据块会拷贝到 CPU Cache 中的哪个位置上,因为 Cache 的大小远远小于内存,所以,需要有一种地址关联的算法,能够让内存中的数据可以被映射到 Cache 中来。这个有点像内存地址从逻辑地址向物理地址映射的方法,但不完全一样。

基本上来说,我们会有如下的一些方法。

  • 一种方法是,任何一个内存地址的数据可以被缓存在任何一个 Cache Line 里,这种方法是最灵活的,但是,如果我们要知道一个内存是否存在于 Cache 中,我们就需要进行 O(n) 复杂度的 Cache 遍历,这是很没有效率的。
  • 另一种方法,为了降低缓存搜索算法,我们需要使用像Hash Table这样的数据结构,最简单的hash table就是做求模运算,比如:我们的 L1 Cache 有 512 个 Cache Line,那么,公式:(内存地址 mod 512)* 64 就可以直接找到所在的Cache地址的偏移了。但是,这样的方式需要我们的程序对内存地址的访问要非常地平均,不然冲突就会非常严重。这成了一种非常理想的情况了。

为了避免上述的两种方案的问题,于是就要容忍一定的hash冲突,也就出现了 N-Way 关联。也就是把连续的N 个 Cache Line 绑成一组,然后,先把找到相关的组,然后再在这个组内找到相关的 Cache Line。这叫 Set Associativity。如下图所示。

对于 N-Way 组关联,可能有点不好理解,这里个例子,并多说一些细节(不然后面的代码你会不能理解),Intel 大多数处理器的 L1 Cache 都是 32KB,8-Way 组相联,Cache Line 是 64 Bytes。这意味着,

  • 32KB的可以分成,32KB / 64 = 512 条 Cache Line。
  • 因为有8 Way,于是会每一Way 有 512 / 8 = 64 条 Cache Line。
  • 于是每一路就有 64 x 64 = 4096 Byts 的内存。

为了方便索引内存地址,

  • Tag:每条 Cache Line 前都会有一个独立分配的 24 bits来存的 tag,其就是内存地址的前24bits
  • Index:内存地址后续的 6 个 bits 则是在这一 Way 的是Cache Line 索引,2^6 = 64 刚好可以索引64条Cache Line
  • Offset:再往后的 6bits 用于表示在 Cache Line 里的偏移量

如下图所示:(图片来自《Cache: a place for concealment and safekeeping》)

当拿到一个内存地址的时候,先拿出中间的 6bits 来,找到是哪组。

然后,在这一个 8 组的 cache line 中,再进行 O(n) n=8 的遍历,主是要匹配前 24bits 的 tag。如果匹配中了,就算命中,如果没有匹配到,那就是 cache miss,如果是读操作,就需要进向后面的缓存进行访问了。

L2/L3 同样是这样的算法。而淘汰算法有两种,一种是随机一种是 LRU。现在一般都是以 LRU 的算法(通过增加一个访问计数器来实现)

这也意味着:

  • L1 Cache 可映射 36bits 的内存地址,一共 2^36 = 64GB 的内存
  • 当 CPU 要访问一个内存的时候,通过这个内存中间的 6bits 定位是哪个 set,通过前 24bits 定位相应的Cache Line。
  • 就像一个 hash Table 的数据结构一样,先是 O(1)的索引,然后进入冲突搜索。
  • 因为中间的 6bits 决定了一个同一个 set,所以,对于一段连续的内存来说,每隔 4096 的内存会被放在同一个组内,导致缓存冲突。

此外,当有数据没有命中缓存的时候,CPU 就会以最小为 Cache Line 的单元向内存更新数据。当然,CPU 并不一定只是更新 64Bytes,因为访问主存实在是太慢了,所以,一般都会多更新一些。好的 CPU 会有一些预测的技术,如果找到一种 pattern 的话,就会预先加载更多的内存,包括指令也可以预加载。

这叫 Prefetching 技术 (参看,Wikipedia 的 Cache Prefetching 和 纽约州立大学的 Memory Prefetching)。比如,你在for-loop访问一个连续的数组,你的步长是一个固定的数,内存就可以做到prefetching。(注:指令也是以预加载的方式执行)

了解这些细节,会有利于我们知道在什么情况下有可以导致缓存的失效。

缓存的一致性

对于主流的 CPU 来说,缓存的写操作基本上是两种策略,

  • 一种是 Write Back,写操作只要在 cache 上,然后再 flush 到内存上。
  • 一种是 Write Through,写操作同时写到 cache 和内存上。

为了提高写的性能,一般来说,主流的 CPU(如:Intel Core i7/i9)采用的是 Write Back 的策略,因为直接写内存实在是太慢了。

好了,现在问题来了,如果有一个数据 x 在 CPU 第 0 核的缓存上被更新了,那么其它 CPU 核上对于这个数据 x 的值也要被更新,这就是缓存一致性的问题。(当然,对于我们上层的程序我们不用关心 CPU 多个核的缓存是怎么同步的,这对上层的代码来说都是透明的)

一般来说,在 CPU 硬件上,会有两种方法来解决这个问题。

  • Directory 协议。这种方法的典型实现是要设计一个集中式控制器,它是主存储器控制器的一部分。其中有一个目录存储在主存储器中,其中包含有关各种本地缓存内容的全局状态信息。当单个 CPU Cache 发出读写请求时,这个集中式控制器会检查并发出必要的命令,以在主存和 CPU Cache之间或在 CPU Cache自身之间进行数据同步和传输。
  • Snoopy 协议。这种协议更像是一种数据通知的总线型的技术。CPU Cache 通过这个协议可以识别其它Cache上的数据状态。如果有数据共享的话,可以通过广播机制将共享数据的状态通知给其它 CPU Cache。这个协议要求每个 CPU Cache 都可以窥探数据事件的通知并做出相应的反应。如下图所示,有一个 Snoopy Bus 的总线。

因为 Directory 协议是一个中心式的,会有性能瓶颈,而且会增加整体设计的复杂度。而 Snoopy 协议更像是微服务+消息通讯,所以,现在基本都是使用 Snoopy 的总线的设计。

这里,我想多写一些细节,因为这种微观的东西,让人不自然地就会跟分布式系统关联起来,在分布式系统中我们一般用 Paxos/Raft 这样的分布式一致性的算法。

而在 CPU 的微观世界里,则不必使用这样的算法,原因是因为 CPU 的多个核的硬件不必考虑网络会断会延迟的问题。所以,CPU 的多核心缓存间的同步的核心就是要管理好数据的状态就好了。

这里介绍几个状态协议,先从最简单的开始,MESI 协议,这个协议跟那个著名的足球运动员梅西没什么关系,其主要表示缓存数据有四个状态:Modified(已修改), Exclusive(独占的),Shared(共享的),Invalid(无效的)。

这些状态的状态机如下所示(有点复杂,你可以先不看,这个图就是想告诉你状态控制有多复杂):

下面是个示例

当前操作 CPU0 CPU1 Memory 说明
1) CPU0 read(x) x=1 (E) x=1 只有一个CPU有 x 变量, 所以,状态是 Exclusive
2) CPU1 read(x) x=1 (S) x=1(S) x=1 有两个CPU都读取 x 变量, 所以状态变成 Shared
3) CPU0 write(x,9) x=9 (M) x=1(I) x=1 变量改变,在CPU0中状态 变成 Modified,在CPU1中 状态变成 Invalid
4) 变量 x 写回内存 x=9 (M) X=1(I) x=9 目前的状态不变
5) CPU1 read(x) x=9 (S) x=9(S) x=9 变量同步到所有的Cache中, 状态回到Shared

MESI 这种协议在数据更新后,会标记其它共享的 CPU 缓存的数据拷贝为 Invalid 状态,然后当其它 CPU 再次read 的时候,就会出现 cache miss 的问题,此时再从内存中更新数据。从内存中更新数据意味着 20 倍速度的降低。

我们能不能直接从我隔壁的 CPU 缓存中更新?是的,这就可以增加很多速度了,但是状态控制也就变麻烦了。还需要多来一个状态:Owner(宿主),用于标记,我是更新数据的源。于是,出现了 MOESI 协议

MOESI 协议的状态机和演示示例我就不贴了(有兴趣可以上Berkeley上看看相关的课件),我们只需要理解MOESI协议允许 CPU Cache 间同步数据,于是也降低了对内存的操作,性能是非常大的提升,但是控制逻辑也非常复杂。

顺便说一下,与 MOESI 协议类似的一个协议是 MESIF,其中的 F 是 Forward,同样是把更新过的数据转发给别的 CPU Cache 但是,MOESI 中的 Owner 状态 和MESIF 中的 Forward 状态有一个非常大的不一样—— Owner 状态下的数据是 dirty 的,还没有写回内存,Forward 状态下的数据是 clean的,可以丢弃而不用另行通知。

需要说明的是,AMD 用 MOESI,Intel 用 MESIF。所以,F 状态主要是针对 CPU L3 Cache 设计的(前面我们说过,L3 是所有 CPU 核心共享的)。(相关的比较可以参看StackOverlow上这个问题的答案)

程序性能

了解了我们上面的这些东西后,我们来看一下对于程序的影响。

示例一

首先,假设我们有一个64M长的数组,设想一下下面的两个循环:

1
2
3
4
5
6
const int LEN = 64*1024*1024;
int *arr = new int[LEN];

for (int i = 0; i < LEN; i += 2) arr[i] *= i;

for (int i = 0; i < LEN; i += 8) arr[i] *= i;

按我们的想法来看,第二个循环要比第一个循环少4倍的计算量,其应该也是要快4倍的。但实际跑下来并不是,在我的机器上,第一个循环需要 127 毫秒,第二个循环则需要 121 毫秒,相差无几。

这里最主要的原因就是 Cache Line,因为 CPU 会以一个 Cache Line 64Bytes 最小时单位加载,也就是 16 个 32bits 的整型,所以,无论你步长是 2 还是 8,都差不多。而后面的乘法其实是不耗 CPU 时间的。

示例二

我们再来看一个与缓存命中率有关的代码,我们以一定的步长increment 来访问一个连续的数组。

1
2
3
4
5
for (int i = 0; i < 10000000; i++) {
for (int j = 0; j < size; j += increment) {
memory[j] += j;
}
}

我们测试一下,在下表中, 表头是步长,也就是每次跳多少个整数,而纵向是这个数组可以跳几次(你可以理解为要几条 Cache Line),于是表中的任何一项代表了这个数组有多少,而且步长是多少。

比如:横轴是 512,纵轴是4,意思是,这个数组有 4*512 = 2048 个长度,访问时按512步长访问,也就是访问其中的这几项:[0, 512, 1024, 1536] 这四项。

表中同的项是,是循环 1000 万次的时间,单位是“微秒”(除以1000后是毫秒)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| count |   1    |   16  |  512  | 1024  |
------------------------------------------
| 1 | 17539 | 16726 | 15143 | 14477 |
| 2 | 15420 | 14648 | 13552 | 13343 |
| 3 | 14716 | 14463 | 15086 | 17509 |
| 4 | 18976 | 18829 | 18961 | 21645 |
| 5 | 23693 | 23436 | 74349 | 29796 |
| 6 | 23264 | 23707 | 27005 | 44103 |
| 7 | 28574 | 28979 | 33169 | 58759 |
| 8 | 33155 | 34405 | 39339 | 65182 |
| 9 | 37088 | 37788 | 49863 |156745 |
| 10 | 41543 | 42103 | 58533 |215278 |
| 11 | 47638 | 50329 | 66620 |335603 |
| 12 | 49759 | 51228 | 75087 |305075 |
| 13 | 53938 | 53924 | 77790 |366879 |
| 14 | 58422 | 59565 | 90501 |466368 |
| 15 | 62161 | 64129 | 90814 |525780 |
| 16 | 67061 | 66663 | 98734 |440558 |
| 17 | 71132 | 69753 |171203 |506631 |
| 18 | 74102 | 73130 |293947 |550920 |

我们可以看到,从 [9,1024] 以后,时间显著上升。包括 [17,512] 和 [18,512] 也显著上升。这是因为,我机器的 L1 Cache 是 32KB, 8 Way 的,前面说过,8 Way 的有 64 组,每组 8 个 Cache Line,当 for-loop步长超过 1024 个整型,也就是正好 4096 Bytes 时,也就是导致内存地址的变化是变化在高位的 24bits 上,

而低位的1 2bits 变化不大,尤其是中间6bits没有变化,导致全部命中同一组 set,导致大量的 cache 冲突,导致性能下降,时间上升。而 [16, 512]也是一样的,其中的几步开始导致L1 Cache开始冲突失效。

示例三

接下来,我们再来看个示例。下面是一个二维数组的两种遍历方式,一个逐行遍历,一个是逐列遍历,这两种方式在理论上来说,寻址和计算量都是一样的,执行时间应该也是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const int row = 1024;
const int col = 512
int matrix[row][col];

//逐行遍历
int sum_row=0;
for(int _r=0; _r<row; _r++) {
for(int _c=0; _c<col; _c++){
sum_row += matrix[_r][_c];
}
}

//逐列遍历
int sum_col=0;
for(int _c=0; _c<col; _c++) {
for(int _r=0; _r<row; _r++){
sum_col += matrix[_r][_c];
}
}

然而,并不是,在我的机器上,得到下面的结果。

  • 逐行遍历:0.081ms
  • 逐列遍历:1.069ms

执行时间有十几倍的差距。其中的原因,就是逐列遍历对于 CPU Cache 的运作方式并不友好,所以,付出巨大的代价。

示例四

接下来,我们来看一下多核下的性能问题,参看如下的代码。两个线程在操作一个数组的两个不同的元素(无需加锁),线程循环1000万次,做加法操作。在下面的代码中,我高亮了一行,就是p2指针,要么是p[1],或是 p[30],理论上来说,无论访问哪两个数组元素,都应该是一样的执行时间。

1
2
3
4
5
6
7
8
9
10
11
12
void fn (int* data) {
for(int i = 0; i < 10*1024*1024; ++i)
*data += rand();
}

int p[32];

int *p1 = &p[0];
int *p2 = &p[1]; // int *p2 = &p[30];

thread t1(fn, p1);
thread t2(fn, p2);

然而,并不是,在我的机器上执行下来的结果是:

  • 对于 p[0] 和 p[1] :560ms
  • 对于 p[0] 和 p[30]:104ms

这是因为 p[0] 和 p[1] 在同一条 Cache Line 上,而 p[0] 和 p[30] 则不可能在同一条Cache Line 上 ,CPU 的缓存最小的更新单位是 Cache Line,所以,这导致虽然两个线程在写不同的数据,但是因为这两个数据在同一条 Cache Line 上,就会导致缓存需要不断进在两个 CPU 的 L1/L2 中进行同步,从而导致了 5 倍的时间差异。

示例五

接下来,我们再来看一下另外一段代码:我们想统计一下一个数组中的奇数个数,但是这个数组太大了,我们希望可以用多线程来完成这个统计。下面的代码中,我们为每一个线程传入一个 id ,然后通过这个 id 来完成对应数组段的统计任务。这样可以加快整个处理速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int total_size = 16 * 1024 * 1024; //数组长度
int* test_data = new test_data[total_size]; //数组
int nthread = 6; //线程数(因为我的机器是6核的)
int result[nthread]; //收集结果的数组

void thread_func (int id) {
result[id] = 0;
int chunk_size = total_size / nthread + 1;
int start = id * chunk_size;
int end = min(start + chunk_size, total_size);

for ( int i = start; i < end; ++i ) {
if (test_data[i] % 2 != 0 ) ++result[id];
}
}

然而,在执行过程中,你会发现,6 个线程居然跑不过 1 个线程。因为根据上面的例子你知道 result[] 这个数组中的数据在一个 Cache Line 中,所以,所有的线程都会对这个 Cache Line 进行写操作,导致所有的线程都在不断地重新同步 result[] 所在的 Cache Line,所以,导致 6 个线程还跑不过一个线程的结果。这叫 False Sharing。

优化也很简单,使用一个线程内的变量。

1
2
3
4
5
6
7
8
9
10
11
12
void thread_func (int id) {
result[id] = 0;
int chunk_size = total_size / nthread + 1;
int start = id * chunk_size;
int end = min(start + chunk_size, total_size);

int c = 0; //使用临时变量,没有cache line的同步了
for ( int i = start; i < end; ++i ) {
if (test_data[i] % 2 != 0 ) ++c;
}
result[id] = c;
}

我们把两个程序分别在 1 到 32 个线程上跑一下,得出的结果画一张图如下所示(横轴是线程数,纵轴是完成统的时间,单位是微秒):

上图中,我们可以看到,灰色的曲线就是第一种方法,橙色的就是第二种(用局部变量的)方法。当只有一个线程的时候,两个方法相当,基本没有什么差别,但是在线程数增加的时候的时候,你会发现,第二种方法的性能提高的非常快。直到到达 6 个线程的时候,开始变得稳定(前面说过,我的 CPU 是6核的)。

而第一种方法无论加多少线程也没有办法超过第二种方法。因为第一种方法不是 CPU Cache 友好的。也就是说,第二种方法,只要我的 CPU 核数足够多,就可以做到线性的性能扩展,让每一个 CPU 核都跑起来,而第一种则不能。

概述

Raft 是什么?

Raft is a consensus algorithm for managing a replicated log. It produces a result equivalent to (multi-)Paxos, and it is as efficient as Paxos, but its structure is different from Paxos; this makes Raft more understandable than Paxos and also provides a better foundation for building practical systems.

—《In Search of an Understandable Consensus Algorithm》

在分布式系统中,为了消除单点提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性?

这里我们只讨论强一致性,即线性一致性。弱一致性涵盖的范围较广,涉及根据实际场景进行诸多取舍,不在 Raft 系列的讨论目标范围内。

所谓的强一致性(线性一致性)并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,我们可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。

共识算法(Consensus Algorithm)就是用来做这个事情的,它保证即使在小部分(≤ (N-1)/2)节点故障的情况下,系统仍然能正常对外提供服务。共识算法通常基于状态复制机(Replicated State Machine)模型,也就是所有节点从同一个 state 出发,经过同样的操作 log,最终达到一致的 state。

共识算法是构建强一致性分布式系统的基石,Paxos 是共识算法的代表,而 Raft 则是其作者在博士期间研究 Paxos 时提出的一个变种,主要优点是容易理解、易于实现,甚至关键的部分都在论文中给出了伪代码实现。

谁在使用 Raft

采用 Raft 的系统最著名的当属 etcd 了,可以认为 etcd 的核心就是 Raft 算法的实现。作为一个分布式 kv 系统,etcd 使用 Raft 在多节点间进行数据同步,每个节点都拥有全量的状态机数据。我们在学习了 Raft 以后将会深刻理解为什么 etcd 不适合大数据量的存储(for the most critical data)、为什么集群节点数不是越多越好、为什么集群适合部署奇数个节点等问题。

作为一个微服务基础设施,consul 底层使用 Raft 来保证 consul server 之间的数据一致性。在阅读完第六章后,我们会理解为什么 consul 提供了 default、consistent、stale 三种一致性模式(Consistency Modes)、它们各自适用的场景,以及 consul 底层是如何通过改变 Raft 读模型来支撑这些不同的一致性模式的。

TiKV 同样在底层使用了 Raft 算法。虽然都自称是“分布式 kv 存储”,但 TiKV 的使用场景与 etcd 存在区别。其目标是支持 100TB+ 的数据,类似 etcd 的单 Raft 集群肯定无法支撑这个数据量。因此 TiKV 底层使用 Multi Raft,将数据划分为多个 region,每个 region 其实还是一个标准的 Raft 集群,对每个分区的数据实现了多副本高可用。

目前 Raft 在工业界已经开始大放异彩,对于其各类应用场景这里不再赘述,感兴趣的读者可以参考 这里,下方有列出各种语言的大量 Raft 实现。

Raft 基本概念

Raft 使用 Quorum 机制来实现共识和容错,我们将对 Raft 集群的操作称为提案,每当发起一个提案,必须得到大多数(> N/2)节点的同意才能提交。

这里的“提案”我们可以先狭义地理解为对集群的读写操作,“提交”理解为操作成功。

那么当我们向 Raft 集群发起一系列读写操作时,集群内部究竟发生了什么呢?我们先来概览式地做一个整体了解,接下来再分章节详细介绍每个部分。

首先,Raft 集群必须存在一个主节点(leader),我们作为客户端向集群发起的所有操作都必须经由主节点处理。所以 Raft 核心算法中的第一部分就是选主(Leader election)——没有主节点集群就无法工作,先票选出一个主节点,再考虑其它事情。

其次,主节点需要承载什么工作呢?它会负责接收客户端发过来的操作请求,将操作包装为日志同步给其它节点,在保证大部分节点都同步了本次操作后,就可以安全地给客户端回应响应了。这一部分工作在 Raft 核心算法中叫日志复制(Log replication)。

然后,因为主节点的责任是如此之大,所以节点们在选主的时候一定要谨慎,只有符合条件的节点才可以当选主节点。此外主节点在处理操作日志的时候也一定要谨慎,为了保证集群对外展现的一致性,不可以覆盖或删除前任主节点已经处理成功的操作日志。所谓的“谨慎处理”,其实就是在选主和提交日志的时候进行一些限制,这一部分在 Raft 核心算法中叫安全性(Safety)。

Raft 核心算法其实就是由这三个子问题组成的:选主(Leader election)、日志复制(Log replication)、安全性(Safety)。这三部分共同实现了 Raft 核心的共识和容错机制。

除了核心算法外,Raft 也提供了几个工程实践中必须面对问题的解决方案。

  • 第一个是关于日志无限增长的问题。Raft 将操作包装成为了日志,集群每个节点都维护了一个不断增长的日志序列,状态机只有通过重放日志序列来得到。但由于这个日志序列可能会随着时间流逝不断增长,因此我们必须有一些办法来避免无休止的磁盘占用和过久的日志重放。这一部分叫日志压缩(Log compaction)。
  • 第二个是关于集群成员变更的问题。一个 Raft 集群不太可能永远是固定几个节点,总有扩缩容的需求,或是节点宕机需要替换的时候。直接更换集群成员可能会导致严重的脑裂问题。Raft 给出了一种安全变更集群成员的方式。这一部分叫集群成员变更(Cluster membership change)。

此外,我们还会额外讨论线性一致性的定义、为什么 Raft 不能与线性一致划等号、如何基于 Raft 实现线性一致,以及在如何保证线性一致的前提下进行读性能优化。

以上便是理论篇内将会讨论到的大部分内容的概要介绍,这里我们对 Raft 已经有了一个宏观上的认识,知道了各个部分大概是什么内容,以及它们之间的关系。接下来我们将会详细讨论 Raft 算法的每个部分。让我们先从第一部分选主开始。

选主

什么是选主

选主(Leader election)就是在分布式系统内抉择出一个主节点来负责一些特定的工作。在执行了选主过程后,集群中每个节点都会识别出一个特定的、唯一的节点作为 leader。

我们开发的系统如果遇到选主的需求,通常会直接基于 zookeeper 或 etcd 来做,把这部分的复杂性收敛到第三方系统。然而作为 etcd 基础的 Raft 自身也存在“选主”的概念,这是两个层面的事情:基于 etcd 的选主指的是利用第三方 etcd 让集群对谁做主节点的决策达成一致,技术上来说利用的是 etcd 的一致性状态机、lease 以及 watch 机制,这个事情也可以改用单节点的 MySQL/Redis 来做,只是无法获得高可用性;而 Raft 本身的选主则指的是在 Raft 集群自身内部通过票选、心跳等机制来协调出一个大多数节点认可的主节点作为集群的 leader 去协调所有决策。

当你的系统利用 etcd 来写入谁是主节点的时候,这个决策也在 etcd 内部被它自己集群选出的主节点处理并同步给其它节点。

Raft 为什么要进行选主?

按照论文所述,原生的 Paxos 算法使用了一种点对点(peer-to-peer)的方式,所有节点地位是平等的。在理想情况下,算法的目的是制定一个决策,这对于简化的模型比较有意义。但在工业界很少会有系统会使用这种方式,当有一系列的决策需要被制定的时候,先选出一个 leader 节点然后让它去协调所有的决策,这样算法会更加简单快速。

此外,和其它一致性算法相比,Raft 赋予了 leader 节点更强的领导力,称之为 Strong Leader。比如说日志条目只能从 leader 节点发送给其它节点而不能反着来,这种方式简化了日志复制的逻辑,使 Raft 变得更加简单易懂。

Raft 选主过程

节点角色

Raft 集群中每个节点都处于以下三种角色之一:

  • Leader: 所有请求的处理者,接收客户端发起的操作请求,写入本地日志后同步至集群其它节点。
  • Follower: 请求的被动更新者,从 leader 接收更新请求,写入本地文件。如果客户端的操作请求发送给了 follower,会首先由 follower 重定向给 leader。
  • Candidate: 如果 follower 在一定时间内没有收到 leader 的心跳,则判断 leader 可能已经故障,此时启动 leader election 过程,本节点切换为 candidate 直到选主结束。

任期

每开始一次新的选举,称为一个任期(term),每个 term 都有一个严格递增的整数与之关联。每当 candidate 触发 leader election 时都会增加 term,如果一个 candidate 赢得选举,他将在本 term 中担任 leader 的角色。但并不是每个 term 都一定对应一个 leader,有时候某个 term 内会由于选举超时导致选不出 leader,这时 candicate 会递增 term 号并开始新一轮选举。

Term 更像是一个逻辑时钟(logic clock)的作用,有了它,就可以发现哪些节点的状态已经过期。每一个节点都保存一个 current term,在通信时带上这个 term 号。节点间通过 RPC 来通信,主要有两类 RPC 请求:

  • RequestVote RPCs: 用于 candidate 拉票选举。
  • AppendEntries RPCs: 用于 leader 向其它节点复制日志以及同步心跳。

节点状态转换

我们知道集群每个节点的状态都只能是 leader、follower 或 candidate,那么节点什么时候会处于哪种状态呢?下图展示了一个节点可能发生的状态转换:

接下来我们详细讨论下每个转换所发生的场景。

Follower 状态转换过程

Raft 的选主基于一种心跳机制,集群中每个节点刚启动时都是 follower 身份(Step: starts up),leader 会周期性的向所有节点发送心跳包来维持自己的权威,那么首个 leader 是如何被选举出来的呢?方法是如果一个 follower 在一段时间内没有收到任何心跳,也就是选举超时,那么它就会主观认为系统中没有可用的 leader,并发起新的选举(Step: times out, starts election)。

这里有一个问题,即这个“选举超时时间”该如何制定?如果所有节点在同一时刻启动,经过同样的超时时间后同时发起选举,整个集群会变得低效不堪,极端情况下甚至会一直选不出一个主节点。Raft 巧妙的使用了一个随机化的定时器,让每个节点的“超时时间”在一定范围内随机生成,这样就大大的降低了多个节点同时发起选举的可能性。


图:一个五节点 Raft 集群的初始状态,所有节点都是 follower 身份,term 为 1,且每个节点的选举超时定时器不同

若 follower 想发起一次选举,follower 需要先增加自己的当前 term,并将身份切换为 candidate。然后它会向集群其它节点发送“请给自己投票”的消息(RequestVote RPC)。


图:S1 率先超时,变为 candidate,term + 1,并向其它节点发出拉票请求

Candicate 状态转换过程

Follower 切换为 candidate 并向集群其他节点发送“请给自己投票”的消息后,接下来会有三种可能的结果,也即上面节点状态图中 candidate 状态向外伸出的三条线。

选举成功(Step: receives votes from majority of servers)。当candicate从整个集群的大多数(N/2+1)节点获得了针对同一 term 的选票时,它就赢得了这次选举,立刻将自己的身份转变为 leader 并开始向其它节点发送心跳来维持自己的权威。

图:“大部分”节点都给了 S1 选票


图:S1 变为 leader,开始发送心跳维持权威

每个节点针对每个 term 只能投出一张票,并且按照先到先得的原则。这个规则确保只有一个 candidate 会成为 leader。

选举失败(Step: discovers current leader or new term)。Candidate 在等待投票回复的时候,可能会突然收到其它自称是 leader 的节点发送的心跳包,如果这个心跳包里携带的 term 不小于 candidate 当前的 term,那么 candidate 会承认这个 leader,并将身份切回 follower。这说明其它节点已经成功赢得了选举,我们只需立刻跟随即可。但如果心跳包中的 term 比自己小,candidate 会拒绝这次请求并保持选举状态。


图:S4、S2 依次开始选举


图:S4 成为 leader,S2 在收到 S4 的心跳包后,由于 term 不小于自己当前的 term,因此会立刻切为 follower 跟随 S4

选举超时(Step: times out, new election)。第三种可能的结果是 candidate 既没有赢也没有输。如果有多个 follower 同时成为 candidate,选票是可能被瓜分的,如果没有任何一个 candidate 能得到大多数节点的支持,那么每一个 candidate 都会超时。此时 candidate 需要增加自己的 term,然后发起新一轮选举。如果这里不做一些特殊处理,选票可能会一直被瓜分,导致选不出 leader 来。这里的“特殊处理”指的就是前文所述的随机化选举超时时间。


图:S1 ~ S5 都在参与选举


图:没有任何节点愿意给他人投票


图:如果没有随机化超时时间,所有节点将会继续同时发起选举……

以上便是 candidate 三种可能的选举结果。

Leader 状态转换过程

节点状态图中的最后一条线是:discovers server with higher term。想象一个场景:当 leader 节点发生了宕机或网络断连,此时其它 follower 会收不到 leader 心跳,首个触发超时的节点会变为 candidate 并开始拉票(由于随机化各个 follower 超时时间不同),由于该 candidate 的 term 大于原 leader 的 term,因此所有 follower 都会投票给它,这名 candidate 会变为新的 leader。一段时间后原 leader 恢复了,收到了来自新leader 的心跳包,发现心跳中的 term 大于自己的 term,此时该节点会立刻切换为 follower 并跟随的新 leader。

上述流程的动画模拟如下:

图:S4 作为 term2 的 leader


图:S4 宕机,S5 即将率先超时


图:S5 当选 term3 的 leader


图:S4 宕机恢复后收到了来自 S5 的 term3 心跳


图:S4 立刻变为 S5 的 follower

以上就是 Raft 的选主逻辑,但还有一些细节(譬如是否给该 candidate 投票还有一些其它条件)依赖算法的其它部分基础,我们会在后续“安全性”一章描述。

当票选出 leader 后,leader 也该承担起相应的责任了,这个责任是什么?就是下一章将介绍的“日志复制”。

日志复制

什么是日志复制

在前文中我们讲过:共识算法通常基于状态复制机(Replicated State Machine)模型,所有节点从同一个 state 出发,经过一系列同样操作 log 的步骤,最终也必将达到一致的 state。也就是说,只要我们保证集群中所有节点的 log 一致,那么经过一系列应用(apply)后最终得到的状态机也就是一致的。

Raft 负责保证集群中所有节点 log 的一致性。

此外我们还提到过:Raft 赋予了 leader 节点更强的领导力(Strong Leader)。那么 Raft 保证 log 一致的方式就很容易理解了,即所有 log 都必须交给 leader 节点处理,并由 leader 节点复制给其它节点。这个过程,就叫做日志复制(Log replication)。

Raft 日志复制机制解析

整体流程解析

一旦 leader 被票选出来,它就承担起领导整个集群的责任了,开始接收客户端请求,并将操作包装成日志,并复制到其它节点上去。整体流程如下:

  • Leader 为客户端提供服务,客户端的每个请求都包含一条即将被状态复制机执行的指令。
  • Leader 把该指令作为一条新的日志附加到自身的日志集合,然后向其它节点发起附加条目请求(AppendEntries RPC),来要求它们将这条日志附加到各自本地的日志集合。
  • 当这条日志已经确保被安全的复制,即大多数(N/2+1)节点都已经复制后,leader 会将该日志 apply 到它本地的状态机中,然后把操作成功的结果返回给客户端。

整个集群的日志模型可以宏观表示为下图(x ← 3 代表 x 赋值为 3):

每条日志除了存储状态机的操作指令外,还会拥有一个唯一的整数索引值(log index)来表明它在日志集合中的位置。此外,每条日志还会存储一个 term 号(日志条目方块最上方的数字,相同颜色 term 号相同),该 term 表示 leader 收到这条指令时的当前任期,term 相同的 log 是由同一个 leader 在其任期内发送的。

当一条日志被 leader 节点认为可以安全的 apply 到状态机时,称这条日志是 committed(上图中的 committed entries)。那么什么样的日志可以被 commit 呢?答案是:当 leader 得知这条日志被集群过半的节点复制成功时。因此在上图中我们可以看到 (term3, index7) 这条日志以及之前的日志都是 committed,尽管有两个节点拥有的日志并不完整。

Raft 保证所有 committed 日志都已经被持久化,且“最终”一定会被状态机apply。

注:这里的“最终”用词很微妙,它表明了一个特点:Raft 保证的只是集群内日志的一致性,而我们真正期望的集群对外的状态机一致性需要我们做一些额外工作,这一点在《线性一致性与读性能优化》一章会着重介绍。

日志复制流程图解

我们通过 Raft 动画 来模拟常规日志复制这一过程:

如上图,S1 当选 leader,此时还没有任何日志。我们模拟客户端向 S1 发起一个请求。

S1 收到客户端请求后新增了一条日志 (term2, index1),然后并行地向其它节点发起 AppendEntries RPC。

S2、S4 率先收到了请求,各自附加了该日志,并向 S1 回应响应。

所有节点都附加了该日志,但由于 leader 尚未收到任何响应,因此暂时还不清楚该日志到底是否被成功复制。

当 S1 收到2个节点的响应时,该日志条目的边框就已经变为实线,表示该日志已经安全的复制,因为在5节点集群中,2个 follower 节点加上 leader 节点自身,副本数已经确保过半,此时 S1 将响应客户端的请求。

leader 后续会持续发送心跳包给 followers,心跳包中会携带当前已经安全复制(我们称之为 committed)的日志索引,此处为 (term2, index1)。

所有 follower 都通过心跳包得知 (term2, index1) 的 log 已经成功复制 (committed),因此所有节点中该日志条目的边框均变为实线。

对日志一致性的保证

前边我们使用了 (term2, index1) 这种方式来表示一条日志条目,这里为什么要带上 term,而不仅仅是使用 index?原因是 term 可以用来检查不同节点间日志是否存在不一致的情况,阅读下一节后会更容易理解这句话。

Raft 保证:如果不同的节点日志集合中的两个日志条目拥有相同的 term 和 index,那么它们一定存储了相同的指令。为什么可以作出这种保证?因为 Raft 要求 leader 在一个 term 内针对同一个 index 只能创建一条日志,并且永远不会修改它。同时 Raft 也保证:如果不同的节点日志集合中的两个日志条目拥有相同的 term 和 index,那么它们之前的所有日志条目也全部相同。这是因为 leader 发出的 AppendEntries RPC 中会额外携带上一条日志的 (term, index),如果 follower 在本地找不到相同的 (term, index) 日志,则拒绝接收这次新的日志。所以,只要 follower 持续正常地接收来自 leader 的日志,那么就可以通过归纳法验证上述结论。

可能出现的日志不一致场景

在所有节点正常工作的时候,leader 和 follower的日志总是保持一致,AppendEntries RPC 也永远不会失败。然而我们总要面对任意节点随时可能宕机的风险,如何在这种情况下继续保持集群日志的一致性才是我们真正要解决的问题。

上图展示了一个 term8 的 leader 刚上任时,集群中日志可能存在的混乱情况。例如 follower 可能缺少一些日志(a ~ b),可能多了一些未提交的日志(c ~ d),也可能既缺少日志又多了一些未提交日志(e ~ f)。

注:Follower 不可能比 leader 多出一些已提交(committed)日志,这一点是通过选举上的限制来达成的,会在下一章《安全性》介绍。

我们先来尝试复现上述 a ~ f 场景,最后再讲 Raft 如何解决这种不一致问题。

  • 场景a~b. Follower 日志落后于 leader
    • 这种场景其实很简单,即 follower 宕机了一段时间,follower-a 从收到 (term6, index9) 后开始宕机,follower-b 从收到 (term4, index4) 后开始宕机。这里不再赘述。
  • 场景c. Follower 日志比 leader 多 term6
    • 当 term6 的 leader 正在将 (term6, index11) 向 follower 同步时,该 leader 发生了宕机,且此时只有 follower-c 收到了这条日志的 AppendEntries RPC。然后经过一系列的选举,term7 可能是选举超时,也可能是 leader 刚上任就宕机了,最终 term8 的 leader 上任了,成就了我们看到的场景 c。
  • 场景d. Follower 日志比 leader 多 term7
    • 当 term6 的 leader 将 (term6, index10) 成功 commit 后,发生了宕机。此时 term7 的 leader 走马上任,连续同步了两条日志给 follower,然而还没来得及 commit 就宕机了,随后集群选出了 term8 的 leader。
  • 场景e. Follower 日志比 leader 少 term5 ~ 6,多 term4
    • 当 term4 的 leader 将 (term4, index7) 同步给 follower,且将 (term4, index5) 及之前的日志成功 commit 后,发生了宕机,紧接着 follower-e 也发生了宕机。这样在 term5~7 内发生的日志同步全都被 follower-e 错过了。当 follower-e 恢复后,term8 的 leader 也刚好上任了。
  • 场景f. Follower 日志比 leader 少 term4 ~ 6,多 term2 ~ 3
    • 当 term2 的 leader 同步了一些日志(index4 ~ 6)给 follower 后,尚未来得及 commit 时发生了宕机,但它很快恢复过来了,又被选为了 term3 的 leader,它继续同步了一些日志(index7~11)给 follower,但同样未来得及 commit 就又发生了宕机,紧接着 follower-f 也发生了宕机,当 follower-f 醒来时,集群已经前进到 term8 了。

如何处理日志不一致

通过上述场景我们可以看到,真实世界的集群情况很复杂,那么 Raft 是如何应对这么多不一致场景的呢?其实方式很简单暴力,想想 Strong Leader 这个词。

Raft 强制要求 follower 必须复制 leader 的日志集合来解决不一致问题。也就是说,follower 节点上任何与 leader 不一致的日志,都会被 leader 节点上的日志所覆盖。这并不会产生什么问题,因为某些选举上的限制,如果 follower 上的日志与 leader 不一致,那么该日志在 follower 上一定是未提交的。未提交的日志并不会应用到状态机,也不会被外部的客户端感知到。

要使得 follower 的日志集合跟自己保持完全一致,leader 必须先找到二者间最后一次达成一致的地方。因为一旦这条日志达成一致,在这之前的日志一定也都一致(回忆下前文)。这个确认操作是在 AppendEntries RPC 的一致性检查步骤完成的。

Leader 针对每个 follower 都维护一个 next index,表示下一条需要发送给该follower 的日志索引。当一个 leader 刚刚上任时,它初始化所有 next index 值为自己最后一条日志的 index+1。但凡某个 follower 的日志跟 leader 不一致,那么下次 AppendEntries RPC 的一致性检查就会失败。在被 follower 拒绝这次 Append Entries RPC 后,leader 会减少 next index 的值并进行重试。

最终一定会存在一个 next index 使得 leader 和 follower 在这之前的日志都保持一致。极端情况下 next index 为1,表示 follower 没有任何日志与 leader 一致,leader 必须从第一条日志开始同步。

针对每个 follower,一旦确定了 next index 的值,leader 便开始从该 index 同步日志,follower 会删除掉现存的不一致的日志,保留 leader 最新同步过来的。

整个集群的日志会在这个简单的机制下自动趋于一致。此外要注意,leader 从来不会覆盖或者删除自己的日志,而是强制 follower 与它保持一致。这就要求集群票选出的 leader 一定要具备“日志的正确性”,这也就关联到了前文提到的:选举上的限制。下一章我们将对此详细讨论。

安全性及正确性

前面的章节我们讲述了 Raft 算法是如何选主和复制日志的,然而到目前为止我们描述的这套机制还不能保证每个节点的状态机会严格按照相同的顺序 apply 日志。想象以下场景:

  • Leader 将一些日志复制到了大多数节点上,进行 commit 后发生了宕机。
  • 某个 follower 并没有被复制到这些日志,但它参与选举并当选了下一任 leader。
  • 新的 leader 又同步并 commit 了一些日志,这些日志覆盖掉了其它节点上的上一任 committed 日志。
  • 各个节点的状态机可能 apply 了不同的日志序列,出现了不一致的情况。

因此我们需要对“选主+日志复制”这套机制加上一些额外的限制,来保证状态机的安全性,也就是 Raft 算法的正确性。

对选举的限制

我们再来分析下前文所述的 committed 日志被覆盖的场景,根本问题其实发生在第2步。Candidate 必须有足够的资格才能当选集群 leader,否则它就会给集群带来不可预料的错误。Candidate 是否具备这个资格可以在选举时添加一个小小的条件来判断,即:

每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。

Candidate 想要赢得选举成为 leader,必须得到集群大多数节点的投票,那么它的日志就一定至少不落后于大多数节点。又因为一条日志只有复制到了大多数节点才能被 commit,因此能赢得选举的 candidate 一定拥有所有 committed 日志。因此前一篇文章我们才会断定地说:Follower 不可能比 leader 多出一些 committed 日志。比较两个 (term, index) 的逻辑非常简单:如果 term 不同 term 更大的日志更新,否则 index 大的日志更新。

对提交的限制

除了对选举增加一点限制外,我们还需对 commit 行为增加一点限制,来完成我们 Raft 算法核心部分的最后一块拼图。

回忆下什么是 commit:当 leader 得知某条日志被集群过半的节点复制成功时,就可以进行 commit,committed 日志一定最终会被状态机 apply。所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。然而 leader 并不能在任何时候都随意 commit 旧任期留下的日志,即使它已经被复制到了大多数节点。Raft 论文给出了一个经典场景:

上图从左到右按时间顺序模拟了问题场景。

  • 阶段a:S1 是 leader,收到请求后将 (term2, index2) 只复制给了 S2,尚未复制给 S3 ~ S5。
  • 阶段b:S1 宕机,S5 当选 term3 的 leader(S3、S4、S5 三票),收到请求后保存了 (term3, index2),尚未复制给任何节点。
  • 阶段c:S5 宕机,S1 恢复,S1 重新当选 term4 的 leader,继续将 (term2, index2) 复制给了 S3,已经满足大多数节点,我们将其 commit。
  • 阶段d:S1 又宕机,S5 恢复,S5 重新当选 leader(S2、S3、S4 三票),将 (term3, inde2) 复制给了所有节点并 commit。注意,此时发生了致命错误,已经 committed 的 (term2, index2) 被 (term3, index2) 覆盖了。

为了避免这种错误,我们需要添加一个额外的限制:Leader 只允许 commit 包含当前 term 的日志。

针对上述场景,问题发生在阶段c,即使作为 term4 leader 的 S1 将 (term2, index2) 复制给了大多数节点,它也不能直接将其 commit,而是必须等待 term4 的日志到来并成功复制后,一并进行 commit。

  • 阶段e:在添加了这个限制后,要么 (term2, index2) 始终没有被 commit,这样 S5 在阶段d将其覆盖就是安全的;要么 (term2, index2) 同 (term4, index3) 一起被 commit,这样 S5 根本就无法当选 leader,因为大多数节点的日志都比它新,也就不存在前边的问题了。

以上便是对算法增加的两个小限制,它们对确保状态机的安全性起到了至关重要的作用。

至此我们对 Raft 算法的核心部分,已经介绍完毕。下一章我们会介绍两个同样描述于论文内的辅助技术:集群成员变更和日志压缩,它们都是在 Raft 工程实践中必不可少的部分。

集群成员变更与日志压缩

尽管我们已经通过前几章了解了 Raft 算法的核心部分,但相较于算法理论来说,在工程实践中仍有一些现实问题需要我们去面对。Raft 非常贴心的在论文中给出了两个常见问题的解决方案,它们分别是:

  • 集群成员变更:如何安全地改变集群的节点成员。
  • 日志压缩:如何解决日志集合无限制增长带来的问题。

本文我们将分别讲解这两种技术。

集群成员变更

在前文的理论描述中我们都假设了集群成员是不变的,然而在实践中有时会需要替换宕机机器或者改变复制级别(即增减节点)。一种最简单暴力达成目的的方式就是:停止集群、改变成员、启动集群。这种方式在执行时会导致集群整体不可用,此外还存在手工操作带来的风险。

为了避免这样的问题,Raft 论文中给出了一种无需停机的、自动化的改变集群成员的方式,其实本质上还是利用了 Raft 的核心算法,将集群成员配置作为一个特殊日志从 leader 节点同步到其它节点去。

直接切换集群成员配置

先说结论:所有将集群从旧配置直接完全切换到新配置的方案都是不安全的。

因此我们不能想当然的将新配置直接作为日志同步给集群并 apply。因为我们不可能让集群中的全部节点在“同一时刻”原子地切换其集群成员配置,所以在切换期间不同的节点看到的集群视图可能存在不同,最终可能导致集群存在多个 leader。为了理解上述结论,我们来看一个实际出现问题的场景,下图对其进行了展现。

  • 阶段a. 集群存在 S1 ~ S3 三个节点,我们将该成员配置表示为 C-old,绿色表示该节点当前视图(成员配置)为 C-old,其中红边的 S3 为 leader。
  • 阶段b. 集群新增了 S4、S5 两个节点,该变更从 leader 写入,我们将 S1 ~ S5 的五节点新成员配置表示为 C-new,蓝色表示该节点当前视图为 C-new。
  • 阶段c. 假设 S3 短暂宕机触发了 S1 与 S5 的超时选主。
  • 阶段d. S1 向 S2、S3 拉票,S5 向其它全部四个节点拉票。由于 S2 的日志并没有比 S1 更新,因此 S2 可能会将选票投给 S1,S1 两票当选(因为 S1 认为集群只有三个节点)。而 S5 肯定会得到 S3、S4 的选票,因为 S1 感知不到 S4,没有向它发送 RequestVote RPC,并且 S1 的日志落后于 S3,S3 也一定不会投给 S1,结果 S5 三票当选。最终集群出现了多个主节点的致命错误,也就是所谓的脑裂。

上图来自论文,用不同的形式展现了和图5-1相同的问题。颜色代表的含义与图5-1是一致的,在 problem: two disjoint majorities 所指的时间点,集群可能会出现两个 leader。但是,多主问题并不是在任何新老节点同时选举时都一定可能出现的


图5-3

该假想场景类似图5-1的阶段d,模拟过程如下:

  • S1 为集群原 leader,集群新增 S4、S5,该配置被推给了 S3,S2 尚未收到。
  • 此时 S1 发生短暂宕机,S2、S3 分别触发选主。
  • 最终 S2 获得了 S1 和自己的选票,S3 获得了 S4、S5 和自己的选票,集群出现两个 leader。

图5-3过程看起来好像和图5-1没有什么大的不同,只是参与选主的节点存在区别,然而事实是图5-3的情况是不可能出现的。

注意:Raft 论文中传递集群变更信息也是通过日志追加实现的,所以也受到选主的限制。很多读者对选主限制中比较的日志是否必须是 committed 产生疑惑,回看下在《安全性》一文中的描述:

每个 candidate 必须在 RequestVote RPC 中携带自己本地日志的最新 (term, index),如果 follower 发现这个 candidate 的日志还没有自己的新,则拒绝投票给该 candidate。

这里再帮大家明确下,论文里确实间接表明了,选主时比较的日志是不要求 committed 的,只需比较本地的最新日志就行!

回到图5-3,不可能出现的原因在于,S1 作为原 leader 已经第一个保存了新配置的日志,而 S2 尚未被同步这条日志,根据上一章《安全性》我们讲到的选主限制,S1 不可能将选票投给 S2,因此 S2 不可能成为 leader。

两阶段切换集群成员配置

Raft 使用一种两阶段方法平滑切换集群成员配置来避免遇到前一节描述的问题,具体流程如下:

  • 阶段一
    • 客户端将 C-new 发送给 leader,leader 将 C-old 与 C-new 取并集并立即apply,我们表示为 C-old,new。
    • Leader 将 C-old,new 包装为日志同步给其它节点。
    • Follower 收到 C-old,new 后立即 apply,当 C-old,new 的大多数节点(即 C-old 的大多数节点和 C-new 的大多数节点)都切换后,leader 将该日志 commit。
  • 阶段二
    • Leader 接着将 C-new 包装为日志同步给其它节点。
    • Follower 收到 C-new 后立即 apply,如果此时发现自己不在 C-new 列表,则主动退出集群。
    • Leader 确认 C-new 的大多数节点都切换成功后,给客户端发送执行成功的响应。

上图展示了该流程的时间线。虚线表示已经创建但尚未 commit 的成员配置日志,实线表示 committed 的成员配置日志。

为什么该方案可以保证不会出现多个 leader?我们来按流程逐阶段分析。

  • 阶段1. C-old,new 尚未 commit
    • 该阶段所有节点的配置要么是 C-old,要么是 C-old,new,但无论是二者哪种,只要原 leader 发生宕机,新 leader 都必须得到大多数 C-old 集合内节点的投票。
    • 以图5-1场景为例,S5 在阶段d根本没有机会成为 leader,因为 C-old 中只有 S3 给它投票了,不满足大多数。
  • 阶段2. C-old,new 已经 commit,C-new 尚未下发
    • 该阶段 C-old,new 已经 commit,可以确保已经被 C-old,new 的大多数节点(再次强调:C-old 的大多数节点和 C-new 的大多数节点)复制。
    • 因此当 leader 宕机时,新选出的 leader 一定是已经拥有 C-old,new 的节点,不可能出现两个 leader。
  • 阶段3. C-new 已经下发但尚未 commit
    • 该阶段集群中可能有三种节点 C-old、C-old,new、C-new,但由于已经经历了阶段2,因此 C-old 节点不可能再成为 leader。而无论是 C-old,new 还是 C-new 节点发起选举,都需要经过大多数 C-new 节点的同意,因此也不可能出现两个 leader。
  • 阶段4. C-new 已经 commit
    • 该阶段 C-new 已经被 commit,因此只有 C-new 节点可以得到大多数选票成为 leader。此时集群已经安全地完成了这轮变更,可以继续开启下一轮变更了。

以上便是对该两阶段方法可行性的分步验证,Raft 论文将该方法称之为共同一致(Joint Consensus)。关于集群成员变更另一篇更详细的论文还给出了其它方法,简单来说就是论证一次只变更一个节点的的正确性,并给出解决可用性问题的优化方案。感兴趣的同学可以参考:《Consensus: Bridging Theory and Practice》。

日志压缩

我们知道 Raft 核心算法维护了日志的一致性,通过 apply 日志我们也就得到了一致的状态机,客户端的操作命令会被包装成日志交给 Raft 处理。然而在实际系统中,客户端操作是连绵不断的,但日志却不能无限增长,首先它会占用很高的存储空间,其次每次系统重启时都需要完整回放一遍所有日志才能得到最新的状态机。

因此 Raft 提供了一种机制去清除日志里积累的陈旧信息,叫做日志压缩。

快照(Snapshot)是一种常用的、简单的日志压缩方式,ZooKeeper、Chubby 等系统都在用。简单来说,就是将某一时刻系统的状态 dump 下来并落地存储,这样该时刻之前的所有日志就都可以丢弃了。所以大家对“压缩”一词不要产生错误理解,我们并没有办法将状态机快照“解压缩”回日志序列。
注意,在 Raft 中我们只能为 committed 日志做 snapshot,因为只有 committed 日志才是确保最终会应用到状态机的。

上图展示了一个节点用快照替换了 (term1, index1) ~ (term3, index5) 的日志。快照一般包含以下内容:

  • 日志的元数据:最后一条被该快照 apply 的日志 term 及 index
  • 状态机:前边全部日志 apply 后最终得到的状态机

当 leader 需要给某个 follower 同步一些旧日志,但这些日志已经被 leader 做了快照并删除掉了时,leader 就需要把该快照发送给 follower。

同样,当集群中有新节点加入,或者某个节点宕机太久落后了太多日志时,leader 也可以直接发送快照,大量节约日志传输和回放时间。

同步快照使用一个新的 RPC 方法,叫做 InstallSnapshot RPC。

线性一致性与读性能优化

什么是线性一致性?

在该系列首篇《基本概念》中我们提到过:在分布式系统中,为了消除单点提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性。

什么是一致性?所谓一致性有很多种模型,不同的模型都是用来评判一个并发系统正确与否的不同程度的标准。而我们今天要讨论的是强一致性(Strong Consistency)模型,也就是线性一致性(Linearizability),我们经常听到的 CAP 理论中的 C 指的就是它。

其实我们在第一篇就已经简要描述过何为线性一致性:

所谓的强一致性(线性一致性)并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,我们可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。

“像单机一样提供服务”从感官上描述了一个线性一致性系统应该具备的特性,那么我们该如何判断一个系统是否具备线性一致性呢?通俗来说就是不能读到旧(stale)数据,但具体分为两种情况:

  • 对于调用时间存在重叠(并发)的请求,生效顺序可以任意确定。
  • 对于调用时间存在先后关系(偏序)的请求,后一个请求不能违背前一个请求确定的结果。

只要根据上述两条规则即可判断一个系统是否具备线性一致性。下面我们来看一个非线性一致性系统的例子。

如上图所示,裁判将世界杯的比赛结果写入了主库,Alice 和 Bob 所浏览的页面分别从两个不同的从库读取,但由于存在主从同步延迟,Follower 2 的本次同步延迟高于 Follower 1,最终导致 Bob 听到了 Alice 的惊呼后刷新页面看到的仍然是比赛进行中。

虽然线性一致性的基本思想很简单,只是要求分布式系统看起来只有一个数据副本,但在实际中还是有很多需要关注的点,我们继续看几个例子。

上图从客户端的外部视角展示了多个用户同时请求读写一个系统的场景,每条柱形都是用户发起的一个请求,左端是请求发起的时刻,右端是收到响应的时刻。由于网络延迟和系统处理时间并不固定,所以柱形长度并不相同。

  • x 最初的值为 0,Client C 在某个时间段将 x 写为 1。
  • Client A 第一个读操作位于 Client C 的写操作之前,因此必须读到原始值 0。
  • Client A 最后一个读操作位于 Client C 的写操作之后,如果系统是线性一致的,那么必须读到新值 1。
  • 其它与写操作重叠的所有读操作,既可能返回 0,也可能返回 1,因为我们并不清楚写操作在哪个时间段内哪个精确的点生效,这种情况下读写是并发的。

仅仅是这样的话,仍然不能说这个系统满足线性一致。假设 Client B 的第一次读取返回了 1,如果 Client A 的第二次读取返回了 0,那么这种场景并不破坏上述规则,但这个系统仍不满足线性一致,因为客户端在写操作执行期间看到 x 的值在新旧之间来回翻转,这并不符合我们期望的“看起来只有一个数据副本”的要求。所以我们需要额外添加一个约束,如下图所示。

在任何一个客户端的读取返回新值后,所有客户端的后续读取也必须返回新值,这样系统便满足线性一致了。

我们最后来看一个更复杂的例子,继续细化这个时序图。

如上图所示,每个读写操作在某个特定的时间点都是原子性的生效,我们在柱形中用竖线标记出生效的时间点,将这些标记按时间顺序连接起来。那么线性一致的要求就是:连线总是按照时间顺序向右移动,而不会向左回退。所以这个连线结果必定是一个有效的寄存器读写序列:任何客户端的每次读取都必须返回该条目最近一次写入的值。

线性一致性并非限定在分布式环境下,在单机单核系统中可以简单理解为“寄存器”的特性。

Client B 的最后一次读操作并不满足线性一致,因为在连线向右移动的前提下,它读到的值是错误的(因为Client A 已经读到了由 Client C 写入的 4)。此外这张图里还有一些值得指出的细节点,可以解开很多我们在使用线性一致系统时容易产生的误解:

  • Client B 的首个读请求在 Client D 的首个写请求和 Client A 的首个写请求之前发起,但最终读到的却是最后由 Client A 写成功之后的结果。
  • Client A 尚未收到首个写请求成功的响应时,Client B 就读到了 Client A 写入的值。

上述现象在线性一致的语义下都是合理的。

所以线性一致性(Linearizability)除了叫强一致性(Strong Consistency)外,还叫做原子一致性(Atomic Consistency)、立即一致性(Immediate Consistency)或外部一致性(External Consistency),这些名字看起来都是比较贴切的。

Raft 线性一致性读

在了解了什么是线性一致性之后,我们将其与 Raft 结合来探讨。首先需要明确一个问题,使用了 Raft 的系统都是线性一致的吗?不是的,Raft 只是提供了一个基础,要实现整个系统的线性一致还需要做一些额外的工作。

假设我们期望基于 Raft 实现一个线性一致的分布式 kv 系统,让我们从最朴素的方案开始,指出每种方案存在的问题,最终使整个系统满足线性一致性。

写主读从缺陷分析

写操作并不是我们关注的重点,如果你稍微看了一些理论部分就应该知道,所有写操作都要作为提案从 leader 节点发起,当然所有的写命令都应该简单交给 leader 处理。真正关键的点在于读操作的处理方式,这涉及到整个系统关于一致性方面的取舍。

在该方案中我们假设读操作直接简单地向 follower 发起,那么由于 Raft 的 Quorum 机制(大部分节点成功即可),针对某个提案在某一时间段内,集群可能会有以下两种状态:

  • 某次写操作的日志尚未被复制到一少部分 follower,但 leader 已经将其 commit。
  • 某次写操作的日志已经被同步到所有 follower,但 leader 将其 commit 后,心跳包尚未通知到一部分 follower。

以上每个场景客户端都可能读到过时的数据,整个系统显然是不满足线性一致的。

写主读主缺陷分析

在该方案中我们限定,所有的读操作也必须经由 leader 节点处理,读写都经过 leader 难道还不能满足线性一致?是的!! 并且该方案存在不止一个问题!!

问题一:状态机落后于 committed log 导致脏读

回想一下前文讲过的,我们在解释什么是 commit 时提到了写操作什么时候可以响应客户端:

所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。

也就是说一个提案只要被 leader commit 就可以响应客户端了,Raft 并没有限定提案结果在返回给客户端前必须先应用到状态机。所以从客户端视角当我们的某个写操作执行成功后,下一次读操作可能还是会读到旧值。

这个问题的解决方式很简单,在 leader 收到读命令时我们只需记录下当前的 commit index,当 apply index 追上该 commit index 时,即可将状态机中的内容响应给客户端。

问题二:网络分区导致脏读

假设集群发生网络分区,旧 leader 位于少数派分区中,而且此刻旧 leader 刚好还未发现自己已经失去了领导权,当多数派分区选出了新的 leader 并开始进行后续写操作时,连接到旧 leader 的客户端可能就会读到旧值了。

因此,仅仅是直接读 leader 状态机的话,系统仍然不满足线性一致性。

Raft Log Read

为了确保 leader 处理读操作时仍拥有领导权,我们可以将读请求同样作为一个提案走一遍 Raft 流程,当这次读请求对应的日志可以被应用到状态机时,leader 就可以读状态机并返回给用户了。这种读方案称为 Raft Log Read,也可以直观叫做 Read as Proposal。

为什么这种方案满足线性一致?因为该方案根据 commit index 对所有读写请求都一起做了线性化,这样每个读请求都能感知到状态机在执行完前一写请求后的最新状态,将读写日志一条一条的应用到状态机,整个系统当然满足线性一致。但该方案的缺点也非常明显,那就是性能差,读操作的开销与写操作几乎完全一致。而且由于所有操作都线性化了,我们无法并发读状态机。

Raft 读性能优化

接下来我们将介绍几种优化方案,它们在不违背系统线性一致性的前提下,大幅提升了读性能。

Read Index

与 Raft Log Read 相比,Read Index 省掉了同步 log 的开销,能够大幅提升读的吞吐,一定程度上降低读的时延。其大致流程为:

  • Leader 在收到客户端读请求时,记录下当前的 commit index,称之为 read index。
  • Leader 向 followers 发起一次心跳包,这一步是为了确保领导权,避免网络分区时少数派 leader 仍处理请求。
  • 等待状态机至少应用到 read index(即 apply index 大于等于 read index)。
  • 执行读请求,将状态机中的结果返回给客户端。

这里第三步的 apply index 大于等于 read index 是一个关键点。因为在该读请求发起时,我们将当时的 commit index 记录了下来,只要使客户端读到的内容在该 commit index 之后,那么结果一定都满足线性一致(如不理解可以再次回顾下前文线性一致性的例子以及2.2中的问题一)。

Lease Read

与 Read Index 相比,Lease Read 进一步省去了网络交互开销,因此更能显著降低读的时延。基本思路是 leader 设置一个比选举超时(Election Timeout)更短的时间作为租期,在租期内我们可以相信其它节点一定没有发起选举,集群也就一定不会存在脑裂,所以在这个时间段内我们直接读主即可,而非该时间段内可以继续走 Read Index 流程,Read Index 的心跳包也可以为租期带来更新。

Lease Read 可以认为是 Read Index 的时间戳版本,额外依赖时间戳会为算法带来一些不确定性,如果时钟发生漂移会引发一系列问题,因此需要谨慎的进行配置。

Follower Read

在前边两种优化方案中,无论我们怎么折腾,核心思想其实只有两点:

  • 保证在读取时的最新 commit index 已经被 apply。
  • 保证在读取时 leader 仍拥有领导权。

这两个保证分别对应2.2节所描述的两个问题。

其实无论是 Read Index 还是 Lease Read,最终目的都是为了解决第二个问题。换句话说,读请求最终一定都是由 leader 来承载的。

那么读 follower 真的就不能满足线性一致吗?其实不然,这里我们给出一个可行的读 follower 方案:Follower 在收到客户端的读请求时,向 leader 询问当前最新的 commit index,反正所有日志条目最终一定会被同步到自己身上,follower 只需等待该日志被自己 commit 并 apply 到状态机后,返回给客户端本地状态机的结果即可。这个方案叫做 Follower Read。

注意:Follower Read 并不意味着我们在读过程中完全不依赖 leader 了,在保证线性一致性的前提下完全不依赖 leader 理论上是不可能做到的。

原文:https://www.dingmos.com/index.php/archives/4/

lab1

实验分为三个部分:

  • 熟悉汇编语言、QEMU x86模拟器、PC上电启动过程
  • 检查我们的6.828内核的boot loader程序,它位于lab的boot目录下。
  • 深入研究6.828内核本身的初始模板,位于kernel目录下。

MIT6.828 实验环境配置

使用命令行创建了一个目录~/6.828,在该目录下初始化一个git仓库

1
~/6.828$ git init

把JOS系统源码clone到本地

1
~/6.828$ git clone https://pdos.csail.mit.edu/6.828/2017/jos.git lab

安装QEMU这个仿真器需要先安装包。

1
2
3
4
5
sudo apt-get install libsdl1.2-dev
sudo apt-get install libglib2.0-dev
sudo apt-get install libz-dev
sudo apt-get install libpixman-1-dev
sudo apt-get install libtool*

打开qemu所在目录,进行configuration

1
sudo ./configure --disable-kvm --disable-werror --prefix=$HMOE --target-list="i386-softmmu x86_64-softmmu"

最后进行安装。

1
sudo make && make install

安装包时频繁出现依赖问题,把apt-get换成了aptitude无用;最后是换了源解决的,可能是因为在安装中断后更换了源,换回去就好了。

之后又报了Werror。在配置的时候处理werror解决。

1
sudo ./configure --disable-werror --prefix==/usr/local --target-list="i386-softmmu x86_64-softmmu"

QUMU安装好之后,make lab下的代码报错:

1
2
lib/printfmt.c:41: undefined reference to `__udivdi3'
lib/printfmt.c:49: undefined reference to `__umoddi3'

ARM是精简指令集,对求余和除法操作基本上不支持。linux内核源码linux/arch/arm/lib/lib1funcs.S实现支持除法、求模操作等操作的库函数。本来应该多研究下,但是发现有现成的解决方案,我开发环境是64gcc,但需要的是32位,所以安装32位gcc解决问题。

1
sudo apt-get install gcc-multilib

再次进行make,成功!

1
2
3
4
5
6
7
~/6.828/lab$ sudo make
+ ld obj/kern/kernel
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 390 bytes (max 510)
+ mk obj/kern/kernel.img

之后需要make qemu,又报错了:

1
2
3
4
5
/bin/sh: 1: /home/yuhao/qemu/: Permission denied
/home/yuhao/qemu/ -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::26000
make: execvp: /home/yuhao/qemu/: Permission denied
GNUmakefile:156: recipe for target 'qemu' failed
make: *** [qemu] Error 127

应该是qemu的可执行文件配置错误,改一下env.mk。在执行启动简单映像的命令后,又有错误:

1
2
GLib-WARNING **:21:58:30.131:gmem.c:489:不支持自定义内存分配vtable
(qemu-system-x86_64:23983):Gtk-WARNING **:21:58:30.175:无法打开显示:

出现此问题是因为glib2错误(https://bugzilla.redhat.com/show_bug.cgi?id=1594304)。

此问题的另一方面是Red Hat和CentOS存储库包含过时的QEMU版本(最近是4)。

  1. 用qemu-kvm而不是qemu-system-x86_64:https://www.tecmint.com/install-manage-virtual-machines-in-centos/
  2. 从fedora仓库重新安装/更新所有QEMU软件包(https://copr-be.cloud.fedoraproject.org/results/fcomida/qemu-4/fedora-30-x86_64/00910942-qemu/)`rpm -i /path/to/file/file_name.rpm`
  3. 自己编译QEMU(https://www.qemu.org/download/#source)。
1
2
3
4
5
6
7
8
wget https://download.qemu.org/qemu-4.1.0-rc2.tar.xz
tar xvJf qemu-4.1.0-rc2.tar.xz
cd qemu-4.1.0-rc2
sudo ./configure --disable-kvm --disable-werror --prefix=$HMOE --target-list="i386-softmmu x86_64-softmmu"

make
OR
make install

运行成功的话终端就会打印出以下字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/home/yuhao/6.828/qemu/i386-softmmu/qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::26000 -D qemu.log
VNC server running on 127.0.0.1:5900
6828 decimal is XXX octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.

键入kerninfo,值得注意的是,此内核监视器“直接”在模拟PC的“原始(虚拟)硬件”上运行。

1
2
3
4
5
6
7
8
K> kerninfo
Special kernel symbols:
_start 0010000c (phys)
entry f010000c (virt) 0010000c (phys)
etext f0101acd (virt) 00101acd (phys)
edata f0113060 (virt) 00113060 (phys)
end f01136a0 (virt) 001136a0 (phys)
Kernel executable memory footprint: 78KB

细节记录

  • PC中BIOS大小为64k, 物理地址范围0x000f0000-0x000fffff
  • PC 开机首先0xfffff0处执行 jmp [0xf000,0xe05b] 指令。在gdb中使用si(Step Instruction)进行跟踪。

使用gdb进行调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ make gdb
GNU gdb (GDB) 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
+ target remote localhost:26000
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel

(gdb) si
[f000:e05b] 0xfe05b: cmpw $0xffc8,%cs:(%esi) # 比较大小,改变PSW
0x0000e05b in ?? ()
(gdb) si
[f000:e062] 0xfe062: jne 0xd241d416 # 不相等则跳转
0x0000e062 in ?? ()
(gdb) si
[f000:e066] 0xfe066: xor %edx,%edx # 清零edx
0x0000e066 in ?? ()
(gdb) si
[f000:e068] 0xfe068: mov %edx,%ss
0x0000e068 in ?? ()
(gdb) si
[f000:e06a] 0xfe06a: mov $0x7000,%sp
0x0000e06a in ?? ()

BIOS运行过程中,它设定了中断描述符表,对VGA显示器等设备进行了初始化。在初始化完PCI总线和所有BIOS负责的重要设备后,它就开始搜索软盘、硬盘、或是CD-ROM等可启动的设备。最终,当它找到可引导磁盘时,BIOS从磁盘读取引导加载程序并将控制权转移给它。

Part 2: The Boot Loader

机器的物理地址空间有如下布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
+------------------+  <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\

/\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000

对于6.828,我们将使用传统的硬盘启动机制,这意味着我们的boot loader必须满足于512字节。

boot loader由一个汇编语言源文件boot/boot.S和一个C源文件boot/main.c组成。

boot.S

BIOS将boot.S这段代码从硬盘的第一个扇区load到物理地址为0x7c00的位置,同时CPU工作在real mode。

boot.S需要将CPU的工作模式从实模式转换到32位的保护模式, 并且 jump 到 C 语言程序。

源码阅读,知识点:

  • cli (clear interrupt)
  • cld (clear direction flag)

df: 方向标志位。在串处理指令中,控制每次操作后si,di的增减。(df=0,每次操作后si、di递增;df=1,每次操作后si、di递减)。

为了向前兼容早期的PC机,A20地址线接地,所以当地址大于1M范围时,会默认回滚到0处。所以在转向32位模式之前,需要使能A20。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1

movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64

seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2

movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60

  • test 逻辑运算指令,对两个操作数进行AND操作,并且修改PSW, test 与 AND 指令唯一不同的地方是,TEST 指令不修改目标操作数。

    • test al, 00001001b ;测试位 0 和位 3
  • lgdt gdtdesc, 加载全局描述符表,暂时不管全局描述表是如何生成的。

  • cr0, control register,控制寄存器。
    • CR0中包含了6个预定义标志,0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
1
2
3
4
5
6
7
8
9
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0

调试boot.S

设置一个断点在地址0x7c00处,这是boot sector被加载的位置。然后让程序继续运行直到这个断点。跟踪/boot/boot.S文件的每一条指令,同时使用boot.S文件和系统为你反汇编出来的文件obj/boot/boot.asm。你也可以使用GDB的x/i指令来获取去任意一个机器指令的反汇编指令,把源文件boot.S文件和boot.asm文件以及在GDB反汇编出来的指令进行比较。

追踪到bootmain函数中,而且还要具体追踪到readsect()子函数里面。找出和readsect()c语言程序的每一条语句所对应的汇编指令,回到bootmain(),然后找出把内核文件从磁盘读取到内存的那个for循环所对应的汇编语句。找出当循环结束后会执行哪条语句,在那里设置断点,继续运行到断点,然后运行完所有的剩下的语句。

下面我们将分别分析一下这道练习中所涉及到的两个重要文件,它们一起组成了boot loader。分别是/boot/boot.S/boot/main.c文件。其中前者是一个汇编文件,后者是一个C语言文件。当BIOS运行完成之后,CPU的控制权就会转移到boot.S文件上。所以我们首先看一下boot.S文件。

  /boot/boot.S:

1
2
3
4
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts

这几条指令就是boot.S最开始的几句,其中cli是boot.S,也是boot loader的第一条指令。这条指令用于把所有的中断都关闭。因为在BIOS运行期间有可能打开了中断。此时CPU工作在实模式下。

1
cld                         # String operations increment

这条指令用于指定之后发生的串处理操作的指针移动方向。在这里现在对它大致了解就够了。

1
2
3
4
5
# Set up the important data segment registers (DS, ES, SS).
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment

这几条命令主要是在把三个段寄存器,ds,es,ss全部清零,因为经历了BIOS,操作系统不能保证这三个寄存器中存放的是什么数。所以这也是为后面进入保护模式做准备。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 # Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1

movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64

seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2

movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60

这部分指令就是在准备把CPU的工作模式从实模式转换为保护模式。我们可以看到其中的指令包括inb,outb这样的IO端口命令。所以这些指令都是在对外部设备进行操作。0x64端口属于键盘控制器804x,名称是控制器读取状态寄存器。

不断的检测bit1。bit1的值代表输入缓冲区是否满了,也就是说CPU传送给控制器的数据,控制器是否已经取走了,如果CPU想向控制器传送新的数据的话,必须先保证这一位为0。所以这三条指令会一直等待这一位变为0,才能继续向后运行。

当0x64端口准备好读入数据后,现在就可以写入数据了,所以19~20这两条指令是把0xd1这条数据写入到0x64端口中。当向0x64端口写入数据时,则代表向键盘控制器804x发送指令。这个指令将会被送给0x60端口。

D1指令代表下一次写入0x60端口的数据将被写入给804x控制器的输出端口。可以理解为下一个写入0x60端口的数据是一个控制指令。

然后21~24号指令又开始再次等待,等待刚刚写入的指令D1,是否已经被读取了。

如果指令被读取了,25~26号指令会向控制器输入新的指令,0xdf。这个指令的含义是,使能A20线,代表可以进入保护模式了。

1
2
3
4
5
6
7
8
27   # Switch from real to protected mode, using a bootstrap GDT
28 # and segment translation that makes virtual addresses
29 # identical to their physical addresses, so that the
30 # effective memory map does not change during the switch.
31 lgdt gdtdesc
32 movl %cr0, %eax
33 orl $CR0_PE_ON, %eax
34 movl %eax, %cr0

首先31号指令lgdt gdtdesc,是把gdtdesc这个标识符的值送入全局映射描述符表寄存器GDTR中。这个GDT表是处理器工作于保护模式下一个非常重要的表。这条指令的功能就是把关于GDT表的一些重要信息存放到CPU的GDTR寄存器中,其中包括GDT表的内存起始地址,以及GDT表的长度。这个寄存器由48位组成,其中低16位表示该表长度,高32位表该表在内存中的起始地址。所以gdtdesc是一个标识符,标识着一个内存地址。从这个内存地址开始之后的6个字节中存放着GDT表的长度和起始地址。我们可以在这个文件的末尾看到gdtdesc,如下:

1
2
3
4
5
6
7
8
9
10
 1 # Bootstrap GDT
2 .p2align 2 # force 4 byte alignment
3 gdt:
4 SEG_NULL # null seg
5 SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
6 SEG(STA_W, 0x0, 0xffffffff) # data seg
7
8 gdtdesc:
9 .word 0x17 # sizeof(gdt) - 1
10 .long gdt # address gdt

其中第3行的gdt是一个标识符,标识从这里开始就是GDT表了。可见这个GDT表中包括三个表项(4,5,6行),分别代表三个段,null seg,code seg,data seg。由于xv6其实并没有使用分段机制,也就是说数据和代码都是写在一起的,所以数据段和代码段的起始地址都是0x0,大小都是0xffffffff=4GB。

在第4~6行是调用SEG()子程序来构造GDT表项的。这个子函数定义在mmu.h中,形式如下:  

1
2
3
4
 #define SEG(type,base,lim)                    \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

可见函数需要3个参数,一是type即这个段的访问权限,二是base,这个段的起始地址,三是lim,即这个段的大小界限。gdt表中的每一个表项的结构:

1
2
3
4
5
6
7
8
struct gdt_entry_struct {
limit_low: resb 2
base_low: resb 2
base_middle : resb 1
access: resb 1
granularity: resb 1
base_high: resb 1
}

每个表项一共8字节,其中limit_low就是limit的低16位。base_low就是base的低16位,依次类推。

然后在gdtdesc处就要存放这个GDT表的信息了,其中0x17是这个表的大小-1 = 0x17 = 23,至于为什么不直接存表的大小24,根据查询是官方规定的。紧接着就是这个表的起始地址gdt。

1
2
3
4
5
6
7
8
27   # Switch from real to protected mode, using a bootstrap GDT
28 # and segment translation that makes virtual addresses
29 # identical to their physical addresses, so that the
30 # effective memory map does not change during the switch.
31 lgdt gdtdesc
32 movl %cr0, %eax
33 orl $CR0_PE_ON, %eax
34 movl %eax, %cr0

再回到刚才那里,当加载完GDT表的信息到GDTR寄存器之后。紧跟着3个操作,32~34指令。 这几步操作明显是在修改CR0寄存器的内容。CR0寄存器还有CR1~CR3寄存器都是80x86的控制寄存器。其中$CR0_PE的值定义于”mmu.h”文件中,为0x00000001。可见上面的操作是把CR0寄存器的bit0置1,CR0寄存器的bit0是保护模式启动位,把这一位值1代表保护模式启动。

1
35  ljmp    $PROT_MODE_CSEG, $protcseg

这只是一个简单的跳转指令,这条指令的目的在于把当前的运行模式切换成32位地址模式

1
2
3
4
5
6
7
8
protcseg:
# Set up the protected-mode data segment registers
36 movw $PROT_MODE_DSEG, %ax # Our data segment selector
37 movw %ax, %ds # -> DS: Data Segment
38 movw %ax, %es # -> ES: Extra Segment
39 movw %ax, %fs # -> FS
40 movw %ax, %gs # -> GS
41 movw %ax, %ss # -> SS: Stack Segment

修改这些寄存器的值。这些寄存器都是段寄存器,如果刚刚加载完GDTR寄存器我们必须要重新加载所有的段寄存器的值,而其中CS段寄存器必须通过长跳转指令,即23号指令来进行加载。这样才能是GDTR的值生效。

1
2
3
# Set up the stack pointer and call into C.
42 movl $start, %esp
43 call bootmain

接下来的指令就是要设置当前的esp寄存器的值,然后准备正式跳转到main.c文件中的bootmain函数处。我们接下来分析一下这个函数的每一条指令:

1
2
// read 1st page off disk
1 readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

这里面调用了一个函数readseg,这个函数在bootmain之后被定义了:

1
void readseg(uchar *pa, uint count, uint offset);

它的功能从注释上来理解应该是,把距离内核起始地址offset个偏移量存储单元作为起始,将它和它之后的count字节的数据读出送入以pa为起始地址的内存物理地址处。

所以这条指令是把内核的第一个页(4MB = 4096 = SECTSIZE8 = 5128)的内容读取的内存地址ELFHDR(0x10000)处。其实完成这些后相当于把操作系统映像文件的elf头部读取出来放入内存中。

读取完这个内核的elf头部信息后,需要对这个elf头部信息进行验证,并且也需要通过它获取一些重要信息。

elf文件:elf是一种文件格式,主要被用来把程序存放到磁盘上。是在程序被编译和链接后被创建出来的。一个elf文件包括多个段。对于一个可执行程序,通常包含存放代码的文本段(text section),存放全局变量的data段,存放字符串常量的rodata段。elf文件的头部就是用来描述这个elf文件如何在存储器中存储。

1
2
2 if (ELFHDR->e_magic != ELF_MAGIC)
3 goto bad;

elf头部信息的magic字段是整个头部信息的开端。并且如果这个文件是格式是ELF格式的话,文件的elf->magic域应该是=ELF_MAGIC的,所以这条语句就是判断这个输入文件是否是合法的elf可执行文件。

1
4 ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);

我们知道头部中一定包含Program Header Table。这个表格存放着程序中所有段的信息。通过这个表我们才能找到要执行的代码段,数据段等等。所以我们要先获得这个表。

这条指令就可以完成这一点,首先elf是表头起址,而phoff字段代表Program Header Table距离表头的偏移量。所以ph可以被指定为Program Header Table表头。

1
5 eph = ph + ELFHDR->e_phnum;

由于phnum中存放的是Program Header Table表中表项的个数,即段的个数。所以这步操作是吧eph指向该表末尾。

1
2
3
4
6 for (; ph < eph; ph++)
// p_pa is the load address of this segment (as well
// as the physical address)
7 readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

这个for循环就是在加载所有的段到内存中。ph->paddr根据参考文献中的说法指的是这个段在内存中的物理地址。ph->off字段指的是这一段的开头相对于这个elf文件的开头的偏移量。ph->filesz字段指的是这个段在elf文件中的大小。ph->memsz则指的是这个段被实际装入内存后的大小。通常来说memsz一定大于等于filesz,因为段在文件中时许多未定义的变量并没有分配空间给它们。

所以这个循环就是在把操作系统内核的各个段从外存读入内存中。

1
8 ((void (*)(void)) (ELFHDR->e_entry))();

e_entry字段指向的是这个文件的执行入口地址。所以这里相当于开始运行这个文件。也就是内核文件。 自此就把控制权从boot loader转交给了操作系统的内核。

分析完了程序后,来完成Exercise要求我们做的事情:

在一个terminal中cd到lab目录下,执行make qemu-gdb。再开一个 terminal执行make gdb

因为BIOS会把boot loader加载到0x7c00的位置,因此设置断点b *0x7c00。再执行c,会看到QUMU终端上显示Booting from hard disk。

执行x/30i 0x7c00就能看到与boot.S中类似的汇编代码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) c
Continuing.
[ 0:7c00] => 0x7c00: cli

Breakpoint 1, 0x00007c00 in ?? ()
(gdb) x/30i 0x7c00
=> 0x7c00: cli
0x7c01: cld
0x7c02: xor %eax,%eax
0x7c04: mov %eax,%ds
0x7c06: mov %eax,%es
0x7c08: mov %eax,%ss
0x7c0a: in $0x64,%al
0x7c0c: test $0x2,%al
0x7c0e: jne 0x7c0a
0x7c10: mov $0xd1,%al
0x7c12: out %al,$0x64
0x7c14: in $0x64,%al
0x7c16: test $0x2,%al
0x7c18: jne 0x7c14
0x7c1a: mov $0xdf,%al
0x7c1c: out %al,$0x60
0x7c1e: lgdtl (%esi)
0x7c21: fs jl 0x7c33
0x7c24: and %al,%al
0x7c26: or $0x1,%ax
0x7c2a: mov %eax,%cr0
0x7c2d: ljmp $0xb866,$0x87c32
0x7c34: adc %al,(%eax)

这条gdb指令是把存放在0x7c00以及之后30字节的内存里面的指令反汇编出来,我们可以拿它直接和boot.S以及在obj/boot/boot.asm进行比较,这三者在指令上没有区别,只不过在源代码中,我们指定了很多标识符比如set20.1,.start,这些标识符在被汇编成机器代码后都会被转换成真实物理地址。比如set20.1就被转换为0x7c0a,那么在obj/boot/boot.asm中还把这种对应关系列出来了,但是在真实执行时,即第一种情况中,就看不到set20.1标识符了,完全是真实物理地址。

加载内核

接下来我们分析boot loader的C语言部分。

首先熟悉以下C指针。 编译运行pointer.c结果。 可以发现 a[],b的地址相差很多,因为两者所存放的段不同。

1
2
3
4
5
6
7
8
1: a = 0xbfa8bdbc, b = 0x9e3a160, c = (nil)
2: a[0] = 200, a[1] = 101, a[2] = 102, a[3] = 103
3: a[0] = 200, a[1] = 300, a[2] = 301, a[3] = 302
4: a[0] = 200, a[1] = 400, a[2] = 301, a[3] = 302
5: a[0] = 200, a[1] = 128144, a[2] = 256, a[3] = 302

// b = a + 4
6: a = 0xbfa8bdbc, b = 0xbfa8bdc0, c = 0xbfa8bdbd

ELF格式非常强大和复杂,但大多数复杂的部分都是为了支持共享库的动态加载,在6.828课程中并不会用到。在本课程中,我们可以把ELF可执行文件简单地看为带有加载信息的标头,后跟几个程序部分,每个程序部分都是一个连续的代码块或数据,其将被加载到指定内存中。

我们所需要关心的Program Section是:

  • .text : 可执行指令
  • .rodata: 只读数据段,例如字符串常量。(但是,我们不会费心设置硬件来禁止写入。)
  • .data : 存放已经初始化的数据
  • .bss : 存放未初始化的变量, 但是在ELF中只需要记录.bss的起始地址和长度。Loader and program必须自己将.bss段清零。

每个程序头的ph-> p_pa字段包含段的目标物理地址(在这种情况下,它实际上是一个物理地址,尽管ELF规范对该字段的实际含义含糊不清)

BIOS会将引导扇区的内容加载到 0x7c00 的位置,引导程序也就从0x7C00的位置开始执行。我们通过-Ttext 0x7C00将链接地址传递给boot / Makefrag中的链接器,因此链接器将在生成的代码中生成正确的内存地址。

除了部分信息之外,ELF头中还有一个对我们很重要的字段,名为e_entry。该字段保存程序中入口点的链接地址:程序应该开始执行的代码段的存储地址。 在反汇编代码中,可以看到最后call 了 0x10018地址。

1
2
((void (*)(void)) (ELFHDR->e_entry))();
7d6b: ff 15 18 00 01 00 call *0x10018

在0x7d6b 打断点后,c 再si一次,发现实际跳转地址位0x10000c

1
2
3
4
5
6
7
8
9
(gdb) b *0x7d6b
Breakpoint 3 at 0x7d6b
(gdb) c
Continuing.
=> 0x7d6b: call *0x10018

Breakpoint 3, 0x00007d6b in ?? ()
(gdb) si
=> 0x10000c: movw $0x1234,0x472

与实际执行objdump -f kernel的 结果一致。

1
2
3
4
../kern/kernel:     file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c

Part3:The Kernel

我们现在将开始更详细地研究JOS内核。(最后你会写一些代码!)。与引导加载程序一样,内核从一些汇编语言代码开始,这些代码设置可以使C语言代码正确执行。

使用虚拟内存来解决位置依赖问题

操作系统内核通常被链接到非常高的虚拟地址(例如0xf0100000)下运行,以便留下处理器虚拟地址空间的低地址部分供用户程序使用。 在下一个lab中,这种安排的原因将变得更加清晰。

许多机器在地址范围无法达到0xf0100000,因此我们无法指望能够在那里存储内核。相反,我们将使用处理器的内存管理硬件将虚拟地址0xf0100000(内核代码期望运行的链接地址)映射到物理地址0x00100000(引导加载程序将内核加载到物理内存中)。尽管内核的虚拟地址足够高,可以为用户进程留下大量的内存空间,在物理地址中内核将会被加载到1MB的位置,仅次于BIOS。

现在,我们只需映射前4MB的物理内存,这足以让我们启动并运行。 我们使用kern/entrypgdir.c中手写的,静态初始化的页面目录和页表来完成此操作。 现在,你不必了解其工作原理的细节,只需注意其实现的效果。

实现虚拟地址,有一个很重要的寄存器CR0-PG:

PG:CR0的位31是分页(Paging)标志。当设置该位时即开启了分页机制;当复位时则禁止分页机制,此时所有线性地址等同于物理地址。在开启这个标志之前必须已经或者同时开启PE标志。即若要启用分页机制,那么PE和PG标志都要置位。

Exercise 7

  1. 使用QEMU和GDB跟踪到JOS内核并停在movl %eax,%cr0。 检查内存为0x00100000和0xf0100000。 现在,使用stepi GDB命令单步执行该指令。 再次检查内存为0x00100000和0xf0100000。 确保你了解刚刚发生的事情。

注意实验文档上所说的,硬件实现的页表转换机制将0xf0000000等那些f打头的16进制地址转到0x00100000。GDB调试设置断点时,设置的是物理地址,不是逻辑地址,所以断点设置为kernel的入口地址。

b *0x10000c
1
不知为何,断点设置到0x100000不行,可能是因为代码段中那一段标号和段标识我不认识。

0x100000处的反汇编代码如下

1
2
3
4
5
6
7
8
9
10
.globl entry
entry:
movw $0x1234,0x472 # warm boot
f0100000: 02 b0 ad 1b 00 00 add 0x1bad(%eax),%dh
f0100006: 00 00 add %al,(%eax)
f0100008: fe 4f 52 decb 0x52(%edi)
f010000b: e4 .byte 0xe4

f010000c <entry>:
f010000c: 66 c7 05 72 04 00 00 movw $0x1234,0x472

执行过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x10000c: movw $0x1234,0x472

Breakpoint 1, 0x0010000c in ?? ()
(gdb) s
Cannot find bounds of current function
(gdb) si
=> 0x100015: mov $0x112000,%eax
0x00100015 in ?? ()
(gdb) si
=> 0x10001a: mov %eax,%cr3
0x0010001a in ?? ()
(gdb) si
=> 0x10001d: mov %cr0,%eax
0x0010001d in ?? ()
(gdb) si
=> 0x100020: or $0x80010001,%eax
0x00100020 in ?? ()
(gdb) si
=> 0x100025: mov %eax,%cr0
0x00100025 in ?? ()
(gdb) si
=> 0x100028: mov $0xf010002f,%eax
0x00100028 in ?? ()
(gdb)

当执行到movl %eax,%cr0时,停下,此时查看两处内存结果如下。

1
2
3
4
5
6
7
=> 0x100025:    mov    %eax,%cr0
0x00100025 in ?? ()
(gdb) x/1x 0x00100000
0x100000: 0x1badb002
(gdb) x/1x 0xf0100000
0xf0100000 <_start+4026531828>: 0x00000000
(gdb)

因为0xf0100000处不是我们真正装载内核的地方,逻辑地址0xf0100000被映射成了0x00100000,所以低地址处有内容,高地址处无内容。

当单步执行完movl %eax,%cr0 时,停下,此时查看两处内存结果如下。

1
2
3
4
5
6
7
=> 0x100028:    mov    $0xf010002f,%eax
0x00100028 in ?? ()
(gdb) x/1x 0x00100000
0x100000: 0x1badb002
(gdb) x/1x 0xf0100000
0xf0100000 <_start+4026531828>: 0x1badb002
(gdb)

可以看到高地址处和低地址处值相同了。

原因其实在实验指导书里写着。

Once CR0_PG is set, memory references are virtual addresses that get translated by the virtual memory hardware to physical addresses. entry_pgdir translates virtual addresses in the range 0xf0000000 through 0xf0400000 to physical addresses 0x00000000 through 0x00400000, as well as virtual addresses 0x00000000 through 0x00400000 to physical addresses 0x00000000 through 0x00400000.

首先明确cr0是什么。cr0全称是control register 0.下面是wiki中的解释。

The CR0 register is 32 bits long on the 386 and higher processors. On x86-64 processors in long mode, it (and the other control registers) is 64 bits long. CR0 has various control flags that modify the basic operation of the processor.

Bit Name Full Name Description
0 PE Protected Mode Enable If 1, system is in protected mode, else system is in real mode
1 MP Monitor co-processor Controls interaction of WAIT/FWAIT instructions with TS flag in CR0
2 EM Emulation If set, no x87 floating-point unit present, if clear, x87 FPU present
3 TS Task switched Allows saving x87 task context upon a task switch only after x87 instruction used
4 ET Extension type On the 386, it allowed to specify whether the external math coprocessor was an 80287 or 80387
5 NE Numeric error Enable internal x87 floating point error reporting when set, else enables PC style x87 error detection
16 WP Write protect When set, the CPU can’t write to read-only pages when privilege level is 0
18 AM Alignment mask Alignment check enabled if AM set, AC flag (in EFLAGS register) set, and privilege level is 3
29 NW Not-write through Globally enables/disable write-through caching
30 CD Cache disable Globally enables/disable the memory cache
31 PG Paging If 1, enable paging and use the § CR3 register, else disable paging.

把eax赋给cr0时,eax=0x80110001,对应上面的标志位就能知道发出了什么控制信息。最关键的是PG,这个信号打开了页表机制,以后都会自动将 0xf0000000 到 0xf0400000 的虚拟(逻辑)地址转成 0x00000000 到 0x00400000 的物理地址。

所以此处会自动把0xf0100000转换成0x00100000,所以两者的值相等。

如果映射机制失败,我觉得jmp *%eax之后会失败。因为此时eax的值是0xf010002f,如果没有地址映射,那会指向这个物理高地址,而不是本应指向的0x100000附近的低地址,就会出错。

1
2
3
4
5
6
7
8
9
10
11
12
=> 0x100025:    mov    $0xf010002c,%eax
0x00100025 in ?? ()
(gdb)
=> 0x10002a: jmp *%eax
0x0010002a in ?? ()
(gdb)
=> 0xf010002c <relocated>: add %al,(%eax)
relocated () at kern/entry.S:74
74 movl $0x0,%ebp # nuke frame pointer
(gdb)
Remote connection closed
(gdb)

上面是注释掉movl %eax,%cr0之后的调试结果。果然,跳转之后的第一条指令就报错了。

在entry.S中说:

1
The kernel (this code) is linked at address ~(KERNBASE + 1 Meg),

在程序编译后,被链接到高地址处。在kernel.ld链接脚本文件里指定了。
1
2
/* Link the kernel at this address: "." means the current address */
. = 0xF0100000;

但是bootloader 实际把kernel加载到了0x100000的位置

格式化输出到控制台

激动人心的时刻到了,我们终于到了能对设备进行操作的阶段了。能打印出信息,是实现交互的开始,也是我们之后调试的一个重要途径。

大多数人都把printf()这样的函数认为是理所当然的,有时甚至认为它们是C语言的“原语“。但在OS内核中,我们必须自己实现所有I/O.

阅读kern/printf.clib/printfmt.ckern/console.c三个源代码,理清三者之间的关系。

printf.c基于printfmt()和 kernel console’s cputchar();

Exercise 8

我们省略了一小段代码 - 使用“%o”形式的模式打印八进制数所需的代码。 查找并填写此代码片段。

1
2
3
4
5
6
case 'o':
// Replace this with your code.
putch('0', putdat);
num = getuint(&ap, lflag);
base = 8;
goto number;

就是把%u的代码复制一遍,base 改为 8 就差不多了,并不复杂。

Explain the interface between printf.c and console.c. Specifically, what function does console.c export? How is this function used by printf.c?

printf.c中使用了console.c中的cputchar函数,并封装为putch函数。并以函数形参传递到printfmt.c中的vprintfmt函数,用于向屏幕上输出一个字符。

解释console.c中的一段代码。

1
2
3
4
5
6
7
8
9
10
11
12
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
// 显示字符数超过CRT一屏可显示的字符数
int i;
// 清除buf中"第一行"的字符
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
// CRT显示器需要对其用空格擦写才能去掉本来以及显示了的字符。
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
// 显示起点退回到最后一行起始
crt_pos -= CRT_COLS;
}

首先理解几个宏定义和函数

  • CRT_ROWSCRT_COLS:CRT显示器行列最大值, 此处是25x80
  • ctr_buf在初始化时指向了显示器I/O地址

memmovectr_buf+CTR_COLS复制到ctr_buf中,就是清除掉第一行的数据,把最后一行给空出来,2~n行的数据(CRT_SIZE - CRT_COLS)个,移动到1~n-1行的位置。

跟踪执行以下代码,在调用cprintf()时,fmtap指向什么?

1
2
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

kern/init.ci386_init()下加入代码,就可以直接测试;加Lab1_exercise8_3标号的目的是为了在kern/kernel.asm反汇编代码中容易找到添加的代码的位置。可以看到地址在0xf0100080

1
2
3
4
5
6
7
8
9
10
11
// lab1 Exercise_8
{
cprintf("Lab1_Exercise_8:\n");
int x = 1, y = 3, z = 4;
//
Lab1_exercise8_3:
cprintf("x %d, y %x, z %d\n", x, y, z);

unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
}

调试过程fmt=0xf010478d , ap=0xf0118fc4; fmt指向字符串,ap指向栈顶

1
cprintf (fmt=0xf010478d "x %d, y %x, z %d\n") at kern/printf.c:27

可以看到以上地址处就存了字符串

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) x/s 0xf010478d
0xf010478d: "x %d, y %x, z %d\n"

gdb) si
=> 0xf0102f85 <vcprintf>: push %ebp
vcprintf (fmt=0xf010478d "x %d, y %x, z %d\n", ap=0xf0118fc4 "\001")
at kern/printf.c:18
18 {

(gdb) x/16b 0xf0118fc4
0xf0118fc4: 0x01 0x00 0x00 0x00 0x03 0x00 0x00 0x00
0xf0118fcc: 0x04 0x00 0x00 0x00 0x7b 0x47 0x10 0xf0

引用一段Github上大神做的labclpsz/mit-jos-2014的execise8中的一段话。

从这个练习可以看出来,正是因为C函数调用实参的入栈顺序是从右到左的,才使得调用参数个数可变的函数成为可能(且不用显式地指出参数的个数)。但是必须有一个方式来告诉实际调用时传入的参数到底是几个,这个是在格式化字符串中指出的。如果这个格式化字符串指出的参数个数和实际传入的个数不一致,比如说传入的参数比格式化字符串指出的要少,就可能会使用到栈上错误的内存作为传入的参数,编译器必须检查出这样的错误。

4.运行以下代码,输出结果是什么。

1
2
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);

调试输出了He110 World57616的十六进制形式为E110,因为是小端机,i的在内存中为0x720x6c0x640x00,对应ASCII为rld\0

初始化

打开文件kern/entry.S,按ctrl+f查找关键字,找找stack这个词出现在哪里,看看每次出现的含义。

77行处将一个宏变量bootstacktop的值赋值给了寄存器esp。而bootstacktop出现在bootstack下,bootstack出现在.data段下,这是数据段。可以肯定,这就是栈了。通过93行.space指令,在bootstack位置处初始化了KSTKSIZE这么多的空间。KSTKSIZEinc/memlayout.h里面定义,是8*PGSIZE,而PGSIZEinc/mmu.h中定义,值为4096。

栈在内核入口的汇编代码中初始化,是通过一个汇编指令.space,大小是8 * 4096。接下来看看栈的位置。

查看反汇编代码obj/kern/kernel.asmbootstacktop的值为0xf010f000。这就是栈的位置,准确来说,是栈顶,栈将向地址值更小的方向生长。

栈的行为

在正式运行一段代码之前,esp寄存器需要先初始化,正如前文所说。这个初始化可以是手动完成的,如kern/entry.S,也可以是自动完成的,如call指令。程序运行时,esp保存的地址以下的内存,都是栈可以生长,但尚未生长到的。esp表示的是“栈顶地址”stack top。

x86栈指针esp寄存器纸箱栈的最低地址。这个地址之下的都是空闲的。将一个值压入栈需要减小栈指针,同时把值写到栈指针之前指向的地方。在32位机器上,栈只能存储32位的值,esp只能被4整除。

程序“压栈”,就是减小esp,并在刚刚esp指向的位置上写入数据。

还有一个寄存器ebp,意思是base pointer,记录的是当前函数栈的开头。没有指令会自动更新ebp的值,但是任何C编译器都要遵守这个规定,写汇编的程序员也是,调用函数时必须写指令更新ebp寄存器。

调用函数

在执行新的函数callee代码之前,先保存旧函数caller的栈的位置。这样一来,callee才可以返回到正确的指令上。通过ebp寄存器的值,Debugger可以迅速找到调用这个函数的函数,一路找到最开始执行这个函数的函数,这种操作称为backtrace

看到反汇编代码obj/kern/kernel.asm中,所有C函数的第一个指令都是push %ebp,保存了旧的栈地址。第二个指令都是mov %esp, %ebp,将当前栈地址,也就是函数的栈的开头,保存到ebp

函数返回

函数返回时,寄存器eip,也就是Instruction Pointer,跳转到调用本函数的call指令的下一个指令,且esp增加。栈是向下增长的,所以这其实是在“弹出”。调用函数时,函数接受的参数都被压栈,故返回时相应弹出。

Exercise 10

obj/kern/kernel.asm找到test_backtrace函数,并设置断点。进行调试。

1
2
3
4
5
6
7
8
9
void test_backtrace(int x)
{
cprintf("entering test_backtrace %d\n", x);
if (x > 0)
test_backtrace(x-1);
else
mon_backtrace(0, 0, 0);
cprintf("leaving test_backtrace %d\n", x);
}

test_backtrace函数对应的汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
f0100040:	55                   	push   %ebp
f0100041: 89 e5 mov %esp,%ebp
f0100043: 56 push %esi
f0100044: 53 push %ebx
f0100045: e8 5b 01 00 00 call f01001a5 <\_\_x86.get_pc_thunk.bx>
f010004a: 81 c3 be 12 01 00 add $0x112be,%ebx
f0100050: 8b 75 08 mov 0x8(%ebp),%esi
f0100053: 83 ec 08 sub $0x8,%esp
f0100056: 56 push %esi
f0100057: 8d 83 18 07 ff ff lea -0xf8e8(%ebx),%eax
f010005d: 50 push %eax
f010005e: e8 cf 09 00 00 call f0100a32 <cprintf>
f0100063: 83 c4 10 add $0x10,%esp
f0100066: 85 f6 test %esi,%esi
f0100068: 7f 2b jg f0100095 <test\_backtrace+0x55>
f010006a: 83 ec 04 sub $0x4,%esp
f010006d: 6a 00 push $0x0
f010006f: 6a 00 push $0x0
f0100071: 6a 00 push $0x0
f0100073: e8 f4 07 00 00 call f010086c <mon\_backtrace>
f0100078: 83 c4 10 add $0x10,%esp
f010007b: 83 ec 08 sub $0x8,%esp
f010007e: 56 push %esi
f010007f: 8d 83 34 07 ff ff lea -0xf8cc(%ebx),%eax
f0100085: 50 push %eax
f0100086: e8 a7 09 00 00 call f0100a32 <cprintf>
}
f010008b: 83 c4 10 add $0x10,%esp
f010008e: 8d 65 f8 lea -0x8(%ebp),%esp
f0100091: 5b pop %ebx
f0100092: 5e pop %esi
f0100093: 5d pop %ebp
f0100094: c3 ret
f0100095: 83 ec 0c sub $0xc,%esp
f0100098: 8d 46 ff lea -0x1(%esi),%eax
f010009b: 50 push %eax
f010009c: e8 9f ff ff ff call f0100040 <test\_backtrace>
f01000a1: 83 c4 10 add $0x10,%esp
f01000a4: eb d5 jmp f010007b <test\_backtrace+0x3b>

观察test_backtrace函数调用栈

下面开始观察test_backtrace函数的调用栈。%esp存储栈顶的位置,%ebp存储调用者栈顶的位置,%eax存储x的值,这几个寄存器需要重点关注,因此我使用gdb的display命令设置每次运行完成后自动打印它们的值,此外我也设置了自动打印栈内被用到的那段内存的数据,以便清楚观察栈的变化情况。Let’s go.

进入test_backtrace(5)

1
2
3
f01000d1:	c7 04 24 05 00 00 00 	movl   $0x5,(%esp)
f01000d8: e8 63 ff ff ff call f0100040 <test\_backtrace>
f01000dd: 83 c4 10 add $0x10,%esp

test_backtrace函数的调用发生在i386_init函数中,传入的参数x=5.我们将从这里开始跟踪栈内数据的变化情况。各寄存器及栈内的数据如下所示。可见,共有两个4字节的整数被压入栈:

输入参数的值(也就是5)。
call指令的下一条指令的地址(也就是f01000dd)。

1
2
3
4
5
%esp = 0xf010ffdc
%ebp = 0xf010fff8
// stack info
0xf010ffe0: 0x00000005 // 第1次调用时的输入参数:5
0xf010ffdc: 0xf01000dd // 第1次调用时的返回地址

进入test_backtrace函数后,涉及栈内数据修改的指令可以分为三部分:

  • 函数开头,将部分寄存器的值压栈,以便函数结束前可以恢复。
  • 调用cprintf前,将输入参数压入栈。
  • 在第2次调用test_backtrace前,将输入参数压入栈。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// function start
f0100040: 55 push %ebp
f0100041: 89 e5 mov %esp,%ebp
f0100043: 56 push %esi
f0100044: 53 push %ebx
// call cprintf
f0100053: 83 ec 08 sub $0x8,%esp
f0100056: 56 push %esi
f0100057: 8d 83 18 07 ff ff lea -0xf8e8(%ebx),%eax
f010005d: 50 push %eax
f010005e: e8 cf 09 00 00 call f0100a32 <cprintf>
f0100063: 83 c4 10 add $0x10,%esp
// call test_backtrace(x-1)
f0100095: 83 ec 0c sub $0xc,%esp
f0100098: 8d 46 ff lea -0x1(%esi),%eax
f010009b: 50 push %eax
f010009c: e8 9f ff ff ff call f0100040 <test_backtrace>

进入test_backtrace(4)

在即将进入test_backtrace(4)前,栈内数据如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
%esp = 0xf010ffc0
%ebp = 0xf010ffd8
// stack info
0xf010ffe0: 0x00000005 // 第1次调用时的输入参数:5
0xf010ffdc: 0xf01000dd // 第1次调用时的返回地址
0xf010ffd8: 0xf010fff8 // 第1次调用时寄存器%ebp的值
0xf010ffd4: 0x10094 // 第1次调用时寄存器%esi的值
0xf010ffd0: 0xf0111308 // 第1次调用时寄存器%ebx的值
0xf010ffcc: 0xf010004a // 残留数据,不需关注
0xf010ffc8: 0x00000000 // 残留数据,不需关注
0xf010ffc4: 0x00000005 // 残留数据,不需关注
0xf010ffc0: 0x00000004 // 第2次调用时的输入参数

进入mon_backtrace(0, 0, 0)

在即将进入mon_backtrace(0, 0, 0)前,栈内数据如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
%esp = 0xf010ff20
%ebp = 0xf010ff38
// stack info
0xf010ffe0: 0x00000005 // 第1次调用时的输入参数:5
0xf010ffdc: 0xf01000dd // 第1次调用时的返回地址
0xf010ffd8: 0xf010fff8 // 第1次调用开始时寄存器%ebp的值
0xf010ffd4: 0x10094 // 第1次调用开始时寄存器%esi的值
0xf010ffd0: 0xf0111308 // 第1次调用开始时寄存器%ebx的值
0xf010ffcc: 0xf010004a // 预留空间,不需关注
0xf010ffc8: 0x00000000 // 预留空间,不需关注
0xf010ffc4: 0x00000005 // 预留空间,不需关注
0xf010ffc0: 0x00000004 // 第2次调用时的输入参数:4
0xf010ffbc: 0xf01000a1 // 第2次调用时的返回地址
0xf010ffb8: 0xf010ffd8 // 第2次调用开始时寄存器%ebp的值
0xf010ffb4: 0x00000005 // 第2次调用开始时寄存器%esi的值
0xf010ffb0: 0xf0111308 // 第2次调用开始时寄存器%ebx的值
0xf010ffac: 0xf010004a // 预留空间,不需关注
0xf010ffa8: 0x00000000 // 预留空间,不需关注
0xf010ffa4: 0x00000004 // 预留空间,不需关注
0xf010ffa0: 0x00000003 // 第3次调用时的输入参数:3
0xf010ff9c: 0xf01000a1 // 第3次调用时的返回地址
0xf010ff98: 0xf010ffb8 // 第3次调用开始时寄存器%ebp的值
0xf010ff94: 0x00000004 // 第3次调用开始时寄存器%esi的值
0xf010ff90: 0xf0111308 // 第3次调用开始时寄存器%ebx的值
0xf010ff8c: 0xf010004a // 预留空间,不需关注
0xf010ff88: 0xf010ffb8 // 预留空间,不需关注
0xf010ff84: 0x00000003 // 预留空间,不需关注
0xf010ff80: 0x00000002 // 第4次调用时的输入参数:2
0xf010ff7c: 0xf01000a1 // 第4次调用时的返回地址
0xf010ff78: 0xf010ff98 // 第4次调用开始时寄存器%ebp的值
0xf010ff74: 0x00000003 // 第4次调用开始时寄存器%esi的值
0xf010ff70: 0xf0111308 // 第4次调用开始时寄存器%ebx的值
0xf010ff6c: 0xf010004a // 预留空间,不需关注
0xf010ff68: 0xf010ff98 // 预留空间,不需关注
0xf010ff64: 0x00000002 // 预留空间,不需关注
0xf010ff60: 0x00000001 // 第5次调用时的输入参数:1
0xf010ff5c: 0xf01000a1 // 第5次调用时的返回地址
0xf010ff58: 0xf010ff78 // 第5次调用开始时寄存器%ebp的值
0xf010ff54: 0x00000002 // 第5次调用开始时寄存器%esi的值
0xf010ff50: 0xf0111308 // 第5次调用开始时寄存器%ebx的值
0xf010ff4c: 0xf010004a // 预留空间,不需关注
0xf010ff48: 0xf010ff78 // 预留空间,不需关注
0xf010ff44: 0x00000001 // 预留空间,不需关注
0xf010ff40: 0x00000000 // 第6次调用时的输入参数:0
0xf010ff3c: 0xf01000a1 // 第6次调用时的返回地址
0xf010ff38: 0xf010ff58 // 第6次调用开始时寄存器%ebp的值
0xf010ff34: 0x00000001 // 第6次调用开始时寄存器%esi的值
0xf010ff30: 0xf0111308 // 第6次调用开始时寄存器%ebx的值
0xf010ff2c: 0xf010004a // 预留空间,不需关注
0xf010ff28: 0x00000000 // 第7次调用时的第1个输入参数:0
0xf010ff24: 0x00000000 // 第7次调用时的第2个输入参数:0
0xf010ff20: 0x00000000 // 第7次调用时的第3个输入参数:0

mon_backtrace函数目前内部为空,不需关注。

退出mon_backtrace(0, 0, 0):通过add $0x10, %esp语句,将输入参数及预留的4字节从栈中清除。此时%esp = 0xf010ff30,%ebp = 0xf010ff38.

退出test_backtrace(0):连续3个pop语句将ebx, esi和ebp寄存器依次出栈,然后通过ret语句返回。其他1~5的退出过程类似,不再赘述。

实现backtrace

Lab中的练习要求我们实现一个backtrace函数,能够打印函数调用的地址和传给函数的参数值。其实CLion的Debugger就有这个功能:

我们要实现的函数,就是可以获得函数此时的ebp寄存器的值、返回的地址、和获得参数的值。

查找mon_backtrace,来到已经准备好的一个函数。函数中写了Your code here注释,让我们在这里实现backtrace功能。我的实现如下:

1
2
3
4
5
6
7
8
9
10
11
uint32_t ebp = read_ebp();                // 拿到ebp的值,类型和函数read_ebp的返回类型一致
int *ebp_base_ptr = (int *)ebp; // 转化为指针
uint32_t eip = ebp_base_ptr[1]; // 拿到返回地址
cprintf("ebp %x, eip %x, args ", ebp, eip);

int *args = ebp_base_ptr + 2; // 拿到进入函数之前的栈地址

for (int i = 0; i < 5; ++i) { // 输出参数
cprintf("%x ", *(args+i));
}
cprintf("\n");

我们把读取到的ebp的值转化为了int*类型,这样转化使得对指针的加减法步长和栈中元素长度一致。在x86机器中,地址和int类型同质,长度都是4字节。这样转换之后,无论是加法,还是中括号[]索引,改变的地址都是4字节,而不是1字节,可以恰好改变一个元素的长度。

来看打印得到结果:

1
ebp f010ef58, eip f01000a1, args 0 0 0 f010004a f0110308 

给函数传的3个参数的值均为0,和打印结果一致!

eip的值正是调用完函数mon_backtrace后一个指令的地址,可以查看反汇编代码obj/kern/kernel.asm,调用函数指令如下:

1
2
3
4
5
6
7
8
		mon_backtrace(0, 0, 0);
f0100093: 83 ec 04 sub $0x4,%esp
f0100096: 6a 00 push $0x0
f0100098: 6a 00 push $0x0
f010009a: 6a 00 push $0x0
f010009c: e8 e1 07 00 00 call f0100882 <mon_backtrace>
f01000a1: 83 c4 10 add $0x10,%esp
f01000a4: eb d3 jmp f0100079 <test_backtrace+0x39>

倒数第二行指令地址正是0xf01000a1!

读取Symbol Table

这个练习要求我们研究函数debuginfo_eip的实现,弄清楚命名为__STAB_*的几个宏的来历和作用,以及将backtrace功能作为命令加入console功能中。

命名为__STAB_*的宏最早在文件kern/kernel.ld中26行出现,__STABSTR_*则在下面一点的34行出现。这个连接器的配置文件,要求连接器生成elf文件时,分配两个segment给到.stab和.stabstr,正如连接器也分配了.data等segment一样。

运行objdump -h obj/kern/kernel查看分配的segment的信息,有关部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
obj/kern/kernel:     file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00001bad f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 000006f4 f0101bc0 00101bc0 00002bc0 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 000043b1 f01022b4 001022b4 000032b4 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 00001987 f0106665 00106665 00007665 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 00009300 f0108000 00108000 00009000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .got 00000008 f0111300 00111300 00012300 2**2
CONTENTS, ALLOC, LOAD, DATA
6 .got.plt 0000000c f0111308 00111308 00012308 2**2
CONTENTS, ALLOC, LOAD, DATA
7 .data.rel.local 00001000 f0112000 00112000 00013000 2**12
CONTENTS, ALLOC, LOAD, DATA
8 .data.rel.ro.local 00000044 f0113000 00113000 00014000 2**2
CONTENTS, ALLOC, LOAD, DATA
9 .bss 00000648 f0113060 00113060 00014060 2**5
CONTENTS, ALLOC, LOAD, DATA
10 .comment 0000002a 00000000 00000000 000146a8 2**0
CONTENTS, READONLY

运行objdump -G obj/kern/kernel,查看符号列表Symbol Table,得到有关函数和文件的信息,以下粘贴了部分kern/monitor.c文件有关的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
obj/kern/kernel:     file format elf32-i386

Contents of .stab section:

Symnum n_type n_othr n_desc n_value n_strx String
...
375 FUN 0 0 f0100882 1790 mon_backtrace:F(0,1)
376 PSYM 0 0 00000008 1603 argc:p(0,1)
377 PSYM 0 0 0000000c 1768 argv:p(0,2)
378 PSYM 0 0 00000010 1780 tf:p(0,5)
379 SLINE 0 59 00000000 0
380 SOL 0 0 f0100896 601 ./inc/x86.h
381 SLINE 0 214 00000014 0
382 SOL 0 0 f0100898 1541 kern/monitor.c
...

知道了__STAB_*的来历,看看它们的作用。文件kern/kdebug.c中函数debuginfo_eip142行调用了这几个宏,整个函数和同一个文件里面的另一个函数stab_binsearch的目的是从.stab.stabstr两个segment中读取出想要的debug信息,装进一个Eipdebuginfo结构体中。

按照提示,我们首先可以调用read_ebp函数来获取当前ebp寄存器的值。ebp寄存器的值实际上是一个指针,指向当前函数的栈帧的底部(而esp寄存器指向当前函数的栈顶)。我们可以把整个调用栈看做一个数组,其中每个元素均为4字节的整数,并以ebp指针的值为数组起始地址,那么ebp[1]存储的就是函数返回地址,也就是题目中要求的eip的值,ebp[2]以后存储的是输入参数的值。由于题目要求打印5个输入参数,因此需要获取ebp[2]ebp[6]的值。这样第一条栈信息便可打印出来。

那么怎么打印下一条栈信息呢?还得从ebp入手。当前ebp指针存储的恰好是调用者的ebp寄存器的值,因此当前ebp指针又可以看做是一个链表头,我们通过链表头就可以遍历整个链表。举个例子:假设有A、B、C三个函数,A调用B,B调用C,每个函数都对应有一个栈帧,栈帧的底部地址均存储在当时的ebp寄存器中,不妨记为a_ebp, b_ebp和c_ebp,那么将有c_ebp -> b_ebp -> a_ebp,用程序语言表示就是:a_ebp = (uint32_t *)*b_ebpb_ebp = (uint32_t *)*c_ebp

还有一个问题:怎么知道遍历何时结束呢?题目中提示可以参考kern/entry.S,于是我打开此文件,果然找打答案:内核初始化时会将ebp设置为0,因此当我们检查到ebp为0后就应该结束了。

1
2
3
4
# Clear the frame pointer register (EBP)
# so that once we get into debugging C code,
# stack backtraces will be terminated properly.
movl $0x0,%ebp # nuke frame pointer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
uint32_t *ebp;

ebp = (uint32_t *)read_ebp();

cprintf("Stack backtrace:\r\n");

while (ebp)
{
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\r\n",
ebp, ebp[1], ebp[2], ebp[3], ebp[4], ebp[5], ebp[6]);

ebp = (uint32_t *)*ebp;
}

return 0;
}

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
6828 decimal is 15254 octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
Stack backtrace:
ebp f010ff18 eip f0100078 args 00000000 00000000 00000000 f010004a f0111308
ebp f010ff38 eip f01000a1 args 00000000 00000001 f010ff78 f010004a f0111308
ebp f010ff58 eip f01000a1 args 00000001 00000002 f010ff98 f010004a f0111308
ebp f010ff78 eip f01000a1 args 00000002 00000003 f010ffb8 f010004a f0111308
ebp f010ff98 eip f01000a1 args 00000003 00000004 00000000 f010004a f0111308
ebp f010ffb8 eip f01000a1 args 00000004 00000005 00000000 f010004a f0111308
ebp f010ffd8 eip f01000dd args 00000005 00001aac f010fff8 f01000bd 00000000
ebp f010fff8 eip f010003e args 00000003 00001003 00002003 00003003 00004003
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5

debuginfo_eip函数实现根据地址寻找行号的功能

解决这个问题的关键是熟悉stabs每行记录的含义,我折腾了一两小时才搞清楚。首先,使用objdump -G obj/kern/kernel > output.md将内核的符号表信息输出到output.md文件,在output.md文件中可以看到以下片段:

1
2
3
4
5
6
7
Symnum n_type n_othr n_desc n_value  n_strx String
118 FUN 0 0 f01000a6 2987 i386_init:F(0,25)
119 SLINE 0 24 00000000 0
120 SLINE 0 34 00000012 0
121 SLINE 0 36 00000017 0
122 SLINE 0 39 0000002b 0
123 SLINE 0 43 0000003a 0

这个片段是什么意思呢?首先要理解第一行给出的每列字段的含义:

  • Symnum是符号索引,换句话说,整个符号表看作一个数组,Symnum是当前符号在数组中的下标
  • n_type是符号类型,FUN指函数名,SLINE指在text段中的行号
  • n_othr目前没被使用,其值固定为0
  • n_desc表示在文件中的行号
  • n_value表示地址。特别要注意的是,这里只有FUN类型的符号的地址是绝对地址,SLINE符号的地址是偏移量,其实际地址为函数入口地址加上偏移量。比如第3行的含义是地址f01000b8(=0xf01000a6+0x00000012)对应文件第34行。

理解stabs每行记录的含义后,调用stab_binsearch便能找到某个地址对应的行号了。由于前面的代码已经找到地址在哪个函数里面以及函数入口地址,将原地址减去函数入口地址即可得到偏移量,再根据偏移量在符号表中的指定区间查找对应的记录即可。代码如下所示:

1
2
3
4
5
6
stab_binsearch(stabs, &lfun, &rfun, N_SLINE, addr - info->eip_fn_addr);

if (lfun <= rfun)
{
info->eip_line = stabs[lfun].n_desc;
}

给内核模拟器增加backtrace命令,并在mon_backtrace中增加打印文件名、函数名和行号

给内核模拟器增加backtrace命令。很简单,在kern/monitor.c文件中模仿已有命令添加即可。

1
2
3
4
5
static struct Command commands[] = {
{ "help", "Display this list of commands", mon_help },
{ "kerninfo", "Display information about the kernel", mon_kerninfo },
{ "backtrace", "Display a backtrace of the function stack", mon_backtrace },
};

在mon_backtrace中增加打印文件名、函数名和行号

经过上面的探索,这个问题就很容易解决了。在mon_backtrace中调用debuginfo_eip来获取文件名、函数名和行号即可。注意,返回的Eipdebuginfo结构体的eip_fn_name字段除了函数名外还有一段尾巴,比如test_backtrace:F(0,25),需要将”:F(0,25)”去掉,可以使用printf("%.*s", length, string)来实现。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
uint32_t *ebp;
struct Eipdebuginfo info;
int result;
ebp = (uint32_t *)read_ebp();

cprintf("Stack backtrace:\r\n");
while (ebp)
{
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\r\n", ebp, ebp[1], ebp[2], ebp[3], ebp[4], ebp[5], ebp[6]);

memset(&info, 0, sizeof(struct Eipdebuginfo));

result = debuginfo_eip(ebp[1], &info);
if (0 != result)
cprintf("failed to get debuginfo for eip %x.\r\n", ebp[1]);
else
cprintf("\t%s:%d: %.*s+%u\r\n", info.eip_file, info.eip_line, info.eip_fn_namelen, info.eip_fn_name, ebp[1] - info.eip_fn_addr);
ebp = (uint32_t *)*ebp;
}
return 0;
}

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Stack backtrace:
ebp f010ff18 eip f0100078 args 00000000 00000000 00000000 f010004a f0111308
kern/init.c:16: test_backtrace+56
ebp f010ff38 eip f01000a1 args 00000000 00000001 f010ff78 f010004a f0111308
kern/init.c:16: test_backtrace+97
ebp f010ff58 eip f01000a1 args 00000001 00000002 f010ff98 f010004a f0111308
kern/init.c:16: test_backtrace+97
ebp f010ff78 eip f01000a1 args 00000002 00000003 f010ffb8 f010004a f0111308
kern/init.c:16: test_backtrace+97
ebp f010ff98 eip f01000a1 args 00000003 00000004 00000000 f010004a f0111308
kern/init.c:16: test_backtrace+97
ebp f010ffb8 eip f01000a1 args 00000004 00000005 00000000 f010004a f0111308
kern/init.c:16: test_backtrace+97
ebp f010ffd8 eip f01000dd args 00000005 00001aac f010fff8 f01000bd 00000000
kern/init.c:43: i386_init+55
ebp f010fff8 eip f010003e args 00000003 00001003 00002003 00003003 00004003
{standard input}:0: <unknown>+0
```:q

lab2

简介

在本实验中,我们将编写操作系统的内存管理代码。 内存管理有两个组成部分。

第一个部分是内核的物理内存分配器,以致于内核可以分配和释放内存。 分配器将以4096字节为操作单位,称为一个页面。 我们的任务是维护一个数据结构,去记录哪些物理页面是空闲的,哪些是已分配的,以及共享每个已分配页面的进程数。 我们还要编写例程来分配和释放内存页面。

内存管理的第二个组件是虚拟内存,它将内核和用户软件使用的虚拟地址映射到物理内存中的地址。 当指令使用内存时,x86硬件的内存管理单元(MMU)执行映射,查询一组页表。 我们根据任务提供的规范修改JOS以设置MMU的页面表。

lab2包含的新源文件:

  • inc/memlayout.h
  • kern/pmap.c
  • kern/pmap.h
  • kern/kclock.h
  • kern/kclock.c

memlayout.h描述了虚拟地址空间的布局,这是我们需要通过修改pmap.c实现的。memlayout.hpmap.h定义了PageInfo结构,可以通过这个结构来跟踪那个物理地址是空闲的。kclock.ckclock.h操作系统的时钟。

memlayout.h给贴心的画了个图,很形象的表述了虚拟地址的分布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

Virtual memory map: Permissions
kernel/user

4 Gig --------> +------------------------------+
| | RW/--
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
: . :
: . :
: . :
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
| | RW/--
| Remapped Physical Memory | RW/--
| | RW/--
KERNBASE, ----> +------------------------------+ 0xf0000000 --+
KSTACKTOP | CPU0's Kernel Stack | RW/-- KSTKSIZE |
| - - - - - - - - - - - - - - -| |
| Invalid Memory (*) | --/-- KSTKGAP |
+------------------------------+ |
| CPU1's Kernel Stack | RW/-- KSTKSIZE |
| - - - - - - - - - - - - - - -| PTSIZE
| Invalid Memory (*) | --/-- KSTKGAP |
+------------------------------+ |
: . : |
: . : |
MMIOLIM ------> +------------------------------+ 0xefc00000 --+
| Memory-mapped I/O | RW/-- PTSIZE
ULIM, MMIOBASE --> +------------------------------+ 0xef800000
| Cur. Page Table (User R-) | R-/R- PTSIZE
UVPT ----> +------------------------------+ 0xef400000
| RO PAGES | R-/R- PTSIZE
UPAGES ----> +------------------------------+ 0xef000000
| RO ENVS | R-/R- PTSIZE
UTOP,UENVS ------> +------------------------------+ 0xeec00000
UXSTACKTOP -/ | User Exception Stack | RW/RW PGSIZE
+------------------------------+ 0xeebff000
| Empty Memory (*) | --/-- PGSIZE
USTACKTOP ---> +------------------------------+ 0xeebfe000
| Normal User Stack | RW/RW PGSIZE
+------------------------------+ 0xeebfd000
| |
| |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
. .
. .
. .
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
| Program Data & Heap |
UTEXT --------> +------------------------------+ 0x00800000
PFTEMP -------> | Empty Memory (*) | PTSIZE
| |
UTEMP --------> +------------------------------+ 0x00400000 --+
| Empty Memory (*) | |
| - - - - - - - - - - - - - - -| |
| User STAB Data (optional) | PTSIZE
USTABDATA ----> +------------------------------+ 0x00200000 |
| Empty Memory (*) | |
0 ------------> +------------------------------+ --+

(*) Note: The kernel ensures that "Invalid Memory" is *never* mapped.
"Empty Memory" is normally unmapped, but user programs may map pages
there if desired. JOS user programs map pages temporarily at UTEMP.

回顾:未初始化完成的内存映射

在Lab 1中,我们做了一个虚拟内存映射,将0xf0000000-0xf0400000映射到物理地址0x00000000-00400000,总共大小为4MB。如果访问任何超出这个范围的虚拟地址,CPU都会出错。

在之后写代码时,代码中的地址都是虚拟地址,翻译成物理地址的过程是硬件实现的,我们不应该想着如何直接操作物理地址。但是,有时将地址转化物理地址可以方便一些操作,在文件inc/memlayout.hkern/pmap.h中提供了一些宏和函数,方便我们做这样的地址换算。

首先提供了宏KERNBASE,注释说所有物理地址都被映射到这里,值为0xf0000000,正是我们映射的地址。所谓所有,就是已经映射过的地址,不包括还没映射的地址。

1
2
// All physical memory mapped at this address
#define KERNBASE 0xF0000000

宏函数KADDR调用了函数_kaddr,将物理地址转化成内核地址,或称虚拟地址,也就是在物理地址的数值上加上了KERNBAE。此时的“所有”物理地址,范围还很小,因为其它的内存映射还没有建立,故可以这样简单地操作。其它内存映射建立之后,物理地址转化为虚拟地址的过程将很复杂。

1
2
3
4
5
6
7
8
9
10
11
/* This macro takes a physical address and returns the corresponding kernel
* virtual address. It panics if you pass an invalid physical address. */
#define KADDR(pa) _kaddr(__FILE__, __LINE__, pa)

static inline void*
_kaddr(const char *file, int line, physaddr_t pa)
{
if (PGNUM(pa) >= npages)
_panic(file, line, "KADDR called with invalid pa %08lx", pa);
return (void *)(pa + KERNBASE);
}

相应的反向过程将虚拟地址转化为物理地址,宏函数PADDR做了这样的事情。也就是在输入的虚拟地址上减去KERNBASE,非常简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* This macro takes a kernel virtual address -- an address that points above
* KERNBASE, where the machine's maximum 256MB of physical memory is mapped --
* and returns the corresponding physical address. It panics if you pass it a
* non-kernel virtual address.
*/
#define PADDR(kva) _paddr(__FILE__, __LINE__, kva)

static inline physaddr_t
_paddr(const char *file, int line, void *kva)
{
if ((uint32_t)kva < KERNBASE)
_panic(file, line, "PADDR called with invalid kva %08lx", kva);
return (physaddr_t)kva - KERNBASE;
}

以下为把页转换为物理地址和把物理地址转成页,或者把页转成虚拟地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static inline physaddr_t
page2pa(struct PageInfo *pp)
{
return (pp - pages) << PGSHIFT;
}

static inline struct PageInfo*
pa2page(physaddr_t pa)
{
if (PGNUM(pa) >= npages)
panic("pa2page called with invalid pa");
return &pages[PGNUM(pa)];
}

static inline void*
page2kva(struct PageInfo *pp)
{
return KADDR(page2pa(pp));
}

Part 1任务总览

Lab 2 Part 1让我们完成内核内存初始化,而用户区User Level内存初始化在后面的part中完成。

初始化操作集中在文件kern/pmap.c的函数mem_init中,在内核初始化函数i386_init中调用。在这个part中,我们开始写这个函数以及它将调用的函数,只需要写到check_page_alloc函数的调用之前即可。check_page_alloc这一行之上进行的操作汇总如下。

  • 直接调用硬件查看可以使用的内存大小,也就是函数i386_detect_memory。
  • 创建一个内核初始化时的page目录,并设置权限。
  • 创建用于管理page的数组,初始化page分配器组件。
  • 测试page分配器组件。

需要我们写的函数有:

  • boot_alloc,page未初始化时的分配器。
  • page_init, page_alloc, page_free,page分配器组件。
  • mem_init,总的内存初始化函数。

完成分配器之后,我们的目标是让虚拟地址有基础。进程需要更多内存,向内核发出请求,内核利用分配器,将一个由分配器决定的物理地址和由进程决定的虚拟地址关联到一起,称为映射。这是后面的Lab的内容,本文只关心分配,不关心任何形式的映射。

va_list va_start等等

VA函数(variable argument function),参数可变函数。理解这个操作,头脑中需要有栈的概念,参数按序(从右到左)压栈,第一个参数在低地址位置。函数原型为

1
2
3
4
5
6
7
8
9
10
11
typedef char* va_list;
// 以4字节为单位对齐
#define _INTSIZEOF(n) (sizeof(n)+sizeof(int)-1)& ~(sizeof(int)-1)
// 求得参数栈的第一个参数地址

#define va_start(ap, v) (ap = (va_list)&v + _INTSIZEOF(v))
// 这里很巧妙,ap+SIZE指向下一个参数地址,再返回总体减去size(即又指回了当前变量)

#define va_arg(ap, t) (*(t *) ((ap+=_INTSIZEOF(t)) - _INTSIZEOF(t)) )

#define va_end(ap) (ap = (va_list) 0)

  • va_list ap 定义一个变差变量ap
  • va_start(ap, last) 初始化ap,得到可变参数列表的第一个参数的确切地址。实际就是指向参数堆栈的栈顶
  • va_arg(ap, type) 已知变量类型为type的情况下,获得下一个变参变量
  • va_end(ap) 结束操作

entry_pgdir的写法也是内存映射的一个重要部分。

1
2
3
4
5
6
7
8
9
10
__attribute__((__aligned__(PGSIZE)))
pde_t entry_pgdir[NPDENTRIES] = {
// Map VA's [0, 4MB) to PA's [0, 4MB)
// 在数组定义中,这是什么写法?
[0]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
};

两个内存分配器

有两个分配器,一个是正式的Page分配器,在之后的所有情况下我们都使用这个。另一个是在Page分配器初始化完成之前使用的,更加原始、简单。

在page分配器初始化完成之前,内核在初始化的过程中使用boot_alloc函数分配内存,也可称为boot分配器。这个分配器非常原始,在page分配器初始化完成后,务必不可调用boot_alloc分配内存,以免出现莫名其妙的错误。

page分配器

Page分配器操作内存是以page为单位的,之后几乎所有管理内存的机制都是以page为单位。page就是将所有的内存地址分成长度相同的一个个区块,每个的长度都是4096Bytes。所有可以分配的内存都注册到一个链表中,通过分配器,可以方便地拿到一个未分配的page。

内存管理组件维护一个链表,称为free list,这个链表将所有未分配的page连起来。需要分配内存时,将链表头部对应的page返回,并将链表头部更新为链表中的下一个元素。

inc/memlayout.h中定义了这样的结构体,pp_ref是指向这个页面的指针数量,指针pp_link就是链表中常用的next指针。

1
2
3
4
5
6
7
8
9
10
11
struct PageInfo {
// Next page on the free list.
struct PageInfo *pp_link;

// pp_ref is the count of pointers (usually in page table entries)
// to this page, for pages allocated using page_alloc.
// Pages allocated at boot time using pmap.c's
// boot_alloc do not have valid reference count fields.
uint16_t pp_ref;
};

创建了一个struct PageInfo的数组,数组中第i个成员代表内存中第i个page。故物理地址和数组索引很方便相换算。初始化时,形成一个链表,所有可分配的page都以struct PageInfo的形式存在于链表上。要通过分配器拿到一个page,也就是读取链表开头的节点,这个节点就对应一个page

1
2
extern struct PageInfo *pages;
extern size_t npages;

初始化函数page_init将所有的pp_link初始化指向与自己相邻的PageInfo,如下,这是初步实现,后续还有更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//
// Initialize page structure and memory free list.
// After this is done, NEVER use boot_alloc again. ONLY use the page
// allocator functions below to allocate and deallocate physical
// memory via the page_free_list.
//
void
page_init(void)
{
// The example code here marks all physical pages as free.
// However this is not truly the case. What memory is free?
// 1) Mark physical page 0 as in use.
// This way we preserve the real-mode IDT and BIOS structures
// in case we ever need them. (Currently we don't, but...)
// 2) The rest of base memory, [PGSIZE, npages_basemem * PGSIZE)
// is free.
// 3) Then comes the IO hole [IOPHYSMEM, EXTPHYSMEM), which must
// never be allocated.
// 4) Then extended memory [EXTPHYSMEM, ...).
// Some of it is in use, some is free. Where is the kernel
// in physical memory? Which pages are already in use for
// page tables and other data structures?
//
// Change the code to reflect this.
// NB: DO NOT actually touch the physical memory corresponding to
// free pages!
size_t i;
for (i = 0; i < npages; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}

这样初始化的操作是在kern/pmap.c中完成的。大概来说,初始化就是拉了这样一个链表,并且将指针page_free_list指向链表的开头。分配内存时,若读取page_free_list指针得到NULL,则说明分配器已经给完了它能够管理的内存,再也给不出来了。

分配器组件的函数都是在操作PageInfo指针,也就是pages数组中的元素,而不是直接操作每个page的地址。如分配函数page_alloc返回的是一个PageInfo,释放page的函数page_free接受的也是一个PageInfo指针。将这个指针和pages数组开头地址做差,可以得到这个PageInfo在数组中的索引,也就可以换算出相应物理地址。

在文件kern/pmap.h中,已经写好了一个函数page2kva,接受一个PageInfo指针,返回得到相应page的虚拟地址。我们可以直接使用这个函数进行换算,这样得到的是虚拟地址,要得到物理地址,还需要在此基础上将地址的数值减去0xf0000000,宏PADDR做了这件事情。

内核的其他代码通过函数page_allocfree list取出一个page,返回当前page_free_list指针,并令page_free_list指针指向原链表中的下一个元素。

讲义中要求我们实现文件kern/pmap.c中的函数page_alloc,注释中写的比较清楚,分配一个物理页首先需要判断是否还有free的page,如果没有的话就返回NULL。之后从page_free_list中拿出一个page,因为page的指针还指向下一个free_page,所以free_page_list需要指向target->pp_link,同时target->pp_link置空。如果需要把页置为0的话,需要转成物理地址然后调用memset。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Allocates a physical page.  If (alloc_flags & ALLOC_ZERO), fills the entire
// returned physical page with '\0' bytes. Does NOT increment the reference
// count of the page - the caller must do these if necessary (either explicitly
// or via page_insert).
//
// Be sure to set the pp_link field of the allocated page to NULL so
// page_free can check for double-free bugs.
//
// Returns NULL if out of free memory.
//
// Hint: use page2kva and memset

struct PageInfo *
page_alloc(int alloc_flags)
{

// out of memory
if (page_free_list == NULL) {
// no changes made so far of course
return NULL;
}
struct PageInfo *target = page_free_list;
page_free_list = page_free_list->pp_link; // update free list pointer
target->pp_link = NULL; // set to NULL according to notes
char *space_head = page2kva(target); // extract kernel virtual memory
if (alloc_flags & ALLOC_ZERO) {
// zero the page according to flags
memset(space_head, 0, PGSIZE);
}
return target;
}

要释放一个page,也就是将这个page放回链表。将page_free_list指针指向这个PageInfo结构体,并设置这个结构体的pp_link为之前的page_free_list指针。放回链表的这个page也就变成了free list的开头。

讲义中要求我们实现文件kern/pmap.c中的函数page_free,给的提示足够多了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Return a page to the free list.
// (This function should only be called when pp->pp_ref reaches 0.)
void
page_free(struct PageInfo *pp)
{
// Fill this function in
// Hint: You may want to panic if pp->pp_ref is nonzero or
// pp->pp_link is not NULL.
if (pp->pp_ref != 0 || pp->pp_link != NULL)
panic("Page double free or freeing a referenced page...\n");
pp->pp_link = page_free_list;
page_free_list = pp;
}

page分配器boot_alloc

page分配组件完成初始化之前,使用boot_alloc函数分配内存,pages数组就是这个函数分配的。

函数接受一个参数,代表要多少字节内存。函数将这个字节数上调到page大小的边界,也就是调整为离这个字节数最近的4096的整数倍,以求每次分配都是以page为单位的。这个分配器只能在page分配器初始化完成之前使用,之后一律使用page分配器。

实现非常简单,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void *
boot_alloc(uint32_t n)
{
static char *nextfree; // virtual address of next byte of free memory
char *result;

if (!nextfree) {
extern char end[];
nextfree = ROUNDUP((char *) end, PGSIZE);
}

// special case according to notes
if (n == 0) {
return nextfree;
}

// note before update
result = nextfree;
nextfree = ROUNDUP(n, PGSIZE) + nextfree;

// out of memory panic
if (nextfree > (char *)0xf0400000) {
panic("boot_alloc: out of memory, nothing changed, returning NULL...\n");
nextfree = result; // reset static data
return NULL;
}

return result;
}

第一次调用这个函数时,必须初始化nextfree指针。这个初始化也很简单,确定了内核本身在内存中的位置后,让boot_alloc函数在内核所占空间的内存之后的第一个page开始分配。表现为代码,就是从连接器中拿到内核的最后一个字节的地址end,将这个指针的数值上调到4096的整数倍。

其中,需要注意的一个是end到底是什么,另一个是ROUNDUP这个宏。其中,end指向内核的bss段的末尾。利用objdump -h kernel可以看出,bss段已经是内核的最后一段。因此,end 指向的是第一个未使用的虚拟内存地址。而ROUNDUP定义在inc/types.h中。

这个end指针是连接器产生的,可以看连接配置文件kern/kernel.ld的53行左右,end指向内核的最后一个字节的下一个字节。

内核内存布局和分配器初始化

这里正式讲解page分配器的初始化,也就是page_init函数的实现,正确初始化之后的分配器才可以正确使用page_alloc, page_free等函数。要知道分配器如何初始化,就要理解内核内存的布局Layout。

获得物理内存信息

在初始化内存组件的函数mem_init中,首先调用了函数i386_detect_memory获得了内存硬件信息。追踪一下这个函数的调用,底层实现在kern/kclock.c中,通过一系列汇编指令向硬件要信息。汇编指令如何执行的,我们暂且不关心。

最终得到的内存信息是两个整数npages, npages_basemem,分别代表现有内存的page个数,以及在拓展内存之前的page个数。这些属于原始硬件信息,获得这个信息是为了确定一段IO映射区的位置。

接着研究现有内存布局。

内存布局

在文件kern/memlayout.h中,有一个虚拟内存的布局示意图,这个示意图主要描绘用户区内存分配,而不是指出物理内存分布,故我们暂时不细看它。地址0xf0000000以上的区域,也就是我们现在已经映射的区域,是我们关心的区域。宏KERNBASE就是0xf0000000,同时这个地址也是内核栈的开端。以下为了讲述方便,所有地址都是物理内存。

初始化的重要一步是弄清楚哪些物理地址可以分配,哪些不可以。这也就是弄清楚内存布局的意义所在。

我们从KERNBASE开始想起。回顾Lab 1我们知道,内存0xf0000-0x100000是BIOS映射区,在这之前又是ROM映射区,这段空间不能使用,不能被分配器分配出去。查看讲义,我们知道,地址0xa0000-0x100000是ROM, BIOS等IO使用的内存,不可以被分配,初始化时应排除这部分空间。在文件inc/memlayout.h中,宏IOPHYSMEM定义了这段IO段内存的开头。

IOPHYSMEM之前还有一些内存没有分配,这部分内存是可以使用的。函数i386_detect_memory得到的npages_basemem就是这一段的长度,初始化page分配器时应该包含这一段。可以验证一下,npages_basemem的值为160,这么多个page总的大小为160 * 4096 = 655360 = 0xa0000,确实是IOPHYSMEM

从0x100000开始以上的内存就是内核,可以回顾Lab 1中探索内核结构的结果,内核的.text区的虚拟地址为0xf0100000,物理地址正是0x100000。文件inc/memlayout.h中定义的宏EXTPHYSMEM就是0x100000,意思是BIOS以上的内存,称为拓展区,其上限由RAM硬件大小决定。

如果你不记得内核的装载方式,可以使用指令objdump -h obj/kern/kernel查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
% obj/kern/kernel:     file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00002a4d f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 00000bd0 f0102a60 00102a60 00003a60 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 000050d1 f0103630 00103630 00004630 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 00001bc3 f0108701 00108701 00009701 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 00009300 f010b000 0010b000 0000c000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .got 00000008 f0114300 00114300 00015300 2**2
CONTENTS, ALLOC, LOAD, DATA
6 .got.plt 0000000c f0114308 00114308 00015308 2**2
CONTENTS, ALLOC, LOAD, DATA
7 .data.rel.local 00001000 f0115000 00115000 00016000 2**12
CONTENTS, ALLOC, LOAD, DATA
8 .data.rel.ro.local 00000060 f0116000 00116000 00017000 2**5
CONTENTS, ALLOC, LOAD, DATA
9 .bss 00000681 f0116060 00116060 00017060 2**5
CONTENTS, ALLOC, LOAD, DATA
10 .comment 00000012 00000000 00000000 000176e1 2**0
CONTENTS, READONLY

内核占用了拓展区的开头,这些空间不应该被分配器管辖,不应该初始化到链表上。在初始化page分配器之前,调用了几次boot_alloc,这是内核运行时重要数据,他们占用的空间也不应该被分配器管辖。

分配器应该管辖最后一次调用boot_alloc分配的空间之后的空间,这个空间开头的地址可以直接通过boot_alloc(0)得到。

剩余的内存可以自由使用,分配器初始化是应该把链表拉到剩余的空间去。

分配器初始化

mem_init函数中需要添加以下两行,为所有页分配空间:

1
2
3
4
5
6
7
8
9
//////////////////////////////////////////////////////////////////////
// Allocate an array of npages 'struct PageInfo's and store it in 'pages'.
// The kernel uses this array to keep track of physical pages: for
// each physical page, there is a corresponding struct PageInfo in this
// array. 'npages' is the number of physical pages in memory. Use memset
// to initialize all fields of each struct PageInfo to 0.
// Your code goes here:
pages = (struct PageInfo *) boot_alloc(npages * sizeof(struct PageInfo));
memset(pages, 0, npages * sizeof(struct PageInfo));

初始化就是拉链表,并注意排除不应该纳入分配器管辖的空间。总结上面对内存布局的研究,纳入分配器管辖的总共有两部分,分别是basemem部分,也就是0x0-0xa0000,和boot_alloc最后分配的空间的后面的部分,排除了内核,和一些boot_alloc取得的空间。

boot_alloc即将分配的空间可以给函数传0直接得到,这是函数的特殊处理。由于boot_allocpage为单位分配,这样得到的地址是一个page的首地址,这个page的索引可以轻易获得:

1
i = PADDR(boot_alloc(0)) / PGSIZE;

最后分配得到的应该如下图所示,其中basemem部分省略了指针指向。

完整实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 1.mark page 0 as in use
// 这样我们就可以保留实模式IDT和BIOS结构,以备不时之需。
pages[0].pp_ref = 1;
// pages[0].pp_link = NULL;
// page_free_lis = &pages[0];
// 被注释掉的这两句不对,因为这个开头的页不能放到free_list中被分配。

// 2. The rest of base memory, [PGSIZE, npages_basemem * PGSIZE)is free.
size_t i;
for (i = 1; i < npages_basemem; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}

// 3. Then comes the IO hole[IOPHYSMEM, EXTPHYSMEM), which must
// never be allocated.
for (; i < EXTPHYSMEM/PGSIZE; i ++) {
pages[i].pp_ref = 1;
}

// 4. Then extended memory [EXTPHYSMEM, ...).
// 还要注意哪些内存已经被内核、页表使用了!
// first需要向上取整对齐。同时此时已经工作在虚拟地址模式(entry.S对内存进行了映射)下,
// 需要求得first的物理地址
physaddr_t first_free_addr = PADDR(boot_alloc(0));
size_t first_free_page = first_free_addr/PGSIZE;
for(; i < first_free_page; i ++) {
pages[i].pp_ref = 1;
}

// mark other pages as free
for(; i < npages; i ++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}

可以在inc/memlayout.h中找到 IO hole 的定义,可回顾lab 1:

1
2
3
4
5
// At IOPHYSMEM (640K) there is a 384K hole for I/O.  From the kernel,
// IOPHYSMEM can be addressed at KERNBASE + IOPHYSMEM. The hole ends
// at physical address EXTPHYSMEM.
#define IOPHYSMEM 0x0A0000
#define EXTPHYSMEM 0x100000

第四种情况略有难度,实际需要利用boot_alloc函数来找到第一个能分配的页面。相同的思想在已经写好的check_free_page_list函数中也可以找到。关键代码:

1
size_t first_free_address = PADDR(boot_alloc(0));

尤其需要注意的是,由于boot_alloc返回的是内核虚拟地址 (kernel virtual address),一定要利用 PADDR 转为物理地址。在 kern/pmap.h 中可以找到 PADDR 的定义,实际就是减了一个 F0000000。

完成以上步骤,编译运行,看到check_page_alloc() succeeded!则成功。

Lab 2 Part 2:内核内存映射

上一篇Part 1实现了分配器,用的是非常简单的链表管理方式。分配器实现的是剩余空间管理Free Space Management,有了剩余空间管理,接下来就是实际使用这些空间了。

这个part帮助我们正式建立虚拟内存Virtual Memory和物理内存Physical Memory之间的关系,明确了概念,完成了实现。在很多操作系统教材中,内存映射放在Free Space Management之前讲。在真正实现内存管理的时候,必须先有分配器、后有其它的,和讲解知识相反。

虚拟地址、线性地址和物理地址

虚拟地址有段选择器和段内偏移组成,线性地址则是在段地址翻译之后、页地址翻译之前的地址,物理地址则是在段地址翻译、页地址翻译之后的最终的地址,是你从硬件中取数据的地址。

1
2
3
4
5
6
7
8
9

Selector +--------------+ +-----------+
---------->| | | |
| Segmentation | | Paging |
Software | |-------->| |----------> RAM
Offset | Mechanism | | Mechanism |
---------->| | | |
+--------------+ +-----------+
Virtual Linear Physical

C 指针是虚拟地址的“偏移量”组件。在boot/boot.S中,我们安装了一个全局描述符表 (GDT),它通过将所有段基地址设置为 0 并将限制设置为 0xffffffff 来有效地禁用段转换。因此“选择器”不起作用,线性地址总是等于虚拟地址的偏移量。在实验 3 中,我们将不得不与分段进行更多交互以设置权限级别,但是对于记忆翻译,我们可以在整个 JOS 实验中忽略分段,而只关注页面翻译。

回想一下,在实验 1 的第 3 部分中,我们安装了一个简单的页表,以便内核可以在其链接地址 0xf0100000 处运行,即使它实际上加载到 ROM BIOS 上方的物理内存中 0x00100000。这个页表只映射了 4MB 的内存。在本实验中您要为 JOS 设置的虚拟地址空间布局中,我们将扩展它以映射从虚拟地址 0xf0000000 开始的前 256MB 物理内存,并映射虚拟地址空间的许多其他区域。

x86内存管理机制

虚拟、线性和物理地址

  • 虚拟地址
    • 最原始的地址,也是 C/C++ 指针使用的地址。由前 16bit 段 (segment) 选择器和后 32bit 段内的偏移 (offset) 组成,显然一个段大小为 4GB。通过虚拟地址可以获得线性地址。
  • 线性地址
    • 前 10bit 为页目录项(page directory entry, PDE),即该地址在页目录中的索引。中间 10bit 为页表项(page table entry, PTE),代表在页表中的索引,最后 12bit 为偏移,也就是每页 4kB。通过线性地址可以获得物理地址。
  • 物理地址
    • 经过段转换以及页面转换,最终在 RAM 的硬件总线上的地址。

两步映射总览

x86建立了两次映射,程序给出地址,经过这两次翻译之后,才输出从到总线交给内存芯片。这两次映射分别为Segment Translation和Page Translation。

Segment Translation将虚拟地址转化为线性地址Linear Address,Page Translation将线性地址转化为物理地址,也就是真正用来索引内存的地址。

在我们的项目中,还没有对Segment Translation做特殊处理。Lab讲义中说明了,Segment Translation没有映射虚拟地址,线性地址和虚拟地址相同。后文中统一使用“虚拟地址”同时代指虚拟地址和线性地址,因为它们就是一样的。

我们暂时没有使用复杂的Segment Translation,所以Page Translation就是我们的重点,以下简单介绍Segment Translation,着重理解Page Translation。

Segment Translation

Segment Translation的过程可以如下图表示:

由一个事先指定的selector选择器,从一个描述符表descriptor table中读出一个描述符descriptor。由这个描述符读出一个基地址base address,虚拟地址作为一种偏置offset,加到基地址上,就得到了linear address。

描述符表Descriptor Table

描述符表必须事先指定,虚拟地址中不包含关于描述符表的信息。

有两种描述符表,分别为全局描述符表Global Descriptor Table (GDT)和本地描述符表Local Descriptor Table (LDT),分别使用寄存器GDTRLDTR获得。x86有访问这些寄存器的指令,我们没有直接使用,也就不关心了。

描述符Descriptor

通过selector索引描述符表得到的描述符,除了基地址之外,也包含了其他信息,具体结构如下图:

这是两种不同的结构,其中的区别只有DPL和TYPE之间的那个bit,以及TYPE的位置,我们暂时不关心它们的区别。这里需要注意的是P域,也就是Segment Present bit,表示这个segment是否在内存中,之后的Page Translation也有类似机制。

选择符Selector

选择符不但有描述符表的索引,还有选择描述符表GDT/LDT的bit,以及发出的请求所在的优先级,用于区分User Level Access和Kernel Level Access。我们也暂时不关心它们的区别。结构如下:

和segment有关的寄存器

虚拟地址只是一个segment的偏置,本身不包含和segment有关的信息。当前使用的描述符表、描述符选择符,都要另外存储在一些寄存器里面。当使用和跳转有关的指令call, jmp时,这些寄存器被隐式地访问了,从而帮助计算新的地址。

segment寄存器有两个部分,可以直接操作和读取的是16bit的selector域,修改selector域之后,硬件自动将对应的描述符从描述符表中读取进不显示的descriptor域,这样就方便了后续操作。

Page Translation

虚拟地址,也就是线性地址,被拆成了三部分,都是一种索引index,分别索引的是Page Directory, Page Table, Page Frame。从page directory中读出page table的地址,在从读到的page table地址中读到page frame的地址,索引page frame之后,就得到相应物理地址上的内容。

对于开发者来说,page directory, page table都是两个数组,拿到page directory的头部指针,和虚拟地址一起,就可以确定物理地址。

每个域对应长度

线性地址,也就是虚拟地址,的格式如下:

每个域包含bit的个数,也就是长度,决定了每个域对应的数组的长度。我们可以很方便地得到每个域对应的长度:

1
2
3
page_len = 2 ** 12 = 4096            // OFFSET
page_table_len = 2 ** 10 = 1024 // PAGE
page_dir_len = 2 ** 10 = 1024 // DIR

如果你不太理解这种计算方法,可以回到最开始的排列组合。每个bit代表两种状态,有n个bit也就有2^n种状态,也就是这个域可以产生多少索引。

以上计算出了每个域的长度,单位不是字节,而是索引个数。

这些长度应该这样看。一个page directory指向1024个page directory entry,一个page directory entry指向了1024个page table,一个page table entry指向了1024个page frame,一个page frame中包含4096Bytes。

Entry格式

page directory entry, page table entry具有相同格式,如下:

DIR, PAGE域长度相同,而entry的格式也相同,说明page directorypage table其实是相同结构的嵌套。可以把page directory理解为高一级的page table,整个内存管理形成两个层级。一个page table自身就是一个page,是page directory管理的,而page table又管理了page frame

同理,我们可以把虚拟地址拆得更细,从而创造更多的层级,不过这是CPU设计的事情了。

对于page directory来说,entry中12-31位上的PAGE FRAME ADDRESS就是一个page table的基地址。对于page table来说,这个地址是一个page frame的基地址。通过一个虚拟地址,获得3个索引,一次访问这3个结构,就可以得到物理地址了。

这里还要注意一下,bit 0是Present Bit,表示当前entry中的信息是否可以用于映射。要是Present Bit设置为0,则这个entry不包含有效信息。索引各种page directory/table时,必须先检查这个bit。

entry中的其他部分暂时不使用。

可以使用的工具代码

在开始写代码之前,需要看看项目中已经提供好了哪些可以使用的工具。

首先是上个part中写好的分配器,boot_alloc已经不使用了,主要是page_alloc/page_free在使用。然后就是三个头文件mmu.h, memlayout.h, pmap.h中的各种小函数了

在 JOS 中,由于只有一个段,所以虚拟地址数值上等于线性地址。

JOS 内核常常需要读取或更改仅知道物理地址的内存。例如,添加一个到页表的映射要求分配物理内存来存储页目录并初始化内存。然而,内核和其他任何程序一样,无法绕过虚拟内存转换这个步骤,因此不能直接使用物理地址。JOS 将从 0x00000000 开始的物理内存映射到 0xf0000000 的其中一个原因就是需要使内核能读写仅知道物理地址的内存。为了把物理地址转为虚拟地址,内核需要给物理地址加上 0xf0000000。这就是 KADDR 函数做的事。

同样,JOS 内核有时也需要从虚拟地址获得物理地址。内核的全局变量和由 boot_alloc 分配的内存都在内核被加载的区域,即从0xf0000000开始的地方。因此,若需要将虚拟地址转为物理地址,直接减去0xf0000000即可。这就是 PADDR 函数做的事。

mmu.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 线性地址 'la' 可以被分成三块:
//
// +--------10------+-------10-------+---------12----------+
// | Page Directory | Page Table | Offset within Page |
// | Index | Index | |
// +----------------+----------------+---------------------+
// \--- PDX(la) --/ \--- PTX(la) --/ \---- PGOFF(la) ----/
// \---------- PGNUM(la) ----------/
//
// PDX, PTX, PGOFF, 和 PGNUM 宏将线性地址进行切分
// 如果需要通过PDX(la), PTX(la), and PGOFF(la)组织一个线性地址la的话
// 使用 PGADDR(PDX(la), PTX(la), PGOFF(la)).

// page number field of address
#define PGNUM(la) (((uintptr_t) (la)) >> PTXSHIFT)

// page directory index
#define PDX(la) ((((uintptr_t) (la)) >> PDXSHIFT) & 0x3FF)

// page table index
#define PTX(la) ((((uintptr_t) (la)) >> PTXSHIFT) & 0x3FF)

// offset in page
#define PGOFF(la) (((uintptr_t) (la)) & 0xFFF)

// construct linear address from indexes and offset
#define PGADDR(d, t, o) ((void*) ((d) << PDXSHIFT | (t) << PTXSHIFT | (o)))

// Page directory and page table constants.
#define NPDENTRIES 1024 // page directory entries per page directory
#define NPTENTRIES 1024 // page table entries per page table

还有一些页表以及页目录会用到的标识位,exercise 4 中用得到的用中文注释:

1
2
3
4
5
6
7
8
9
10
// Page table/directory entry flags.
#define PTE_P 0x001 // 该项是否存在
#define PTE_W 0x002 // 可写入
#define PTE_U 0x004 // 用户有权限读取
#define PTE_PWT 0x008 // Write-Through
#define PTE_PCD 0x010 // Cache-Disable
#define PTE_A 0x020 // Accessed
#define PTE_D 0x040 // Dirty
#define PTE_PS 0x080 // Page Size
#define PTE_G 0x100 // Global

根据虚拟地址取出Page Table Entry

这里开始实现Lab讲义中指定要实现的函数,先是pgdir_walk函数,在文件kern/pmap.c中。这个函数接受一个page directory和一个虚拟地址,要求得到虚拟地址在这个page directory下对应的page table entry

先拆分虚拟地址,根据虚拟地址取出page directory/table/frame中的索引。用到的三个宏函数在文件mmu.h中,也就是通过移位>>和与&从一串bit中取出一些bit。需要完成如图的转换,返回对应的页表地址,即红圈圈出的部分的虚拟地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pte_t *
pgdir_walk(pde_t *pgdir, const void *va, int create)
{
// 参数1: 页目录项指针
// 参数2: 线性地址,JOS 中等于虚拟地址
// 参数3: 若页目录项不存在是否创建
// 返回: 页表项指针
uint32_t page_dir_idx = PDX(va);
uint32_t page_tab_idx = PTX(va);
pte_t *pgtab;
if (pgdir[page_dir_idx] & PTE_P) {
pgtab = KADDR(PTE_ADDR(pgdir[page_dir_idx]));
} else {
if (create) {
struct PageInfo *new_pageInfo = page_alloc(ALLOC_ZERO);
if (new_pageInfo) {
new_pageInfo->pp_ref += 1;
pgtab = (pte_t *) page2kva(new_pageInfo);
// 修改页目录的flag,根据 check_page 函数中用到的属性。
// 因为分配以页为单位对齐,必然后 12bit 为0
pgdir[page_dir_idx] = PADDR(pgtab) | PTE_P | PTE_W | PTE_U;
} else {
return NULL;
}
} else {
return NULL;
}
}
return &pgtab[page_tab_idx];
}

需要将PageInfo结构体的指针转换为物理地址,而不是虚拟地址。这个操作的依据是80386 Programmer’s Reference Manual的规定,在entry中放置的一定是物理地址。更新完page directory entry之后,原函数pgdir_walk根据虚拟地址中的索引,从新的page directory entry中获得新的page table地址,并返回。

通过宏函数KADDR转化为虚拟地址,而不是直接从page directory entry中读取出来的物理地址。

映射一段空间

第二个要实现的函数是boot_map_region,这个函数将虚拟地址中的几个page映射到连续的物理地址上。代码很简单,利用刚刚写好的函数pgdir_walk,给参数create传1,就可以方便地建立page table

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void
boot_map_region(pde_t *pgdir, uintptr_t va, size_t size, physaddr_t pa, int perm)
{
// Fill this function in
pte_t *pgtab;
size_t end_addr = va + size;
for (;va < end_addr; va += PGSIZE, pa += PGSIZE) {
pgtab = pgdir_walk(pgdir, (void *)va, 1);
if (!pgtab) {
return;
}
*pgtab = pa | perm | PTE_P;
}
}

boot_map_region中的 for 循环一开始就判断va > end_addr。这是显然的,因为end_addr = 0xf0000000 + 0x1000000 = 0x00000000。因此,实际上boot_map_region的更佳实现是直接用页数,避免溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void
boot_map_region(pde_t *pgdir, uintptr_t va, size_t size, physaddr_t pa, int perm)
{
// Fill this function in
pte_t *pgtab;
size_t pg_num = PGNUM(size);
cprintf("map region size = %d, %d pages\n",size, pg_num);
for (size_t i = 0; i < pg_num; i ++) {
pgtab = pgdir_walk(pgdir, (void *)va, 1);
if (!pgtab) {
return;
}
*pgtab = pa | perm | PTE_P;
va += PGSIZE;
pa += PGSIZE;
}
}

注释中提示我们,这是静态映射,不要增加每个page对应的PageInfo结构体的引用计数pp_ref

根据各个函数的依赖关系,下一个编写page_lookup函数。作用是查找虚拟地址对应的物理页描述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct PageInfo *
page_lookup(pde_t *pgdir, void *va, pte_t **pte_store)
{
// 参数1: 页目录指针
// 参数2: 线性地址,JOS 中等于虚拟地址
// 参数3: 指向页表指针的指针
// 返回: 页描述结构体指针
pte_t *pgtab = pgdir_walk(pgdir, va, 0); // 不创建,只查找
if (!pgtab) {
return NULL; // 未找到则返回 NULL
}
if (pte_store) {
*pte_store = pgtab; // 附加保存一个指向找到的页表的指针
}
return pa2page(PTE_ADDR(*pgtab)); // 返回页面描述
}

此处再次用到了PTE_ADDR这个宏。其作用是将页表指针指向的内容转为物理地址。这里还是要注意,从page table中拿出page frame的为物理地址,不是虚拟地址。

page_remove函数作用是移除一个虚拟地址与对应的物理页的映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
page_remove(pde_t *pgdir, void *va)
{
// Fill this function in
pte_t *pgtab;
pte_t **pte_store = &pgtab;
struct PageInfo *pInfo = page_lookup(pgdir, va, pte_store);
if (!pInfo) {
return;
}
page_decref(pInfo);
*pgtab = 0; // 将内容清0,即无法再根据页表内容得到物理地址。
tlb_invalidate(pgdir, va); // 通知tlb失效。tlb是个高速缓存,用来缓存查找记录增加查找速度。
}

函数还减小了PageInfo结构体的引用计数pp_ref,并让TLB缓存失效了。

page_insert函数作用是建立一个虚拟地址与物理页的映射,与page_remove对应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int
page_insert(pde_t *pgdir, struct PageInfo *pp, void *va, int perm)
{
// 参数1: 页目录指针
// 参数2: 页描述结构体指针
// 参数3: 线性地址,JOS 中等于虚拟地址
// 参数4: 权限
// 返回: 成功(0),失败(-E_NO_MEM)
pte_t *pgtab = pgdir_walk(pgdir, va, 1); // 查找该虚拟地址对应的页表项,不存在则建立。
if (!pgtab) {
return -E_NO_MEM; // 空间不足
}
if (*pgtab & PTE_P) {
// 页表项已经存在,即该虚拟地址已经映射到物理页了
if (page2pa(pp) == PTE_ADDR(*pgtab)) {
// 如果映射到与之前相同的页,仅更改权限,不增加引用
*pgtab = page2pa(pp) | perm | PTE_P;
return 0;
} else {
// 如果是更新映射的物理页,则要删除之前的映射关系
page_remove(pgdir, va);
}
}
*pgtab = page2pa(pp) | perm | PTE_P;
pp->pp_ref++;
return 0;
}

需要注意的是,如果同样的虚拟页映射到了同样的物理页,如果不做特殊处理仍然调用page_remove后再增加引用次数,可能会出现以下情况:

  • 当该物理页ref = 1,经过page_remove后会被加入空闲页链表。然而,在函数最后还需要增加其引用计数,导致page_free_list中出现了非空闲页。

课程中希望尽量不要做特例处理,即避免使用if,于是可以这么改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int 
page_insert(pde_t *pgdir, struct PageInfo *pp, void *va, int perm)
{
pte_t *pgtab = pgdir_walk(pgdir, va, 1);
if (!pgtab) {
return -E_NO_MEM;
}
// 这里一定要提前增加引用
pp->pp_ref++;
if (*pgtab & PTE_P) {
page_remove(pgdir, va);
}
*pgtab = page2pa(pp) | perm | PTE_P;
return 0;
}

以上只要区分开了entry中保存的都是物理地址就好弄了。

Page Table组织总结

在Lab 2中,我们让代码跑过了各种check_*函数,但是没有对其中的原理充分深究。这里总结一下。

内核的内存管理是以page为单位的,称为一个Page Frame,一个page的大小是4096Bytes,也就是4KB。内核使用free list链表的方式管理尚未分配的空间,实现非常简单。

要使用内存,必须建立虚拟地址映射。无论是C代码还是汇编代码,要访问内存,都是通过虚拟地址。C代码中,所有指针的值都必须为虚拟地址,代码才能正确执行,否则*访问不到想要的地址。

虚拟地址映射是通过一个二级table实现的,两个层级分别被称为Page DirectoryPage Table。两者在结构上没有区别,只是相同结构的相互嵌套。虚拟地址不包含任何table的地址,只包含table的索引。必须事先指定好Page Directory的地址,利用这个地址得到Page Directory Entry,从而得到Page Table地址,从而得到Page Frame地址,需要且仅需要指定Page Directory地址。Page Directory地址是寄存器cr3,设置cr3的行为会导致硬件执行切换Page Directory配套的一系列操作。

在函数mem_init之前,内核加载时简单地初始化了一个Page Directory,将0xf0000000开始的一段地址映射到0x0开始的一段地址,以方便正式初始化虚拟地址映射之前的操作。在mem_init函数的最后,我们需要初始化一个真正的kern_pgdir,并将寄存器cr3设置为它的地址。

最终得到的虚拟地址布局为文件memlayout.h中的注释(再来一遍,这个图画的真的太好了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/*
* Virtual memory map: Permissions
* kernel/user
*
* 4 Gig --------> +------------------------------+
* | | RW/--
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* : . :
* : . :
* : . :
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
* | | RW/--
* | Remapped Physical Memory | RW/--
* | | RW/--
* KERNBASE, ----> +------------------------------+ 0xf0000000 --+
* KSTACKTOP | CPU0's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| |
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* | CPU1's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| PTSIZE
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* : . : |
* : . : |
* MMIOLIM ------> +------------------------------+ 0xefc00000 --+
* | Memory-mapped I/O | RW/-- PTSIZE
* ULIM, MMIOBASE --> +------------------------------+ 0xef800000
* | Cur. Page Table (User R-) | R-/R- PTSIZE
* UVPT ----> +------------------------------+ 0xef400000
* | RO PAGES | R-/R- PTSIZE
* UPAGES ----> +------------------------------+ 0xef000000
* | RO ENVS | R-/R- PTSIZE
* UTOP,UENVS ------> +------------------------------+ 0xeec00000
* UXSTACKTOP -/ | User Exception Stack | RW/RW PGSIZE
* +------------------------------+ 0xeebff000
* | Empty Memory (*) | --/-- PGSIZE
* USTACKTOP ---> +------------------------------+ 0xeebfe000
* | Normal User Stack | RW/RW PGSIZE
* +------------------------------+ 0xeebfd000
* | |
* | |
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* . .
* . .
* . .
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
* | Program Data & Heap |
* UTEXT --------> +------------------------------+ 0x00800000
* PFTEMP -------> | Empty Memory (*) | PTSIZE
* | |
* UTEMP --------> +------------------------------+ 0x00400000 --+
* | Empty Memory (*) | |
* | - - - - - - - - - - - - - - -| |
* | User STAB Data (optional) | PTSIZE
* USTABDATA ----> +------------------------------+ 0x00200000 |
* | Empty Memory (*) | |
* 0 ------------> +------------------------------+ --+
*
* (*) Note: The kernel ensures that "Invalid Memory" is *never* mapped.
* "Empty Memory" is normally unmapped, but user programs may map pages
* there if desired. JOS user programs map pages temporarily at UTEMP.
*/

建立映射的函数们

我们已经写好了很多函数,在把它们用起来之前,再浏览一遍它们的目的。

首先是分配器,对未分配的物理内存进行管理。在初始化函数mem_init中调用page_init初始化了这个分配器,之后通过page_allocpage_free获取和释放page

要正确建立映射,首先需要正确方便地索引Page DirectoryPage Table。函数pgdir_walk,根据指定Page Directory索引出Page Table Entry。函数page_lookup基于pgdir_walk,进一步得到这个Page Table Entry对应的物理地址。

地址映射可以建立或移除,我们都写好了方便的函数。函数boot_map_region用于给内核做映射,只处理0xf0000000以上虚拟空间。函数page_insertpage_remove处理其他空间的映射,分别建立映射、移除映射。

其他函数对以上起辅助作用。

为内核建立虚拟地址映射

Lab 2 Part 3要求我们补全函数mem_init后面的部分,也就是给内核配置好kern_pgdir,并设置寄存器cr3。在这里使用的函数都是boot_map_region

JOS 将处理器的 32 位线性地址空间分为两部分。我们将在 lab3 中开始加载和运行的用户环境(进程)将控制下部的布局和内容,而内核始终保持对上部的完全控制。分隔线由inc/memlayout.h中的符号ULIM随意定义,为内核保留大约 256MB 的虚拟地址空间。这就解释了为什么我们需要在实验室 1 中给内核一个如此高的链接地址:否则内核的虚拟地址空间将没有足够的空间同时映射到它下面的用户环境。

权限和故障隔离

由于内核和用户内存都存在于每个环境的地址空间中,我们将不得不在 x86 页表中使用权限位来允许用户代码仅访问地址空间的用户部分。否则用户代码中的错误可能会覆盖内核数据,导致崩溃或更微妙的故障;用户代码也可能窃取其他环境的私人数据。请注意,可写权限位PTE_W会影响用户和内核代码!

用户环境将无权访问ULIM之上的任何内存,而内核将能够读写此内存。对于地址范围[UTOP,ULIM),内核和用户环境都有相同的权限:可以读但不能写这个地址范围。该地址范围用于向用户环境公开某些只读的内核数据结构。最后,UTOP下面的地址空间是供用户环境使用的;用户环境将设置访问此内存的权限。

JOS 将处理器的 32 位线性地址分为用户环境(低位地址)以及内核环境(高位地址)。分界线在inc/memlayout.h中定义为ULIM

1
2
3
4
5
6
7
#define KERNBASE    0xF0000000
// Kernel stack.
#define KSTACKTOP KERNBASE
// Memory-mapped IO.
#define MMIOLIM (KSTACKTOP - PTSIZE)
#define MMIOBASE (MMIOLIM - PTSIZE)
#define ULIM (MMIOBASE)

其中PTSIZE被定义为一个页目录项映射的 Byte,一个页目录中有1024个页表项,每个页表项可映射一个物理页。故为 4MB。可算得 ULIM = 0xf0000000 - 0x00400000 - 0x00400000 = 0xef800000,可通过查看inc/memlayout确认。

我们还需要给物理页表设置权限以确保用户只能访问用户环境的地址空间。否则,用户的代码可能会覆盖内核数据,造成严重后果。用户环境应该在高于 ULIM 的内存中没有任何权限,而内核则可以读写着部分内存。在 UTOP( 0xeec00000) 到 ULIM 的 12MB 区间中,存储了一些内核数据结构。内核以及用户环境对这部分地址都只具有 read-only 权限。低于 UTOP 的内存则由用户环境自由设置权限使用。

首先是分配器的pages数组,Hints中告诉我们这应该是对用户只读,并映射到UPAGES地址去。UPAGES (0xef000000 ~ 0xef400000)最多4MB,这是 JOS 记录物理页面使用情况的数据结构,即 exercise 1 中完成的东西,只有 kernel 能够访问。由于用户空间同样需要访问这个数据结构,我们将用户空间的一块内存映射到存储该数据结构的物理内存上。很自然联想到了boot_map_region这个函数。

1
2
3
4
5
6
7
// Map 'pages' read-only by the user at linear address UPAGES
// Permissions:
// - the new image at UPAGES -- kernel R, user R
// (ie. perm = PTE_U | PTE_P)
// - pages itself -- kernel RW, user NONE
// Your code goes here:
boot_map_region(kern_pgdir, UPAGES, ROUNDUP((sizeof(struct PageInfo)*npages), PGSIZE),PADDR(pages),PTE_U );

需要注意的是目前只建立了一个页目录,即kernel_pgdir,所以第一个参数显然为kernel_pgdir。第二个参数是虚拟地址,UPAGES本来就是以虚拟地址形式给出的。第三个参数是映射的内存块大小。第四个参数是映射到的物理地址,直接取 pages 的物理地址即可。权限PTE_U表示用户有权限读取。

然后是内核的栈,用户不可读写,映射到bootstack地址。内核栈0xefff8000 ~ 0xf0000000为32kB。bootstack表示的是栈地最低地址,由于栈向低地址生长,实际是栈顶。常数KSTACKTOP = 0xf0000000KSTKSIZE = 32kB。在此之下是一块未映射到物理内存的地址,所以如果栈溢出时,只会报错而不会覆盖数据。因此我们只用映射[KSTACKTOP-KSTKSIZE, KSTACKTOP)区间内的虚拟地址即可。

1
2
3
4
5
6
7
8
9
10
11
// Use the physical memory that 'bootstack' refers to as the kernel
// stack. The kernel stack grows down from virtual address KSTACKTOP.
// We consider the entire range from [KSTACKTOP-PTSIZE, KSTACKTOP)
// to be the kernel stack, but break this into two pieces:
// * [KSTACKTOP-KSTKSIZE, KSTACKTOP) -- backed by physical memory
// * [KSTACKTOP-PTSIZE, KSTACKTOP-KSTKSIZE) -- not backed; so if
// the kernel overflows its stack, it will fault rather than
// overwrite memory. Known as a "guard page".
// Permissions: kernel RW, user NONE
// Your code goes here:
boot_map_region(kern_pgdir, KSTACKTOP-KSTKSIZE, KSTKSIZE,PADDR(bootstack),PTE_W );

这里设置了PTE_W开启了写权限,然而并没有开启PTE_U,于是仅有内核能够读写,用户没有任何权限。

其余的地址全部映射到KERNBASE上,无论物理内存是否有这么大。用户不可读写。内核( 0xf0000000 ~ 0xffffffff )256MB。这里需要映射全部 0xf0000000 至 0xffffffff 共 256MB 的内存地址。

1
2
3
4
5
6
7
8
// Map all of physical memory at KERNBASE.
// Ie. the VA range [KERNBASE, 2^32) should map to
// the PA range [0, 2^32 - KERNBASE)
// We might not have 2^32 - KERNBASE bytes of physical memory, but
// we just set up the mapping anyway.
// Permissions: kernel RW, user NONE
// Your code goes here:
boot_map_region(kern_pgdir, KERNBASE, 0x100000000 - KERNBASE, 0, PTE_U);

为用户建立虚拟地址映射

这里才是Lab 3的内容。和page类似,内核通过一个struct Env数组envs管理用户环境。函数env_init初始化了这个数组,具体操作和page_init类似,就是拉链表。

函数env_setup_vm为指定的用户环境struct Env初始化虚拟地址映射,得到的是一个pde_t *类型的Page Directory。需要注意以下几点:

  • Page Directory分配的新page应该增加引用统计次数pp_ref
  • UTOP以下的地址对用户应该为可读可写的。
  • 可以使用kern_pgdir作为模板,在其基础上更改。

https://zhuanlan.zhihu.com/p/176967610

https://www.dingmos.com/index.php/archives/5/

lab3

简介

lab3 将主要实现能运行被保护的用户模式环境(protected user-mode environment,即 process)的内核服务。我们将增加数据结构来记录进程、创建进程、为其装载一个程序镜像。我们还要让 JOS 内核能够处理进程产生的系统调用和异常。

Lab 3 有如下几个新文件

  • inc/env.h:一些用户模式下的环境定义
  • trap.h:trap的定义
  • syscall.h:系统调用的定义,用户空间到内核空间
  • lib.h:用户模式下的定义
  • kern/env.h:内核支持用户模式的一些数据结构定义
  • env.c:内核实现了用户空间
  • trap.h:内核内部实现的处理trap
  • trap.c:trap处理的函数
  • trapentry.S:用汇编实现的进入trap处理的入口
  • syscall.h:内核实现的处理系统调用的函数
  • syscall.c:实现了系统调用
  • lib/Makefrag:生成用户库obj/lib/libjos.a的makefile
  • entry.S:用户环境的入口函数,用汇编实现
  • libmain.c:用户模式的入口
  • syscall.c:用户模式的系统调用入口
  • console.c:putchar和getchar的用户模式下实现,提供了终端的IO
  • exit.c:用户模式下exit的实现
  • panic.c:用户模式下panic的实现

env.h中,定义了envid_t,有三个部分,第一个部分ENVX(eid)环境index与envs[]数组中的环境index一样。uniqueifier用于区分不同情况下创建的环境。第三个部分是用于区分是否是真正的环境、是错误的环境。如果envid_t == 0就是当前的环境。

下边三个宏应该是为env编号以及取出env index用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef int32_t envid_t;
// An environment ID 'envid_t' has three parts:
//
// +1+---------------21-----------------+--------10--------+
// |0| Uniqueifier | Environment |
// | | | Index |
// +------------------------------------+------------------+
// \--- ENVX(eid) --/
//
// The environment index ENVX(eid) equals the environment's index in the
// 'envs[]' array. The uniqueifier distinguishes environments that were
// created at different times, but share the same environment index.
//
// All real environments are greater than 0 (so the sign bit is zero).
// envid_ts less than 0 signify errors. The envid_t == 0 is special, and
// stands for the current environment.

#define LOG2NENV 10
#define NENV (1 << LOG2NENV)
#define ENVX(envid) ((envid) & (NENV - 1))

Env需要存下当前环境下的寄存器、env_id及生成这个env的父亲的id,并将env组织成一个链表。

1
2
3
4
5
6
7
8
9
10
11
12
struct Env {
struct Trapframe env_tf; // Saved registers
struct Env *env_link; // Next free Env
envid_t env_id; // Unique environment identifier
envid_t env_parent_id; // env_id of this env's parent
enum EnvType env_type; // Indicates special system environments
unsigned env_status; // Status of the environment
uint32_t env_runs; // Number of times environment has run

// Address space
pde_t *env_pgdir; // Kernel virtual address of page dir
};

trap.h中除了定义一些错误和异常的id,主要是定义了两个结构,一个是用于在发生中断时把当前寄存器入栈的结构,另一个是记录这个trap的信息,注意到都使用了__attribute__ ((packed)),它的作用就是告诉编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐,应该是为了去掉无意义的padding避免出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct PushRegs {
/* registers as pushed by pusha */
uint32_t reg_edi;
uint32_t reg_esi;
uint32_t reg_ebp;
uint32_t reg_oesp; /* Useless */
uint32_t reg_ebx;
uint32_t reg_edx;
uint32_t reg_ecx;
uint32_t reg_eax;
} __attribute__((packed));

struct Trapframe {
struct PushRegs tf_regs;
uint16_t tf_es;
uint16_t tf_padding1;
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
} __attribute__((packed));

syscall.h中主要是用enum列举现有的syscall的代码。

1
2
3
4
5
6
7
8
/* system call numbers */
enum {
SYS_cputs = 0,
SYS_cgetc,
SYS_getenvid,
SYS_env_destroy,
NSYSCALLS
};

lib.h主要定义了用户模式下使用的一些函数和变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// main user program
void umain(int argc, char **argv);

// libmain.c or entry.S
extern const char *binaryname;
extern const volatile struct Env *thisenv;
extern const volatile struct Env envs[NENV];
extern const volatile struct PageInfo pages[];

// exit.c
void exit(void);

// readline.c
char* readline(const char *buf);

// syscall.c
void sys_cputs(const char *string, size_t len);
int sys_cgetc(void);
envid_t sys_getenvid(void);
int sys_env_destroy(envid_t);

/* File open modes */
#define O_RDONLY 0x0000 /* open for reading only */
#define O_WRONLY 0x0001 /* open for writing only */
#define O_RDWR 0x0002 /* open for reading and writing */
#define O_ACCMODE 0x0003 /* mask for above modes */

#define O_CREAT 0x0100 /* create if nonexistent */
#define O_TRUNC 0x0200 /* truncate to zero length */
#define O_EXCL 0x0400 /* error if already exists */
#define O_MKDIR 0x0800 /* create directory, not regular file */

Part A: 用户环境和异常处理

新的头文件inc/env.h包含了一些基础的用户环境的定义。内核可以使用这些Env数据结构来管理每一个用户环境。

kern/env.c,kenerl维护了一组Env结构:

1
2
3
struct Env *envs = NULL;		// All environments
struct Env *curenv = NULL; // The current env
static struct Env *env_free_list; // Free environment list

一旦 JOS 启动并运行,envs指针就会指向一个表示系统中所有环境的Env结构数组。 在我们的设计中,JOS 内核将支持最多NENV同时活动的环境,尽管在任何给定时间运行的环境通常要少得多。(NENVinc/env.h中的常量)。一旦分配,envs数组将包含每个NENV可能环境的Env数据结构的单个实例。

JOS 内核在env_free_list中保留了所有不活动的Env结构。 这种设计允许轻松分配和释放环境,因为它们只需添加到空闲列表或从空闲列表中删除。内核使用curenv符号在任何给定时间跟踪当前正在执行的环境。 在启动期间,在运行第一个环境之前,curenv最初设置为 NULL。

Environment State

Envinc/env.h中定义

1
2
3
4
5
6
7
8
9
10
11
12
struct Env {
struct Trapframe env_tf; // Saved registers
struct Env *env_link; // Next free Env
envid_t env_id; // Unique environment identifier
envid_t env_parent_id; // env_id of this env's parent
enum EnvType env_type; // Indicates special system environments
unsigned env_status; // Status of the environment
uint32_t env_runs; // Number of times environment has run

// Address space
pde_t *env_pgdir; // Kernel virtual address of page dir
};

  • env_tf:
    • inc/trap.h中定义,在这个环境没有运行时保存了其运行时的寄存器状态。当内核从用户态切换到内核态的时候,会保存当前的环境信息,用于之后切换时的场景恢复。
  • env_link:
    • 这是env组织起来的链表env_free_listenv_free_list指向链表中的第一个空闲env
      env_id:
    • 这个值唯一的标识了一个env。一个用户环境结束后,内核可能重新分配这个Env结构给一个不同的环境,但是这个Env就会有不同的env_id,即便这个env_id是复用的。
  • env_parent_id:
    • 内核存储了创造这个环境的父环境的env_id。这样可以将所有的环境组织成一个环境树,这样就可以方便的决定哪个环境可以对某个结构做什么操作。
  • env_type:
    • 这用于区分特殊环境。 对于大多数环境,它将是ENV_TYPE_USER。在后面的实验中,我们将针对特殊的系统服务环境再介绍几种类型。
  • env_status: 有如下几种取值:
    • ENV_FREE: 表示Env结构处于非活动状态,因此在env_free_list
    • ENV_RUNNABLE: 表示Env结构代表一个正在等待在处理器上运行的环境。
    • ENV_RUNNING: 表示Env结构代表当前运行的环境。
    • ENV_NOT_RUNNABLE: 表示Env结构表示当前活动的环境,但它当前尚未准备好运行:例如,因为它正在等待来自另一个环境的进程间通信 (IPC)。
    • ENV_DYING: 表示Env结构代表僵尸环境。僵尸环境将在下一次陷入内核时被释放。
  • env_pgdir: 这个变量保存了这个环境页面目录的内核虚拟地址。

与 Unix 进程一样,JOS 环境将“线程”和“地址空间”的概念结合在一起。线程主要由保存的寄存器(env_tf字段)定义,地址空间由env_pgdir指向的页目录和页表定义。为了运行一个环境,内核必须用保存的寄存器和适当的地址空间设置 CPU。

我们的struct Env类似于 xv6 中的struct proc。两个结构都在Trapframe结构中保存环境(即进程的)用户模式寄存器状态。在 JOS 中,单个环境不像 xv6 中的进程那样拥有自己的内核堆栈。内核中一次只能有一个 JOS 环境处于活动状态,因此 JOS 只需要一个内核堆栈。

分配环境数组

我们需要将envs指针指向一个由Env结构体组成的数组,就像我们在 lab2 中对pages指针做的一样。同时,JOS 还需要将不活动的Env记录在env_free_list之中,类似于page_free_list。curenv指针记录着现在执行的进程。在第一个进程运行之前,为NULL。
kern/pmap.c中添加以下两行代码,基本就是仿造之前对pages的处理。

1
2
envs = (struct Env*) boot_alloc(NENV * sizeof(struct Env));
memset(envs, 0, sizeof(struct Env) * NENV);

之后进行make;make qemu,如果从Lab 2继承下来,会出现kernel panic at kern/pmap.c:152: PADDR called with invalid kva 00000000的错误!究其原因,链接器提供的end变量并没有指向数据区域的最后,而是指向数据区域内。

错误分析

使用objdump -h obj/kern/kernel,得到如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00005379 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 000016b0 f0105380 00105380 00006380 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 000088c9 f0106a30 00106a30 00007a30 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 00002a60 f010f2f9 0010f2f9 000102f9 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 0007a014 f0112000 00112000 00013000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .got 0000000c f018c014 0018c014 0008d014 2**2
CONTENTS, ALLOC, LOAD, DATA
6 .got.plt 0000000c f018c020 0018c020 0008d020 2**2
CONTENTS, ALLOC, LOAD, DATA
7 .data.rel.local 0000100e f018d000 0018d000 0008e000 2**12
CONTENTS, ALLOC, LOAD, DATA
8 .data.rel.ro.local 000000cc f018e020 0018e020 0008f020 2**5
CONTENTS, ALLOC, LOAD, DATA
9 .bss 00000f14 f018e100 0018e100 0008f100 2**5
CONTENTS, ALLOC, LOAD, DATA
10 .comment 0000002b 00000000 00000000 00090014 2**0
CONTENTS, READONLY

可以看出.bss段的范围为:0xf018e100-0xf018f014,大小为0xf14。将end变量输出,得到:end=0xf018f000。可以看到end在数据段之间。

解决办法

修改链接脚本kern/kernel.ld:

1
2
3
4
5
6
7
.bss : {
PROVIDE(edata = .);
*(.bss)
*(COMMON)
PROVIDE(end = .);
BYTE(0)
}

COMMON添加在end之前即可。

将虚拟内存的 UENVS 段映射到 envs 的物理地址

1
2
3
4
5
6
// Map the 'envs' array read-only by the user at linear address UENVS
// (ie. perm = PTE_U | PTE_P).
// Permissions:
// - the new image at UENVS -- kernel R, user R
// - envs itself -- kernel RW, user NONE
boot_map_region(kern_pgdir, (uintptr_t)UENVS, ROUNDUP(NENV*sizeof(struct Env), PGSIZE), PADDR(envs), PTE_U | PTE_P);

修正这个错误之后,发现代码的顺序也会影响最后check判断,可能是因为如果不在它指定的地方添加代码的话,会影响page分配的顺序,从而影响检查。

创建和运行环境

在这里,环境和进程是可以对等的,都指程序运行期间的抽象。不直接叫进程是因为jos中实现的系统调用和UNIX是有差别的。

我们需要编写运行用户环境所需的kern/env.c代码。因为我们还没有文件系统,所以我们将设置内核来加载嵌入在内核中的静态二进制映像。JOS将此二进制文件作为ELF可执行映像嵌入内核中。

kern/Makefrag文件中,使用了一些方法将这些二进制文件直接“链接”到内核可执行文件中。 链接器命令行上的-b binary选项会将这些文件作为“原始”未解释的二进制文件链接,而不是作为编译器生成的常规.o文件链接。(就链接器而言,这些文件根本不必是ELF文件——它们可以是任何格式,例如文本文件或图片)如果在构建内核后查看obj/kern/kernel.sym,你会注意到链接器“神奇地”产生了许多有趣的符号,这些符号具有晦涩的名字,如_binary_obj_user_hello_start,_binary_obj_user_hello_end_binary_obj_user_hello_size。链接器通过修改二进制文件的文件名来生成这些符号名称; 这些符号为常规内核代码提供了引用嵌入式二进制文件的方法。

kern/init.c中的i386_init()中我们将会看到运行这些二进制镜像的方法。

一个函数一个函数的看,第一个是env_init,把所有的env组织成一个链表envs_free_list

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 将'envs'中的所有环境加入到env_free_list中
// 确保环境以相同的顺序加入到空闲列表中
void
env_init(void)
{
// Set up envs array
// LAB 3: Your code here.
int i;
for (i = NENV-1; i >= 0; i --) {
envs[i].env_id = 0;
envs[i].env_link = env_free_list;
env_free_list = &envs[i];
}
// Per-CPU part of the initialization
env_init_percpu();
}

函数env_setup_vm为指定的用户环境struct Env初始化虚拟地址映射,得到的是一个pde_t *类型的Page Directory。需要注意以下几点:

  • Page Directory分配的新page应该增加引用统计次数pp_ref
  • UTOP以下的地址对用户应该为可读可写的。
  • 可以使用kern_pgdir作为模板,在其基础上更改。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static int
env_setup_vm(struct Env *e)
{
int i;
struct PageInfo *p = NULL;

// Allocate a page for the page directory
if (!(p = page_alloc(ALLOC_ZERO)))
return -E_NO_MEM;

// Now, set e->env_pgdir and initialize the page directory.
//
// Hint:
// - Can you use kern_pgdir as a template? Hint: Yes.
// (Make sure you got the permissions right in Lab 2.)
// - The initial VA below UTOP is empty.
// - You do not need to make any more calls to page_alloc.
// - Note: In general, pp_ref is not maintained for
// physical pages mapped only above UTOP, but env_pgdir
// is an exception -- you need to increment env_pgdir's
// pp_ref for env_free to work correctly.
// - The functions in kern/pmap.h are handy.

// LAB 3: Your code here.
e->env_pgdir = page2kva(p);
memcpy(e->env_pgdir, kern_pgdir, PGSIZE);
p->pp_ref ++;
for (pde_t* pde = page2kva(p); pde < (pde_t*)(page2kva(p)+PGSIZE); pde ++)
*pde = *pde | PTE_U | PTE_W;

// UVPT maps the env's own page table read-only.
// Permissions: kernel R, user R
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;

return 0;
}

env_alloc用来分配一个env,并保存到newenv_store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 分配并初始化一个新的env,将其存在 *newenv_store.
//
// Returns 0 on success, < 0 on failure. Errors include:
// -E_NO_FREE_ENV if all NENV environments are allocated
// -E_NO_MEM on memory exhaustion
//
int
env_alloc(struct Env **newenv_store, envid_t parent_id)
{
int32_t generation;
int r;
struct Env *e;

// 如果env_free_list为空了,说明分配光了
if (!(e = env_free_list))
return -E_NO_FREE_ENV;

// 调用这个函数如果返回小于0,则说明没有多余内存了
if ((r = env_setup_vm(e)) < 0)
return r;

// 新生成一个env_id,当前的id加上特定的偏移量,再取低位
generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV - 1);
if (generation <= 0) // Don't create a negative env_id.
generation = 1 << ENVGENSHIFT;
e->env_id = generation | (e - envs);

e->env_parent_id = parent_id;
e->env_type = ENV_TYPE_USER;
e->env_status = ENV_RUNNABLE;
e->env_runs = 0;

// 清除之前可能保存的寄存器信息
memset(&e->env_tf, 0, sizeof(e->env_tf));

// 为寄存器赋初值
// GD_UD is the user data segment selector in the GDT
// GD_UT is the user text segment selector
// 每个寄存器的最低几位标志了特权级,3是用户态。
// 当我们转换特权级时,硬件会检查特权级和描述符优先级等
e->env_tf.tf_ds = GD_UD | 3;
e->env_tf.tf_es = GD_UD | 3;
e->env_tf.tf_ss = GD_UD | 3;
e->env_tf.tf_esp = USTACKTOP;
e->env_tf.tf_cs = GD_UT | 3;
// You will set e->env_tf.tf_eip later.

// commit the allocation
env_free_list = e->env_link;
*newenv_store = e;

cprintf("[%08x] new env %08x\n", curenv ? curenv->env_id : 0, e->env_id);
return 0;
}

region_alloc()为进程分配内存并完成映射。要利用 lab2 中的page_alloc()完成分配内存页,page_insert()完成虚拟地址到物理页的映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 为环境env分配len个字节的物理内存,将它映射到物理地址va
// 页需要可被用户和内核写
static void
region_alloc(struct Env *e, void *va, size_t len)
{

uintptr_t va_start = ROUNDDOWN((uintptr_t)va, PGSIZE);
uintptr_t va_end = ROUNDUP((uintptr_t)va + len, PGSIZE);
struct PageInfo *pginfo = NULL;
for (int cur_va=va_start; cur_va<va_end; cur_va+=PGSIZE) {
pginfo = page_alloc(0);
if (!pginfo) {
int r = -E_NO_MEM;
panic("region_alloc: %e" , r);
}
cprintf("insert page at %08x\n",cur_va);
page_insert(e->env_pgdir, pginfo, (void *)cur_va, PTE_U | PTE_W | PTE_P);
}
}

load_icode()作用是将 ELF 二进制文件读入内存,由于 JOS 暂时还没有自己的文件系统,实际就是从*binary这个内存地址读取。大概需要做的事:

  • 根据ELF header得出Programm header
  • 遍历所有Programm header,分配好内存,加载类型为ELF_PROG_LOAD的段。
  • 分配用户栈。

lcr3([页目录物理地址])将页目录地址加载到cr3寄存器。

更改函数入口时,将env->env_tf.tf_eip设置为elf->e_entry,等待之后的env_pop_tf()调用。

这里相当于实现一个ELF可执行文件加载器。ELF文件以一个ELF文件头开始,通过ELFHDR->e_magic字段判断该文件是否是ELF格式的,然后通过ELFHDR->e_phoff获取程序头距离ELF文件的偏移,ph指向的就是程序头的起始位置,相当于一个数组,程序头记录了有哪些Segment需要加载,加载到线性地址的何处?ph_num保存了总共有多少Segment。遍历ph数组,分配线性地址p_va开始的p_memsz大小的空间。并将ELF文件中binary + ph[i].p_offset偏移处的Segment拷贝到线性地址p_va处。

有一点需要注意,在执行for循环前,需要加载e->env_pgdir,也就是这句lcr3(PADDR(e->env_pgdir));,因为我们要将Segment拷贝到用户的线性地址空间内,而不是内核的线性地址空间。
加载完Segment后需要设置e->env_tf.tf_eip = ELFHDR->e_entry;也就是程序第一条指令的位置。

最后region_alloc(e, (void *) (USTACKTOP - PGSIZE), PGSIZE);为用户环境分配栈空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static void
load_icode(struct Env *e, uint8_t *binary)
{
struct Proghdr *ph, *eph;
struct Elf *elf = (struct Elf *)binary;
if (elf->e_magic != ELF_MAGIC) {
panic("load_icode: not an ELF file");
}
// 通过ELF header中保存的Programm header的偏移找到Programm header
ph = (struct Proghdr *)(binary + elf->e_phoff);
eph = ph + elf->e_phnum;

// 加载这个环境自己的页表
lcr3(PADDR(e->env_pgdir));
for (; ph<eph; ph++) {
// 如果这个programm header是需要被加载的
if (ph->p_type == ELF_PROG_LOAD) {
if (ph->p_filesz > ph->p_memsz) {
panic("load_icode: file size is greater than memory size");
}
// 分配一个空间并将所需要的复制过来,将其后填充
region_alloc(e, (void *)ph->p_va, ph->p_memsz);
memcpy((void *)ph->p_va, binary + ph->p_offset, ph->p_filesz);
memset((void *)ph->p_va + ph->p_filesz, 0, ph->p_memsz - ph->p_filesz);
}
}
e->env_tf.tf_eip = elf->e_entry;
// Now map one page for the program's initial stack
// at virtual address USTACKTOP - PGSIZE.

// LAB 3: Your code here.
region_alloc(e, (void *) USTACKTOP-PGSIZE, PGSIZE);
lcr3(PADDR(kern_pgdir));
}

env_create()作用是新建一个进程。调用已经写好的env_alloc()函数即可,之后更改类型并且利用load_icode()读取 ELF。

1
2
3
4
5
6
7
8
9
10
11
12
void
env_create(uint8_t *binary, enum EnvType type)
{
// LAB 3: Your code here.
struct Env *e;
int r = env_alloc(&e, 0);
if (r<0) {
panic("env_create: %e",r);
}
e->env_type = type;
load_icode(e, binary);
}

env_run()启动某个进程。最后调用了env_pop_tf()这个函数。该函数的作用是将struct Trapframe中存储的寄存器状态 pop 到相应寄存器中。查看之前写的load_icode()函数中的e->env_tf.tf_eip = elf->e_entry这一句,经过env_pop_tf()之后,指令寄存器的值即设置到了可执行文件的入口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void
env_run(struct Env *e)
{
// Step 1: If this is a context switch (a new environment is running):
// 1. Set the current environment (if any) back to
// ENV_RUNNABLE if it is ENV_RUNNING (think about
// what other states it can be in),
// 2. Set 'curenv' to the new environment,
// 3. Set its status to ENV_RUNNING,
// 4. Update its 'env_runs' counter,
// 5. Use lcr3() to switch to its address space.
// Step 2: Use env_pop_tf() to restore the environment's
// registers and drop into user mode in the
// environment.

// Hint: This function loads the new environment's state from
// e->env_tf. Go back through the code you wrote above
// and make sure you have set the relevant parts of
// e->env_tf to sensible values.

// LAB 3: Your code here.
if (curenv && curenv->env_status == ENV_RUNNING) {
curenv->env_status = ENV_RUNNABLE;
}
curenv = e;
e->env_status = ENV_RUNNING;
e->env_runs++;
lcr3(PADDR(e->env_pgdir));

env_pop_tf(&e->env_tf);
}

Trapframe结构和env_pop_tf()函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
struct PushRegs {
/* registers as pushed by pusha */
uint32_t reg_edi;
uint32_t reg_esi;
uint32_t reg_ebp;
uint32_t reg_oesp; /* Useless */
uint32_t reg_ebx;
uint32_t reg_edx;
uint32_t reg_ecx;
uint32_t reg_eax;
} __attribute__((packed));

struct Trapframe {
struct PushRegs tf_regs;
uint16_t tf_es;
uint16_t tf_padding1;
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
} __attribute__((packed));

// Restores the register values in the Trapframe with the 'iret' instruction.
// This exits the kernel and starts executing some environment's code.
//
// This function does not return.
//
void
env_pop_tf(struct Trapframe *tf)
{
asm volatile(
"\tmovl %0,%%esp\n" //将%esp指向tf地址处
"\tpopal\n" //弹出Trapframe结构中的tf_regs值到通用寄存器
"\tpopl %%es\n" //弹出Trapframe结构中的tf_es值到%es寄存器
"\tpopl %%ds\n" //弹出Trapframe结构中的tf_ds值到%ds寄存器
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret\n" //中断返回指令,具体动作如下:从Trapframe结构中依次弹出tf_eip,tf_cs,tf_eflags,tf_esp,tf_ss到相应寄存器
: : "g" (tf) : "memory");
panic("iret failed"); /* mostly to placate the compiler */
}

PushRegs结构保存的正是通用寄存器的值,env_pop_tf()第一条指令,将%esp指向tf地址处,也就是将栈顶指向Trapframe结构开始处,Trapframe结构开始处正是一个PushRegs结构,popalPushRegs结构中保存的通用寄存器值弹出到寄存器中,接着按顺序弹出寄存器%es, %ds。最后执行iret指令,该指令是中断返回指令,具体动作如下:从Trapframe结构中依次弹出tf_eiptf_cstf_eflagstf_esptf_ss到相应寄存器。

至此结束,本次 exercise 结束后运行并不会成功,会报错 Triple fault。然后 gdb 停止在:

1
2
=> 0x800a1c:    int    $0x30
0x00800a1c in ?? ()

原因是此时系统已经进入用户空间,执行了 hello 直到使用系统调用。然而由于 JOS 还没有允许从用户态到内核态的切换,CPU 会产生一个保护异常,然而这个异常也没有程序进行处理,于是生成了 double fault 异常,这个异常同样没有处理。所以报错 triple fault。也就是说,看到执行到了 int 这个中断,实际上就是本次 exercise 顺利结束,这个系统调用是为了在终端输出字符。

处理中断和异常

上一节中,int $0x30这个系统调用指令是一条死路:一旦进程进入用户模式,内核将无法再次获得控制权。异常和中断都是“受保护的控制权转移” (protected control transfers),使处理器从用户模式转到内核模式,用户模式代码无法干扰内核或者其他进程的运行。区别在于,中断是由处理器外部的异步事件产生;而异常是由目前处理的代码产生,例如除以0。

为保证切换是被保护的,处理器的中断、异常机制使得正在运行的代码无须选择在哪里以什么方式进入内核。相反,处理器将保证内核在严格的限制下才能被进入。在 x86 架构下,一共有两个机制提供这种保护:

中断描述符表(Interrupt Descriptor Table, IDT):处理器将确保从一些内核预先定义的条目才能进入内核,而不是由中断或异常发生时运行的代码决定。

x86 支持最多 256 个不同中断和异常的条目。每个包含一个中断向量,是一个 0~255 之间的数,代表中断来源:不同的设备以及错误类型。CPU 利用这些向量作为中断描述符表的索引。而这个表是内核定义在私有内存上(用户没有权限),就像全局描述符表(Global Descripter Table, GDT)一样。从表中恰当的条目,处理器可以获得:

  • 需要加载到指令指针寄存器(EIP)的值,该值指向内核中处理这类异常的代码。
  • 需要加载到代码段寄存器(CS)的值,其中最低两位表示优先级(这也是为什么说可以寻址 2^46 的空间而不是 2^48)。 在JOS 中,所有的异常都在内核模式处理,优先级为0 (用户模式为3)。

任务状态段(Task State Segment, TSS):处理器需要保存中断和异常出现时的自身状态,例如 EIP 和 CS,以便处理完后能返回原函数继续执行。但是存储区域必须禁止用户访问,避免恶意代码或 bug 的破坏。

因此,当 x86 处理器处理从用户到内核的模式转换时,也会切换到内核栈。而 TSS 指明段选择器和栈地址。处理器将 SS, ESP, EFLAGS, CS, EIP 压入新栈,然后从 IDT 读取 CS 和 EIP,根据新栈设置 ESP 和 SS。

JOS 仅利用 TSS 来定义需要切换的内核栈。由于内核模式在 JOS 优先级是 0,因此处理器用 TSS 的 ESP0 和 SS0 来定义内核栈,无需 TSS 结构体中的其他内容。其中, SS0 种存储的是 GD_KD(0x10),ESP0 种存储的是 KSTACKTOP(0xf0000000)。相关定义在inc/memlayout.h中可以找到。

中断和异常的类型

x86 的所有异常可以用中断向量 0~31 表示,对应 IDT 的第 0~31 项。例如,页错误产生一个中断向量为 14 的异常。大于 32 的中断向量表示的都是中断,其中,软件中断用 int 指令产生,而硬件中断则由硬件在需要关注的时候产生。

一个例子

通过一个例子来理解上面的知识。假设处理器正在执行用户环境的代码,遇到了”除0”异常。

处理器切换到内核栈,利用了上文TSS中的ESP0SS0,在JOS中分别是KSTACKTOPGD_KD。处理器将异常参数push到了内核栈。一般情况下,按顺序push SS, ESP, EFLAGS, CS, EIP

1
2
3
4
5
6
7
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20 <---- ESP
+--------------------+

存储这些寄存器状态的意义是:SS(堆栈选择器) 的低 16 位与 ESP 共同确定当前栈状态;EFLAGS(标志寄存器)存储当前FLAG;CS(代码段寄存器) 和 EIP(指令指针寄存器) 确定了当前即将执行的代码地址,E 代表”扩展”至32位。根据这些信息,就能保证处理中断结束后能够恢复到中断前的状态。

因为我们将处理一个”除0”异常,其对应中断向量是0,因此,处理器读取 IDT 的条目0,设置 CS:EIP 指向该条目对应的处理函数。

处理函数获得程序控制权并且处理该异常。例如,终止进程的运行。

对于某些特殊的 x86 异常,除了以上 5 个参数以外,还需要存储一个 error code。

1
2
3
4
5
6
7
8
+--------------------+ KSTACKTOP             
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20
| error code | " - 24 <---- ESP
+--------------------+

例如,页错误异常(中断向量=14)就是一个重要的例子,它就需要额外存储一个 error code。

嵌套的异常和中断

内核和用户进程都会引起异常和中断。然而,仅在从用户环境进入内核时才会切换栈。如果中断发生时已经在内核态了(此时, CS 寄存器的低 2bit 为 00) ,那么 CPU 就直接将状态压入内核栈,不再需要切换栈。这样,内核就能处理内核自身引起的”嵌套异常”,这是实现保护的重要工具。

如果处理器已经处于内核态,然后发生了嵌套异常,由于它并不进行栈切换,所以无须存储 SS 和 ESP 寄存器状态。对于不包含 error code 的异常,在进入处理函数前内核栈状态如下所示:

1
2
3
4
5
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
+--------------------+

对于包含了 error code 的异常,则将 error code 继续 push 到 EIP之后。

警告:如果 CPU 处理嵌套异常的时候,无法将状态 push 到内核栈(由于栈空间不足等原因),则 CPU 无法恢复当前状态,只能重启。当然,这是内核设计中必须避免的。

建立中断描述符表(IDT)

IDT可以驻留在物理内存中的任何位置。 处理器通过IDT寄存器(IDTR)定位IDT。

IDT包含了三种描述子

  • 任务门
  • 中断门
  • 陷阱门

每个entry为8bytes,有以下关键bit:

  • 16~31:code segment selector
  • 0~15 & 46-64:segment offset (根据以上两项可确定中断处理函数的地址)
  • Type (8-11):区分中断门、陷阱门、任务门等
  • DPL:Descriptor Privilege Level, 访问特权级
  • P:该描述符是否在内存中

通过上文,已经了解到了建立 IDT 以及处理异常所需要的基本信息。头文件inc/trap.hkern/trap.h包含了与中断和异常相关的定义。

每个异常和中断都应该在trapentry.Strap_init()有自己的处理函数,并在IDT中将这些处理函数的地址初始化,这也描述了Part A的整个过程。每个处理函数都需要在栈上新建一个struct Trapframe(见inc/trap.h),以其地址为参数调用trap()函数,然后进行异常处理。

总体的异常处理应该如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
      IDT                   trapentry.S         trap.c

+----------------+
| &handler1 |---------> handler1: trap (struct Trapframe *tf)
| | // do stuff {
| | call trap // handle the exception/interrupt
| | // ... }
+----------------+
| &handler2 |--------> handler2:
| | // do stuff
| | call trap
| | // ...
+----------------+
.
.
.
+----------------+
| &handlerX |--------> handlerX:
| | // do stuff
| | call trap
| | // ...
+----------------+

首先第一步是搞明白TRAPHANDLER这段汇编代码的意义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define TRAPHANDLER(name, num)  
.globl name;
.type name, @function;
.align 2;
name:
/*
* pushl $0; // if no error code
*/
pushl $(num);
jmp _alltraps

/* Use TRAPHANDLER_NOEC for traps where the CPU doesn't push an error code.
* It pushes a 0 in place of the error code, so the trap frame has the same
* format in either case.
*/
#define TRAPHANDLER_NOEC(name, num)
.globl name;
.type name, @function;
.align 2;
name:
pushl $0;
pushl $(num);
jmp _alltraps

  • .global/ .globl:用来定义一个全局的符号,格式如下:
    • .global symbol或者.globl symbol
    • 汇编函数如果需要在其他文件调用,需要把函数声明为全局的,此时就会用到.global这个伪操作。
  • .type: 用来指定一个符号的类型是函数类型或者是对象类型,对象类型一般是数据, 格式如下:
    • .type symbol, @object
    • .type symbol, @function
  • .align: 用来指定内存对齐方式,格式如下:
    • .align size表示按 size 字节对齐内存。

TRAPHANDLER定义了一个全局可见的函数来处理陷阱。它将陷阱编号压入堆栈,然后跳转到_alltraps。将TRAPHANDLER用于 CPU 自动推送错误代码的陷阱。不应该从 C 调用TRAPHANDLER函数,但可能需要在 C 中声明一个(例如,在 IDT 设置期间获取函数指针)。可以使用void NAME();声明函数。TRAPHANDLER_NOEC是没有返回错误码的陷阱。TRAPHANDLERTRAPHANDLER_NOEC创建的函数都会跳转到_alltraps处,这里参考inc/trap.h中的Trapframe结构,tf_sstf_esptf_eflagstf_cstf_eiptf_err在中断发生时由处理器压入,所以现在只需要压入剩下寄存器(%ds,%es,通用寄存器)。然后将%esp压入栈中(也就是压入trap()的参数tf)

之前在inc/trap.h中已经定义了T_*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Trap numbers
// These are processor defined:
#define T_DIVIDE 0 // divide error
#define T_DEBUG 1 // debug exception
#define T_NMI 2 // non-maskable interrupt
#define T_BRKPT 3 // breakpoint
#define T_OFLOW 4 // overflow
#define T_BOUND 5 // bounds check
#define T_ILLOP 6 // illegal opcode
#define T_DEVICE 7 // device not available
#define T_DBLFLT 8 // double fault
/* #define T_COPROC 9 */ // reserved (not generated by recent processors)
#define T_TSS 10 // invalid task switch segment
#define T_SEGNP 11 // segment not present
#define T_STACK 12 // stack exception
#define T_GPFLT 13 // general protection fault
#define T_PGFLT 14 // page fault
/* #define T_RES 15 */ // reserved
#define T_FPERR 16 // floating point error
#define T_ALIGN 17 // aligment check
#define T_MCHK 18 // machine check
#define T_SIMDERR 19 // SIMD floating point error

// These are arbitrarily chosen, but with care not to overlap
// processor defined exceptions or interrupt vectors.
#define T_SYSCALL 48 // system call
#define T_DEFAULT 500 // catchall

#define IRQ_OFFSET 32 // IRQ 0 corresponds to int IRQ_OFFSET

// Hardware IRQ numbers. We receive these as (IRQ_OFFSET+IRQ_WHATEVER)
#define IRQ_TIMER 0
#define IRQ_KBD 1
#define IRQ_SERIAL 4
#define IRQ_SPURIOUS 7
#define IRQ_IDE 14
#define IRQ_ERROR 19

通过查询80386手册的9.10可以看到如下关于error code的总结,根据是否有error code进行区分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Description                       Interrupt     Error Code

Divide error 0 No
Debug exceptions 1 No
Breakpoint 3 No
Overflow 4 No
Bounds check 5 No
Invalid opcode 6 No
Coprocessor not available 7 No
System error 8 Yes (always 0)
Coprocessor Segment Overrun 9 No
Invalid TSS 10 Yes
Segment not present 11 Yes
Stack exception 12 Yes
General protection fault 13 Yes
Page fault 14 Yes
Coprocessor error 16 No
Two-byte SW interrupt 0-255 No

所以现在在trapentry.S中需要定义handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
TRAPHANDLER_NOEC(divide_handler, T_DIVIDE);
TRAPHANDLER_NOEC(debug_handler, T_DEBUG);
TRAPHANDLER_NOEC(nmi_handler, T_NMI);
TRAPHANDLER_NOEC(brkpt_handler, T_BRKPT);
TRAPHANDLER_NOEC(oflow_handler, T_OFLOW);
TRAPHANDLER_NOEC(bound_handler, T_BOUND);
TRAPHANDLER_NOEC(illop_handler, T_ILLOP);
TRAPHANDLER_NOEC(device_handler, T_DEVICE);
TRAPHANDLER(dblflt_handler, T_DBLFLT);
TRAPHANDLER(tss_handler, T_TSS);
TRAPHANDLER(segnp_handler, T_SEGNP);
TRAPHANDLER(stack_handler, T_STACK);
TRAPHANDLER(gpflt_handler, T_GPFLT);
TRAPHANDLER(pgflt_handler, T_PGFLT);
TRAPHANDLER_NOEC(fperr_handler, T_FPERR);
TRAPHANDLER(align_handler, T_ALIGN);
TRAPHANDLER_NOEC(mchk_handler, T_MCHK);
TRAPHANDLER_NOEC(simderr_handler, T_SIMDERR);
TRAPHANDLER_NOEC(syscall_handler, T_SYSCALL);

// IRQs
TRAPHANDLER_NOEC(timer_handler, IRQ_OFFSET + IRQ_TIMER);
TRAPHANDLER_NOEC(kbd_handler, IRQ_OFFSET + IRQ_KBD);
TRAPHANDLER_NOEC(serial_handler, IRQ_OFFSET + IRQ_SERIAL);
TRAPHANDLER_NOEC(spurious_handler, IRQ_OFFSET + IRQ_SPURIOUS);
TRAPHANDLER_NOEC(ide_handler, IRQ_OFFSET + IRQ_IDE);
TRAPHANDLER_NOEC(error_handler, IRQ_OFFSET + IRQ_ERROR);

该部分主要作用是声明函数。该函数是全局的,但是在 C 文件中使用的时候需要使用void name();再声明一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
Your _alltraps should:
1. push values to make the stack look like a struct Trapframe
2. load GD_KD into %ds and %es
3. pushl %esp to pass a pointer to the Trapframe as an argument to trap()
4. call trap (can trap ever return?)
*/
.globl _alltraps
_alltraps:
pushl %ds
pushl %es
pushal

movw $GD_KD, %ax
movw %ax, %ds
movw %ax, %es
pushl %esp
call trap

这部分较有难度,首先要搞明白,栈是从高地址向低地址生长,而结构体在内存中的存储是从低地址到高地址。而 cpu 以及TRAPHANDLER宏已经将压栈工作进行到了中断向量部分。

首先需要产生一个struct trapframe结构的栈, 而压参数是从右往左,对应这个结构体就是从下往上对应。注意到tf_esp以及tf_ss只用在发生特权级变化的时候才会有,再往上是由硬件自动产生的。在TRAPHANDLER函数中压入了trapno,同时为了保证没有错误代码的trap能符合这个结构体,使用TRAPHANDLER_NOEC压入0占位err。最后我们的程序只需要压入trapno以上的参数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Trapframe {
struct PushRegs tf_regs;
uint16_t tf_es;
uint16_t tf_padding1;
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
} __attribute__((packed));

所以若要形成一个Trapframe,则还应该依次压入dses以及struct PushRegs中的各寄存器(倒序,可使用 pusha指令)。此后还需要更改数据段为内核的数据段。注意,不能用立即数直接给段寄存器赋值。因此不能直接写movw $GD_KD, %ds

kern/trap.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// You will also need to modify trap_init() to initialize the idt to 
// point to each of these entry points defined in trapentry.S;
// the SETGATE macro will be helpful here
void
trap_init(void)
{
extern struct Segdesc gdt[];
void divide_handler();
void debug_handler();
void nmi_handler();
void brkpt_handler();
void oflow_handler();
void bound_handler();
void device_handler();
void illop_handler();
void tss_handler();
void segnp_handler();
void stack_handler();
void gpflt_handler();
void pgflt_handler();
void fperr_handler();
void align_handler();
void mchk_handler();
void simderr_handler();
void syscall_handler();
void dblflt_handler();
void timer_handler();
void kbd_handler();
void serial_handler();
void spurious_handler();
void ide_handler();
void error_handler();

// LAB 3: Your code here.
// GD_KT 全局描述符, kernel text
SETGATE(idt[T_DIVIDE], 0, GD_KT, divide_handler, 0);
SETGATE(idt[T_DEBUG], 0, GD_KT, debug_handler, 0);
SETGATE(idt[T_NMI], 0, GD_KT, nmi_handler, 0);
SETGATE(idt[T_BRKPT], 0, GD_KT, brkpt_handler, 3);
SETGATE(idt[T_OFLOW], 0, GD_KT, oflow_handler, 0);
SETGATE(idt[T_BOUND], 0, GD_KT, bound_handler, 0);
SETGATE(idt[T_DEVICE], 0, GD_KT, device_handler, 0);
SETGATE(idt[T_ILLOP], 0, GD_KT, illop_handler, 0);
SETGATE(idt[T_DBLFLT], 0, GD_KT, dblflt_handler, 0);
SETGATE(idt[T_TSS], 0, GD_KT, tss_handler, 0);
SETGATE(idt[T_SEGNP], 0, GD_KT, segnp_handler, 0);
SETGATE(idt[T_STACK], 0, GD_KT, stack_handler, 0);
SETGATE(idt[T_GPFLT], 0, GD_KT, gpflt_handler, 0);
SETGATE(idt[T_PGFLT], 0, GD_KT, pgflt_handler, 0);
SETGATE(idt[T_FPERR], 0, GD_KT, fperr_handler, 0);
SETGATE(idt[T_ALIGN], 0, GD_KT, align_handler, 0);
SETGATE(idt[T_MCHK], 0, GD_KT, mchk_handler, 0);
SETGATE(idt[T_SIMDERR], 0, GD_KT, simderr_handler, 0);
SETGATE(idt[T_SYSCALL], 0, GD_KT, syscall_handler, 3);
// IRQ
SETGATE(idt[IRQ_OFFSET + IRQ_TIMER], 0, GD_KT, timer_handler, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_KBD], 0, GD_KT, kbd_handler, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_SERIAL], 0, GD_KT, serial_handler, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_SPURIOUS], 0, GD_KT, spurious_handler, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_IDE], 0, GD_KT, ide_handler, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_ERROR], 0, GD_KT, error_handler, 0);

// Per-CPU setup
trap_init_percpu();
}

SETGATE参见inc/mmu.h中的函数定义。

1
2
3
4
5
6
7
8
9
10
11
12
#define SETGATE(gate, istrap, sel, off, dpl)
{
(gate).gd_off_15_0 = (uint32_t) (off) & 0xffff;
(gate).gd_sel = (sel);
(gate).gd_args = 0;
(gate).gd_rsv1 = 0;
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;
(gate).gd_s = 0;
(gate).gd_dpl = (dpl);
(gate).gd_p = 1;
(gate).gd_off_31_16 = (uint32_t) (off) >> 16;
}

  • gate:这是一个 struct Gatedesc。
  • istrap:该中断是 trap(exception) 则为1,是 interrupt 则为0。
  • sel:代码段选择器。进入内核的话是 GD_KT。
  • off:相对于段的偏移,简单来说就是函数地址。
  • dpl(Descriptor Privileged Level):权限描述符。

Part B: 缺页错误,断点异常以及系统调用

处理缺页错误

缺页错误异常,中断向量 14 (T_PGFLT),是一个非常重要的异常类型。当程序遇到缺页异常时,它将引起异常的虚拟地址存入CR2控制寄存器( control register)。在trap.c中,我们已经提供了page_fault_handler()函数用来处理缺页异常。

修改trap_dispatch()函数比较简单,实际上就是在trap_dispatch()中根据 trap number 进行一个处理分配。目前只需要加入缺页异常即可完成该 exercise。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static void
trap_dispatch(struct Trapframe *tf)
{
// Handle processor exceptions.
// LAB 3: Your code here.
switch (tf->tf_trapno) {
case T_PGFLT:
page_fault_handler(tf);
break;
default:
// Unexpected trap: The user process or the kernel has a bug.
print_trapframe(tf);
if (tf->tf_cs == GD_KT)
panic("unhandled trap in kernel");
else {
env_destroy(curenv);
return;
}
}
}

断点异常

断点异常,中断向量 3 (T_BRKPT) 允许调试器给程序加上断点。原理是暂时把程序中的某个指令替换为一个 1 字节大小的int3软件中断指令。在 JOS 中,我们将它实现为一个伪系统调用。这样,任何程序(不限于调试器)都能使用断点功能。这个exercise同样也是修改trap_dispatch()函数。另外需要找到在kern/monitor.c中的void monitor(struct TrapFrame *tf)函数,加入断点处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static void
trap_dispatch(struct Trapframe *tf)
{
// Handle processor exceptions.
// LAB 3: Your code here.
switch (tf->tf_trapno) {
case T_PGFLT:
page_fault_handler(tf);
break;
case T_BRKPT:
monitor(tf);
break;
default:
// Unexpected trap: The user process or the kernel has a bug.
print_trapframe(tf);
if (tf->tf_cs == GD_KT)
panic("unhandled trap in kernel");
else {
env_destroy(curenv);
return;
}
}
}

challenge部分要求,我们修改monitor的代码,使得程序能够继续执行,以及能够逐指令执行。首先,按照题目要求,我们肯定要给monitor增加2个函数,不妨叫做:

1
2
int mon_continue(int argc, char **argv, struct Trapframe *tf);
int mon_stepi(int argc, char **argv, struct Trapframe *tf);

把这两行加在头文件中,再在命令序列中加入这两个命令。

1
2
3
4
5
6
7
static struct Command commands[] = {
{ "help", "Display this list of commands", mon_help },
{ "kerninfo", "Display information about the kernel", mon_kerninfo },
{ "backtrace", "Display a backtrace of the function stack", mon_backtrace },
{ "stepi", "step instruction", mon_stepi},
{ "continue", "continue instruction", mon_continue},
};

其次,根据提示,我们去阅读intel文档中关于EFLAGS寄存器的部分,发现了一个位:Trap Bit。如果这个位被设置位1,那么每次执行一条指令,都会自动触发一次Debug Exception.

那么我们要做的就很简单了:在两个函数中,修改eflags寄存器的值,并返回-1(然后从内核态返回用户态);同时,我们也要给Debug Exception增加特殊的处理函数,使他能够进入monitor。

注意,因为这些中断都是用户态到内核态的,所以trap_init中要做一些修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
int mon_continue(int argc, char **argv, struct Trapframe *tf)
{
// Continue exectuion of current env.
// Because we need to exit the monitor, retrun -1 when we can do so
// Corner Case: If no trapframe(env context) is given, do nothing
if(tf == NULL)
{
cprintf("No Env is Running! This is Not a Debug Monitor!\n");
return 0;
}
// Because we want the program to continue running; clear the TF bit
tf->tf_eflags &= ~(FL_TF);
return -1;
}

int mon_stepi(int argc, char **argv, struct Trapframe *tf)
{
// Continue exectuion of current env.
// Because we need to exit the monitor, retrun -1 when we can do so
// Corner Case: If no trapframe(env context) is given, do nothing
if(tf == NULL)
{
cprintf("No Env is Running! This is Not a Debug Monitor!\n");
return 0;
}
// Because we want the program to single step, set the TF bit
tf->tf_eflags |= (FL_TF);
return -1;
}
// Changes in trap_init
void handlerx();
// Debug Exception could be trap or Fault
SETGATE(idt[T_DEBUG], 0, GD_KT, DEBUG, 3);
void handlerx();
SETGATE(idt[T_NMI], 0, GD_KT, NMI, 0);
void handlerx();
SETGATE(idt[T_BRKPT], 1, GD_KT, BRKPT, 3);

系统调用

用户进程通过系统调用来让内核为他们服务。当用户进程召起一次系统调用,处理器将进入内核态,处理器以及内核合作存储用户进程的状态,内核将执行适当的代码来完成系统调用,最后返回用户进程继续执行。实现细节各个系统有所不同。

JOS 内核使用int指令来触发一个处理器中断。特别的,我们使用int $0x30作为系统调用中断。它并不能由硬件产生,因此使用它不会产生歧义。

应用程序会把系统调用号 (与中断向量不是一个东西) 以及系统调用参数传递给寄存器。这样,内核就不用在用户栈或者指令流里查询这些信息。系统调用号将存放于%eax,参数(至多5个)会存放于%edx, %ecx, %ebx, %edi以及%esi,调用结束后,内核将返回值放回到%eax。之所以用%eax来传递返回值,是由于系统调用导致了栈的切换。

kern中有一套syscall.hsyscall.cinclib中又有一套syscall.hsyscall.c。需要理清这两者之间的关系。

inc/syscall.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef JOS_INC_SYSCALL_H
#define JOS_INC_SYSCALL_H

/* system call numbers */
enum {
SYS_cputs = 0,
SYS_cgetc,
SYS_getenvid,
SYS_env_destroy,
NSYSCALLS
};

#endif /* !JOS_INC_SYSCALL_H */

这个头文件主要定义了系统调用号,实际就是一个 enum 而已。

lib/syscall.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// System call stubs.

#include <inc/syscall.h>
#include <inc/lib.h>

static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
int32_t ret;

// Generic system call: pass system call number in AX,
// up to five parameters in DX, CX, BX, DI, SI.
// Interrupt kernel with T_SYSCALL.
//
// The "volatile" tells the assembler not to optimize
// this instruction away just because we don't use the
// return value.
//
// The last clause tells the assembler that this can
// potentially change the condition codes and arbitrary
// memory locations.

asm volatile("int %1\n"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4),
"S" (a5)
: "cc", "memory");

if(check && ret > 0)
panic("syscall %d returned %d (> 0)", num, ret);

return ret;
}

void
sys_cputs(const char *s, size_t len)
{
syscall(SYS_cputs, 0, (uint32_t)s, len, 0, 0, 0);
}

int
sys_cgetc(void)
{
return syscall(SYS_cgetc, 0, 0, 0, 0, 0, 0);
}

int
sys_env_destroy(envid_t envid)
{
return syscall(SYS_env_destroy, 1, envid, 0, 0, 0, 0);
}

envid_t
sys_getenvid(void)
{
return syscall(SYS_getenvid, 0, 0, 0, 0, 0, 0);
}

这个里边先定义了一个通用的syscall接口,用于被其他系统调用这个通用的接口。

补充知识:GCC内联汇编

其语法固定为:

1
2
3
4
5
6
7
8
9
10
11
12
asm volatile (“asm code”:output:input:changed);

asm volatile("int %1\n"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4),
"S" (a5)
: "cc", "memory");

限定符 意义
“m”、”v”、”o” 内存单元
“r” 任何寄存器
“q” 寄存器eax、ebx、ecx、edx之一
“i”、”h” 直接操作数
“E”、”F” 浮点数
“g” 任意
“a”、”b”、”c”、”d” 分别表示寄存器eax、ebx、ecx和edx
“S”、”D” 寄存器esi、edi
“I” 常数 (0至31)

除了这些约束之外,输出值还包含一个约束修饰符:

输出修饰符 描述
+ 可以读取和写入操作数
= 只能写入操作数
% 如果有必要操作数可以和下一个操作数切换
& 在内联函数完成之前, 可以删除和重新使用操作数

根据表格内容,可以看出该内联汇编作用就是引发一个int中断,中断向量为立即数T_SYSCALL,同时,对寄存器进行操作。

首先不要忘记在kern/trap.c中的trap_init中设置好入口,并且权限设为3,使得用户进程能够产生这个中断。

1
SETGATE(idt[T_SYSCALL], 0, GD_KT, handler48, 3);

另外就是trap_dispatch函数中加入相应的处理方法:

1
2
3
4
5
6
7
8
case T_SYSCALL:
tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax,
tf->tf_regs.reg_edx,
tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx,
tf->tf_regs.reg_edi,
tf->tf_regs.reg_esi);
break;

由于已经通过lib/syscall.c处理,tf结构体中存储的寄存器状态已经记录了系统调用号,系统调用参数等等。现在我们就可以利用这些信息调用kern/syscall.c中的函数了。

在函数trap_dispatch中,被分发到函数handle_syscall。在handle_syscall中调用真正的syscall函数,进行二次分发和运行。内核调用的函数syscall和用户调用的不同,前者在kern/syscall.c中,根据syscallno选择处理函数执行,如下:

kern/syscall.h

1
int32_t syscall(uint32_t num, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5);

用户调用的syscall函数接受6个参数。第一个是系统调用序号,告诉内核要使用那个处理函数,进入寄存器eax。后5个是传递给内核中的处理函数的参数,进入剩下的寄存器edx, ecx, ebx, edi, esi。这些寄存器都在中断产生时被压栈了,可以通过Trapframe访问到。

我们在kern/trap.c中调用的实际上就是这里的syscall函数,而不是lib/syscall.c中的那个。想明白这一点,设置参数也就很简单了,注意返回值的处理。

kern/syscall.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
// Call the function corresponding to the 'syscallno' parameter.
// Return any appropriate return value.
// LAB 3: Your code here.

// panic("syscall not implemented");

int32_t retVal = 0;
switch (syscallno) {
case SYS_cputs:
sys_cputs((const char *)a1, a2);
break;
case SYS_cgetc:
retVal = sys_cgetc();
break;
case SYS_env_destroy:
retVal = sys_env_destroy(a1);
break;
case SYS_getenvid:
retVal = sys_getenvid() >= 0;
case SYS_getenvid:
// retVal = sys_getenvid() >= 0; 错误,应该返回获取的id
// 返回值不仅是用于判断执行成功与否,也可能携带信息
retVal = sys_getenvid();
break;
default:
retVal = -E_INVAL;
}
return retVal;
}

user/hello.c为例,其中调用了cprintf(),注意这是lib/print.c中的cprintf,该cprintf()最终会调用lib/syscall.c中的sys_cputs()sys_cputs()又会调用lib/syscall.c中的syscall(),该函数将系统调用号放入%eax寄存器,五个参数依次放入in DX, CX, BX, DI, SI,然后执行指令int 0x30,发生中断后,去IDT中查找中断处理函数,最终会走到kern/trap.ctrap_dispatch()中,我们根据中断号0x30,又会调用kern/syscall.c中的syscall()函数(注意这时候我们已经进入了内核模式CPL=0),在该函数中根据系统调用号调用kern/print.c中的cprintf()函数,该函数最终调用kern/console.c中的cputchar()将字符串打印到控制台。当trap_dispatch()返回后,trap()会调用env_run(curenv);,该函数前面讲过,会将curenv->env_tf结构中保存的寄存器快照重新恢复到寄存器中,这样又会回到用户程序系统调用之后的那条指令运行,只是这时候已经执行了系统调用并且寄存器eax中保存着系统调用的返回值。任务完成重新回到用户模式CPL=3。

通过 exercise 7,可以看出 JOS系 统调用的步骤为:

  • 用户进程使用inc/目录下暴露的接口
  • lib/syscall.c中的函数将系统调用号及必要参数传给寄存器,并引起一次int $0x30中断
  • kern/trap.c捕捉到这个中断,并将TrapFrame记录的寄存器状态作为参数,调用处理中断的函数
  • kern/syscall.c处理中断

记住这两个execrise能成功执行的话,需要在SETGATE中把这个设置成用户进程能够调用!!!

用户进程启动

用户进程从lib/entry.S开始运行。经过一些设置,调用了lib/libmain.c下的libmain()函数。在libmain()中,我们需要把全局指针thisenv指向该程序在envs[]数组中的位置。

libmain()会调用umain,即用户进程的main函数。在user/hello.c中,

1
2
3
4
5
6
void
umain(int argc, char **argv)
{
cprintf("hello, world\n");
cprintf("i am environment %08x\n", thisenv->env_id); // 之前就在这里报错,因为thisenv = 0
}

在 Exercise 8 中,我们将设置好thisenv,这样就能正常运行用户进程了。这也是我们第一次用到内存的 UENVS 区域。

lib/libmain.c中把thisenv = 0改为:

1
thisenv = &envs[ENVX(sys_getenvid())];

页错误 & 内存保护

内存保护是操作系统的关键功能,它确保了一个程序中的错误不会导致其他程序或是操作系统自身的崩溃。

操作系统通常依赖硬件的支持来实现内存保护。操作系统会告诉硬件哪些虚拟地址可用哪些不可用。当某个程序想访问不可用的内存地址或不具备权限时,处理器将在出错指令处停止程序,然后陷入内核。如果错误可以处理,内核就处理并恢复程序运行,否则无法恢复。

作为可以修复的错误,设想某个自动生长的栈。在许多系统中内核首先分配一个页面给栈,如果某个程序访问了页面外的空间,内核会自动分配更多页面以让程序继续。这样,内核只用分配程序需要的栈内存给它,然而程序感觉仿佛可以拥有任意大的栈内存。

系统调用也为内存保护带来了有趣的问题。许多系统调用接口允许用户传递指针给内核,这些指针指向待读写的用户缓冲区。内核处理系统调用的时候会对这些指针解引用。这样就带来了两个问题:

  1. 内核的页错误通常比用户进程的页错误严重得多,如果内核在操作自己的数据结构时发生页错误,这就是一个内核bug,会引起系统崩溃。因此,内核需要记住这个错误是来自用户进程。
  2. 内核比用户进程拥有更高的内存权限,用户进程给内核传递的指针可能指向一个只有内核能够读写的区域,内核必须谨慎避免解引用这类指针,因为这样可能导致内核的私有信息泄露或破坏内核完整性。

我们将对用户进程传给内核的指针做一个检查来解决这两个问题。内核将检查指针指向的是内存中用户空间部分,页表也允许内存操作。

Exercise 9需要修改kern/trap.c,使得内核态下的缺页能够引起panic。这需要检查tf_cs的地位。在kern/trap.c中加入判断页错误来源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;

// Read processor's CR2 register to find the faulting address
fault_va = rcr2();

// Handle kernel-mode page faults.

// LAB 3: Your code here.
// 在这里判断 cs 的低 2bit
if ((tf->tf_cs & 3) == 0) panic("Page fault in kernel-mode");

// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.

// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}

kern/pmap.c中修改检查用户内存的部分。需要注意的是由于需要存储第一个访问出错的地址,va所在的页面需要单独处理一下,不能直接对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

// Check that an environment is allowed to access the range of memory
// [va, va+len) with permissions 'perm | PTE_P'.
// Normally 'perm' will contain PTE_U at least, but this is not required.
// 'va' and 'len' need not be page-aligned; you must test every page that
// contains any of that range. You will test either 'len/PGSIZE',
// 'len/PGSIZE + 1', or 'len/PGSIZE + 2' pages.

// A user program can access a virtual address if (1) the address is below
// ULIM, and (2) the page table gives it permission. These are exactly
// the tests you should implement here.

// If there is an error, set the 'user_mem_check_addr' variable to the first
// erroneous virtual address.

int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
// LAB 3: Your code here.
uintptr_t start_va = ROUNDDOWN((uintptr_t)va, PGSIZE);
uintptr_t end_va = ROUNDUP((uintptr_t)va + len, PGSIZE);
for (uintptr_t cur_va=start_va; cur_va<end_va; cur_va+=PGSIZE) {
pte_t *cur_pte = pgdir_walk(env->env_pgdir, (void *)cur_va, 0);
if (cur_pte == NULL || (*cur_pte & (perm|PTE_P)) != (perm|PTE_P) || cur_va >= ULIM) {
if (cur_va == start_va) {
user_mem_check_addr = (uintptr_t)va;
} else {
user_mem_check_addr = cur_va;
}
return -E_FAULT;
}
}
return 0;
}

kern/syscall.c中的输出字符串部分加入内存检查。

1
2
3
4
5
6
7
8
9
10
11
static void
sys_cputs(const char *s, size_t len)
{
// Check that the user has permission to read memory [s, s+len).
// Destroy the environment if not.

// LAB 3: Your code here.
user_mem_assert(curenv, s, len, PTE_U);
// Print the string supplied by the user.
cprintf("%.*s", len, s);
}

kern/kdebug.c中的debuginfo_eip函数中加入内存检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        // Make sure this memory is valid.
// Return -1 if it is not. Hint: Call user_mem_check.
// LAB 3: Your code here.
if (user_mem_check(curenv, (void *)usd, sizeof(struct UserStabData), PTE_U) < 0) {
return -1;
}
...
// Make sure the STABS and string table memory is valid.
// LAB 3: Your code here.
if (user_mem_check(curenv, (void *)stabs, stab_end-stabs, PTE_U) < 0) {
return -1;
}
if (user_mem_check(curenv, (void *)stabstr, stabstr_end-stabstr, PTE_U) < 0) {
return -1;
}

TA’s Exercise

在 JOS 中添加一个展示进程信息的系统调用 ( 请在inc/syscall.h中定义SYS_show_environments),该系统调用可打印出所有进程的信息 ( 即struct Env的 内容,只打印env_id,寄存器信息等重要内容即可 )。

整体流程

inc/syscall.h中的枚举中定义变量SYS_show_environments,后在kern/syscall.c中定义函数static void sys_show_environments(void)打印envs数组中正在进行的进程的env_id以及状态 ( 不包括env_status == ENV_NOT_RUNNABLE),并且在文件末尾syscall函数中加入新加system call。到此为止,我们设置完了在 kernel model 下新系统调用的调用过程,之后转向 user model. 在inc/lib中声明刚定义的系统调用,并转到lib/syscall.c下的syscall.c中,利用syscall调用之前定义在kernel中的sys_show_environments(void),最后在user/hello.c中加入了这个调用就可以看到结果了.

调用过程及代码实现

user/hello.c调用在inc/lib.h中声明的sys_show_environments(),也就是在lib/syscall.c中定义的 ( 面对 user model 的 )sys_show_environments()

1
2
// at inc/lib.h:42
void sys_show_environments(void);

应用程序调用inc/lib.h中的sys_show_environments()函数,在lib/syscall.c中函数调用syscall()并且传参SYS_show_environmentssyscall()。之后syscall()利用内联汇编 trap into the kernel 并将T_SYSCALLSYS_show_environments这两个参数传给给后续函数 ( 后面虽然还传递了好几个 0 但是这里没有用就当他们不存在,而这里T_SYSCALL( 作为立即数传入 “i” ) 是用来做为索引给IDT找到SystemCall这个InterruptGate( 当然这也是之后trap_dispatch()要用到的参数 ),而之后的SYS_show_environments会被放入%eax中,之后将通过Trapfram进入kernel model下的stackkernel中的system call识别并调用对应的系统调用。)

1
2
3
4
5
6
// at lib/syscall.c:64
void
sys_show_environments(void)
{
syscall(SYS_show_environments, 0, 0, 0, 0, 0, 0);
}

trap_dispatch()中选择syscall()函数,并将Trapfram中的”寄存器” ( 其实是在 kernel stack 中 ) 保存的数据作为参数传入,之后在kern/syscall.csyscall()选择sys_show_environments()函数打印进程相关信息.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
static void
sys_show_environments(void) {
for(int i = 0; i < NENV; ++i){
if (envs[i].env_status == ENV_FREE || \
envs[i].env_status == ENV_NOT_RUNNABLE)
continue;
cprintf("Environment env_id: %x\tstatus: ", envs[i].env_id);
switch(envs[i].env_status){
case ENV_DYING:
cprintf("ENV_DYING\n");
break;
case ENV_RUNNABLE:
cprintf("ENV_RUNNABLE\n");
break;
case ENV_RUNNING:
cprintf("ENV_RUNNING\n");
break;
default: ;
}
}
return;
}

// Dispatches to the correct kernel function, passing the arguments.
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
// Call the function corresponding to the 'syscallno' parameter.
// Return any appropriate return value.
// LAB 3: Your code here.


switch (syscallno) {
case SYS_cputs:
sys_cputs((char *)a1, (size_t)a2);
return 0;
case SYS_cgetc:
return sys_cgetc();
case SYS_getenvid:
return sys_getenvid();
case SYS_env_destroy:
return sys_env_destroy((envid_t)a1);
case SYS_show_environments:
sys_show_environments();
return 0;
case NSYSCALLS:
default:
return -E_INVAL;
}
panic("syscall not implemented");
}

回顾下,本实验大致做了三件事:

  • 进程建立,可以加载用户ELF文件并执行。
    • 内核维护一个名叫envsEnv数组,每个Env结构对应一个进程,Env结构最重要的字段有Trapframe env_tf(该字段中断发生时可以保持寄存器的状态),pde_t *env_pgdir(该进程的页目录地址)。进程对应的内核数据结构可以用下图总结:
    • 定义了env_init()env_create()等函数,初始化Env结构,将Env结构Trapframe env_tf中的寄存器值设置到寄存器中,从而执行该Env

  • 创建异常处理函数,建立并加载IDT,使JOS能支持中断处理。要能说出中断发生时的详细步骤。需要搞清楚内核态和用户态转换方式:通过中断机制可以从用户环境进入内核态。使用iret指令从内核态回到用户环境。
    • 新建一个中断的步骤主要有:创建一个define标号,SETGATE注册中断和处理函数、特权级;在trap_dispatch中注册。
    • 中断发生过程以及中断返回过程和系统调用原理可以总结为下图:

  • 利用中断机制,使JOS支持系统调用。要能说出遇到int 0x30这条系统调用指令时发生的详细步骤。

lab4

简介

在 lab4 中我们将实现多个同时运行的用户进程之间的抢占式多任务处理。

  • 在 part A 中,我们需要给 JOS 增加多处理器支持。实现轮询( round-robin, RR )调度,并增加基本的用户程序管理系统调用( 创建和销毁进程,分配和映射内存 )。
  • 在 part B 中,我们需要实现一个与 Unix 类似的fork(),允许一个用户进程创建自己的拷贝。
  • 在 part C中,我们会添加对进程间通信 ( IPC ) 的支持,允许不同的用户进程相互通信和同步。还要增加对硬件时钟中断和抢占的支持。

Lab 4 包含许多新的源文件:

  • kern/cpu.h:多处理器支持的内核私有定义
  • kern/mpconfig.c:读取多处理器配置的代码
  • kern/lapic.c:驱动每个处理器中的本地 APIC 单元的内核代码
  • kern/mpentry.S:非引导 CPU 的汇编语言入口代码
  • kern/spinlock.h:自旋锁的内核私有定义,包括大内核锁
  • kern/spinlock.c:实现自旋锁的内核代码
  • kern/sched.c:将要实现的调度程序的代码框架

Part A: 多处理器支持及协同多任务处理

我们首先需要把 JOS 扩展到在多处理器系统中运行。然后实现一些新的 JOS 系统调用来允许用户进程创建新的进程。我们还要实现协同轮询调度,在当前进程不使用 CPU 时允许内核切换到另一个进程。

多处理器支持

我们即将使 JOS 能够支持“对称多处理” (Symmetric MultiProcessing, SMP)。这种模式使所有 CPU 能对等地访问内存、I/O 总线等系统资源。虽然 CPU 在 SMP 下以同样的方式工作,在启动过程中他们可以被分为两个类型:

  • 引导处理器(BootStrap Processor, BSP) 负责初始化系统以及启动操作系统;
  • 应用处理器( Application Processors, AP ) 在操作系统拉起并运行后由 BSP 激活。

哪个 CPU 作为 BSP 由硬件和 BIOS 决定。也就是说目前我们所有的 JOS 代码都运行在 BSP 上。

在 SMP 系统中,每个 CPU 都有一个附属的 (local APIC) LAPIC 单元。LAPIC 单元用于传递中断,并给它所属的 CPU 一个唯一的 ID。在 lab4 中,我们将会用到 LAPIC 单元的以下基本功能 ( 见kern/lapic.c1):

  • 读取 APIC ID 来判断我们的代码运行在哪个 CPU 之上。
  • 从 BSP 发送 STARTUP 跨处理器中断 (InterProcessor Interrupt, IPI) 来启动 AP。
  • 在 part C 中,我们为 LAPIC 的内置计时器编程来触发时钟中断以支持抢占式多任务处理。

处理器通过映射在内存上的 I/O (Memory-Mapped I/O, MMIO) 来访问它的 LAPIC。在 MMIO 中,物理内存的一部分被硬连接到一些 I/O 设备的寄存器,因此,访问内存的 load/store 指令可以被用于访问设备的寄存器。实际上,我们在 lab1 中已经接触过这样的 IO hole,如0xA0000被用来写 VGA 显示缓冲。

LAPIC 开始于物理地址 0xFE000000 ( 4GB以下32MB处 )。如果用以前的映射算法(将0xF0000000 映射到 0x00000000,也就是说内核空间最高只能到物理地址0x0FFFFFFF)显然太高了。因此,JOS 在MMIOBASE(即 虚拟地址0xEF800000) 预留了 4MB 来映射这类设备。我们需要写一个函数来分配这个空间并在其中映射设备内存。

exercise1需要实现kern/pmap.c中的mmio_map_region。首先还得去看kern/lapic.clapic_init的实现。lapic_init()函数的一开始就调用了该函数,将从lapicaddr开始的 4kB 物理地址映射到虚拟地址,并返回其起始地址。注意到,它是以页为单位对齐的,每次都map一个页的大小。

1
2
3
4
5
6
7
8
9
10
11
void
lapic_init(void)
{
if (!lapicaddr)
return;

// lapicaddr is the physical address of the LAPIC's 4K MMIO
// region. Map it in to virtual memory so we can access it.
lapic = mmio_map_region(lapicaddr, 4096);
.....
}

从基址开始保留大小字节的虚拟内存并将物理页 [pa,pa+size) 映射到虚拟地址 [base,base+size)。 由于这是设备内存而不是常规 DRAM,因此您必须告诉 CPU 缓存访问此内存是不安全的。幸运的是,分页表为此提供了位;除了PTE_W之外,只需使用PTE_PCD|PTE_PWT(缓存禁用和直写)创建映射。

实际就是调用boot_map_region来建立所需要的映射,需要注意的是,每次需要更改base的值,使得每次都是映射到一个新的页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//
// Reserve size bytes in the MMIO region and map [pa,pa+size) at this
// location. Return the base of the reserved region. size does *not*
// have to be multiple of PGSIZE.
//
void *
mmio_map_region(physaddr_t pa, size_t size)
{
physaddr_t pa_begin = ROUNDDOWN(pa, PGSIZE);
physaddr_t pa_end = ROUNDUP(pa + size, PGSIZE);
if (pa_end - pa_begin >= MMIOLIM - MMIOBASE) {
panic("mmio_map_region: requesting size too large.\n");
}
size = pa_end - pa_begin;
boot_map_region(kern_pgdir, base, size, pa_begin, PTE_W | PTE_PCD | PTE_PWT);
void *ret = (void *)base;
base += size;
return ret;
}

引导应用处理器

在启动 APs 之前,BSP需要先搜集多处理器系统的信息,例如 CPU 的总数,CPU 各自的 APIC ID,LAPIC 单元的 MMIO 地址。kern/mpconfig.c中的mp_init()函数通过阅读 BIOS 区域内存中的 MP 配置表来获取这些信息。

boot_aps()函数驱动了 AP 的引导。APs 从实模式开始,如同boot/boot.Sbootloader的启动过程。因此boot_aps()将 AP 的入口代码 (kern/mpentry.S) 拷贝到实模式可以寻址的内存区域 (0x7000,MPENTRY_PADDR)。

此后,boot_aps()通过发送STARTUP这个跨处理器中断到各 LAPIC 单元的方式,逐个激活 APs。激活方式为:初始化 AP 的CS:IP值使其从入口代码执行(MPENTRY_PADDR)。kern/mpentry.S中的入口代码跟boot/boot.S中的代码相同。通过一些简单的设置,AP 开启分页进入保护模式,然后调用 C 语言编写的mp_main()boot_aps()等待 AP 发送CPU_STARTED信号,然后再唤醒下一个。

先看boot_aps(),注释比较清楚,将入口代码复制到MPENTRY_PADDR中,遍历cpu,告诉mpentry.S栈的入口在哪,函数lapic_startup向指定处理器发送信号,触发了中断,让处理器从指定地址开始执行。APIC更具体的操作细节我们就不关心了。启动,等待完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Start the non-boot (AP) processors.
static void
boot_aps(void)
{
extern unsigned char mpentry_start[], mpentry_end[];
void *code;
struct CpuInfo *c;

// Write entry code to unused memory at MPENTRY_PADDR
code = KADDR(MPENTRY_PADDR);
memmove(code, mpentry_start, mpentry_end - mpentry_start);

// Boot each AP one at a time
for (c = cpus; c < cpus + ncpu; c++) {
if (c == cpus + cpunum()) // We've started already.
continue;

// Tell mpentry.S what stack to use
mpentry_kstack = percpu_kstacks[c - cpus] + KSTKSIZE;
// Start the CPU at mpentry_start
lapic_startap(c->cpu_id, PADDR(code));
// Wait for the CPU to finish some basic setup in mp_main()
while(c->cpu_status != CPU_STARTED)
;
}
}

mpentry.S中,每一个没有启动的CPU(“AP”)都会因为一个STARTUP中断而启动。AP启动时,CS:IP会被设置成XY00:0000XY是跟着STARTUP一起送过来的值。

代码中设置DS为0,必须从物理地址中的低2^16字节开始运行。

在系统加载的过程中,boot_aps()被调用,然后按照前文所述使用memmove()函数从mpentry.S中拷贝文件中.global mpentry_start标签处开始的入口代码直到.global mpentry_end结束,代码被拷贝到MPENTRY_PADDR(此处是0x7000的I/O hole)对应的内核虚拟地址(别忘了必须拷贝到内核虚拟地址才可以被内核所操作)。然后boot_aps()根据每一个CPU的栈配置percpu_kstacks[]来为每一个AP设置栈地址mpentry_stack。再之后调用lapic_startup()函数来启动AP,并等待AP的状态变为CPU_STARTED以切换到下一个AP的配置。AP启动后会开启分页机制和保护模式,切换运行栈,然后跳转到mp_main()函数。

mp_main()函数中,使用lcr3指令切换页目录到kern_pgdir,初始化LAPIC、用户环境和陷阱处理机制。最后设置struct CpuInfo中的cpu_statusCPU_STARTED来告知BPS已经启动成功。

此代码类似于boot/boot.S,不同之处在于

  • 不需要启用A20
  • 它使用MPBOOTPHYS计算其绝对地址符号,而不是依赖链接器来填充它们
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
.set PROT_MODE_CSEG, 0x8        ## kernel code segment selector
.set PROT_MODE_DSEG, 0x10 ## kernel data segment selector

.code16
.globl mpentry_start
mpentry_start:
cli

xorw %ax, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss

lgdt MPBOOTPHYS(gdtdesc)
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

ljmpl $(PROT_MODE_CSEG), $(MPBOOTPHYS(start32))

.code32
start32:
movw $(PROT_MODE_DSEG), %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
movw $0, %ax
movw %ax, %fs
movw %ax, %gs

## Set up initial page table. We cannot use kern_pgdir yet because
## we are still running at a low EIP.
movl $(RELOC(entry_pgdir)), %eax
movl %eax, %cr3
## Turn on paging.
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0

## Switch to the per-cpu stack allocated in boot_aps()
movl mpentry_kstack, %esp
movl $0x0, %ebp ## nuke frame pointer

## Call mp_main(). (Exercise for the reader: why the indirect call?)
movl $mp_main, %eax
call *%eax

## If mp_main returns (it shouldn't), loop.
spin:
jmp spin

## Bootstrap GDT
.p2align 2 ## force 4 byte alignment
gdt:
SEG_NULL ## null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) ## code seg
SEG(STA_W, 0x0, 0xffffffff) ## data seg

gdtdesc:
.word 0x17 ## sizeof(gdt) - 1
.long MPBOOTPHYS(gdt) ## address gdt

.globl mpentry_end
mpentry_end:
nop

我们修改文件kern/pmap.c中的函数page_init,在构建链表的时候避开AP使用的引导器的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void
page_init(void)
{
// LAB 4:
// Change your code to mark the physical page at MPENTRY_PADDR as in use

pages[0].pp_ref = 1;

size_t mp_page = MPENTRY_PADDR / PGSIZE;
size_t i;
for (i = 1; i < npages_basemem; i++) {
if (i == mp_page) { // lab 4
pages[i].pp_ref = 1;
continue;
}
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}

// 3) Then comes the IO hole [IOPHYSMEM, EXTPHYSMEM), which must never be allocated.
for (i = IOPHYSMEM/PGSIZE; i < EXTPHYSMEM/PGSIZE; i++) {
pages[i].pp_ref = 1;
}

// 4) Then extended memory [EXTPHYSMEM, ...).
size_t first_free_address = PADDR(boot_alloc(0)) / PGSIZE;
for (; i < first_free_address; i++) {
pages[i].pp_ref = 1;
}
for (; i < npages; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}

现在执行make qemu,可以通过check_kern_pgdir()测试,但是不会通过check_kern_pgdir()检查。

kern/mpentry.S是运行在KERNBASE之上的,与其他的内核代码一样。也就是说,类似于mpentry_startmpentry_endstart32这类地址,都位于0xf0000000之上,显然,实模式是无法寻址的。再仔细看MPBOOTPHYS的定义:

1
#define MPBOOTPHYS(s) ((s) - mpentry_start + MPENTRY_PADDR)

其意义可以表示为,从mpentry_startMPENTRY_PADDR建立映射,将mpentry_start + offset地址转为MPENTRY_PADDR + offset地址。查看kern/init.c,发现已经完成了这部分地址的内容拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
static void
boot_aps(void)
{
extern unsigned char mpentry_start[], mpentry_end[];
void *code;
struct CpuInfo *c;

// Write entry code to unused memory at MPENTRY_PADDR
code = KADDR(MPENTRY_PADDR);
memmove(code, mpentry_start, mpentry_end - mpentry_start);
...
}

因此,实模式下就可以通过MPBOOTPHYS宏的转换,运行这部分代码。boot.S中不需要这个转换是因为代码的本来就被加载在实模式可以寻址的地方。

CPU 状态和初始化

当写一个多处理器操作系统时,分清 CPU 的私有状态 ( per-CPU state) 及全局状态 (global state) 非常关键。kern/cpu.h定义了大部分的 per-CPU 状态。我们需要注意的 per-CPU 状态有:

  • Per-CPU 内核栈
    • 因为多 CPU 可能同时陷入内核态,我们需要给每个处理器一个独立的内核栈。percpu_kstacks[NCPU][KSTKSIZE]
    • 在 Lab2 中,我们将BSP的内核栈映射到了KSTACKTOP下方。相似地,在 Lab4 中,我们需要把每个 CPU 的内核栈都映射到这个区域,每个栈之间留下一个空页作为缓冲区避免overflow。CPU 0 ,即BSP的栈还是从KSTACKTOP开始,间隔KSTACKGAP的距离就是 CPU 1 的栈,以此类推。
  • Per-CPU TSS 以及 TSS 描述符
    • 为了指明每个 CPU 的内核栈位置,需要任务状态段 (Task State Segment, TSS),其功能在 Lab3 中已经详细讲过。
  • Per-CPU 当前环境指针
    • 因为每个 CPU 能够同时运行各自的用户进程,我们重新定义了基于cpus[cpunum()]curenv
  • Per-CPU 系统寄存器
    • 所有的寄存器,包括系统寄存器,都是 CPU 私有的。因此,初始化这些寄存器的指令,例如lcr3(),ltr(),lgdt(),lidt()等,必须在每个 CPU 都执行一次。

kern/cpu.h中可以找到对NCPU、CPU状态、CpuInfo以及全局变量percpu_kstacks的声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Maximum number of CPUs
#define NCPU 8
...
enum {
CPU_UNUSED = 0,
CPU_STARTED,
CPU_HALTED,
};
// Per-CPU state
struct CpuInfo {
uint8_t cpu_id; // Local APIC ID; index into cpus[] below
volatile unsigned cpu_status; // The status of the CPU
struct Env *cpu_env; // The currently-running environment.
struct Taskstate cpu_ts; // Used by x86 to find stack for interrupt
};
// Per-CPU kernel stacks
extern unsigned char percpu_kstacks[NCPU][KSTKSIZE];

percpu_kstacks的定义在kern/mpconfig.c中可以找到,以PGSIZE对齐:

1
2
3
// Per-CPU kernel stacks
unsigned char percpu_kstacks[NCPU][KSTKSIZE]
__attribute__ ((aligned(PGSIZE)));

mp_init()是进行初始化的函数,首先设置一个初始cpu,之后对每个CPU进行处理。先通过调用mpconfig()找到struct mpconf然后根据这个结构体内的entries信息对各个CPU结构体进行配置.

如果proc->flagMPPROC_BOOT,说明这个入口对应的处理器是用于启动的处理器,我们把结构体数组cpus[ncpu]地址赋值给bootcpu指针.注意这里ncpu是个全局变量,那么这里实质上就是把cpus数组的第一个元素的地址给了bootcpu.

那个ismp是个全局变量,默认的初始值为0, 但是我们进行mp_init()的时候,就把这个全局变量置为1了,如果出现任何entries匹配错误(switch找不到对应项,跳进default),这个时候我们多可处理器的初始化就失败了,不能用多核处理器进行机器的运行,于是ismp置为0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
void
mp_init(void)
{
struct mp *mp;
struct mpconf *conf;
struct mpproc *proc;
uint8_t *p;
unsigned int i;

bootcpu = &cpus[0];
if ((conf = mpconfig(&mp)) == 0)
return;
ismp = 1;
lapicaddr = conf->lapicaddr;

for (p = conf->entries, i = 0; i < conf->entry; i++) {
switch (*p) {
case MPPROC:
proc = (struct mpproc *)p;
if (proc->flags & MPPROC_BOOT)
bootcpu = &cpus[ncpu];
if (ncpu < NCPU) {
cpus[ncpu].cpu_id = ncpu;
ncpu++;
} else {
cprintf("SMP: too many CPUs, CPU %d disabled\n",
proc->apicid);
}
p += sizeof(struct mpproc);
continue;
case MPBUS:
case MPIOAPIC:
case MPIOINTR:
case MPLINTR:
p += 8;
continue;
default:
cprintf("mpinit: unknown config type %x\n", *p);
ismp = 0;
i = conf->entry;
}
}

bootcpu->cpu_status = CPU_STARTED;
if (!ismp) {
// Didn't like what we found; fall back to no MP.
ncpu = 1;
lapicaddr = 0;
cprintf("SMP: configuration not found, SMP disabled\n");
return;
}
cprintf("SMP: CPU %d found %d CPU(s)\n", bootcpu->cpu_id, ncpu);

if (mp->imcrp) {
// [MP 3.2.6.1] If the hardware implements PIC mode,
// switch to getting interrupts from the LAPIC.
cprintf("SMP: Setting IMCR to switch from PIC mode to symmetric I/O mode\n");
outb(0x22, 0x70); // Select IMCR
outb(0x23, inb(0x23) | 1); // Mask external interrupts.
}
}

处理器同时运行,不能共享一个栈,每个处理器都要有自己的栈。当然,这种区分是在虚拟地址层面上的,不是在物理地址层面上的,不同虚拟地址可以映射到相同物理地址,也可以映射到不同。在这里,我们当然希望能够映射到不同地址上。

主要工作在函数mem_init_mp,这个函数在mem_init初始化完成BSP使用的栈后调用,为各个AP映射栈地址。

讲义和代码注释要求我们给每个栈分配KSTKSIZE大小,中间留出KSTKGAP作为保护,使得一个栈溢出一定不会影响相邻的栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Modify mappings in kern_pgdir to support SMP
// - Map the per-CPU stacks in the region [KSTACKTOP-PTSIZE, KSTACKTOP)
//
static void
mem_init_mp(void)
{
// Map per-CPU stacks starting at KSTACKTOP, for up to 'NCPU' CPUs.
//
// 对每个CPUi,使用percpu_kstacks[i]所代表的物理地址作为内核栈。
// CPU i的内核栈从kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP)向下生长
// 为了避免溢出,还会加上GAP
// Permissions: kernel RW, user NONE
//
// LAB 4: Your code here:

for (int i = 0; i < NCPU; ++i) {
boot_map_region(kern_pgdir,
KSTACKTOP - i * (KSTKSIZE + KSTKGAP) - KSTKSIZE,
KSTKSIZE,
(physaddr_t)PADDR(percpu_kstacks[i]),
PTE_W);
}

// 我看着比较好的一种写法
//uintptr_t start_addr = KSTACKTOP - KSTKSIZE;
// for (size_t i=0; i<NCPU; i++) {
// boot_map_region(kern_pgdir, (uintptr_t) start_addr, KSTKSIZE, PADDR(percpu_kstacks[i]), PTE_W | PTE_P);
// start_addr -= KSTKSIZE + KSTKGAP;
// }
}

各处理器中断初始化

在文件kern/trap.c中函数trap_init_percpu对每个AP的中断进行初始化。上一个Lab留下的版本,不能正确地在多处理器情况下运行。我们需要做一些小更改,让函数正确初始化每个AP的中断。

上一个Lab中,函数trap_init_percpu在函数trap_init中调用,trap_initi386_init中调用。这是给BSP初始化中断。

AP内核入口函数mp_main调用了trap_init_percpu,这是给各个AP初始化中断。在BSP调用的trap_init函数中,中断描述符表已经初始化完成了,在各个AP中也就没比要再做,故没有调用trap_init

只需要将trap_init_percpu的变量ts改为当前处理器的Task State Segment就可以,其它操作和上个Lab相同。需要注意计算出当前处理器的栈的正确地址,不再是KSTACKTOP了。

先注释掉ts,再根据单个cpu的代码做改动。在inc/memlayout.h中可以找到GD_TSS0的定义:

1
#define GD_TSS0   0x28     // Task segment selector for CPU 0

但是并没有其他地方说明其他 CPU 的任务段选择器在哪。因此最大的难点就是找到这个值。实际上,偏移就是cpu_id << 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// static struct Taskstate ts;
...
struct Taskstate* this_ts = &thiscpu->cpu_ts;

// Setup a TSS so that we get the right stack
// when we trap to the kernel.
this_ts->ts_esp0 = KSTACKTOP - thiscpu->cpu_id*(KSTKSIZE + KSTKGAP);
this_ts->ts_ss0 = GD_KD;
this_ts->ts_iomb = sizeof(struct Taskstate);

// Initialize the TSS slot of the gdt.
gdt[(GD_TSS0 >> 3) + thiscpu->cpu_id] = SEG16(STS_T32A, (uint32_t) (this_ts),
sizeof(struct Taskstate) - 1, 0);
gdt[(GD_TSS0 >> 3) + thiscpu->cpu_id].sd_s = 0;

// Load the TSS selector (like other segment selectors, the
// bottom three bits are special; we leave them 0)
ltr(GD_TSS0 + (thiscpu->cpu_id << 3));

// Load the IDT
lidt(&idt_pd);

运行make qemu CPUS=4成功,输出如下提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
6828 decimal is 15254 octal!
Physical memory: 131072K available, base = 640K, extended = 130432K
check_page_free_list() succeeded!
check_page_alloc() succeeded!
check_page() succeeded!
check_kern_pgdir() succeeded!
check_page_free_list() succeeded!
check_page_installed_pgdir() succeeded!
SMP: CPU 0 found 4 CPU(s)
enabled interrupts: 1 2
SMP: CPU 1 starting
SMP: CPU 2 starting
SMP: CPU 3 starting

我们现在的代码在初始化 AP 后就会开始自旋。在进一步操作 AP 之前,我们要先处理几个 CPU 同时运行内核代码的竞争情况。最简单的方法是用一个大内核锁 (big kernel lock)。它是一个全局锁,在某个进程进入内核态时锁定,返回用户态时释放。这种模式下,用户进程可以并发地在 CPU 上运行,但是同一时间仅有一个进程可以在内核态,其他需要进入内核态的进程只能等待。
kern/spinlock.h声明了一个大内核锁kernel_lock。它提供了lock_kernel()unlock_kernel()方法用于获得和释放锁。在以下 4 个地方需要使用到大内核锁:

  • i386_init(),BSP 唤醒其他 CPU 之前获得内核锁
  • mp_main(),初始化 AP 之后获得内核锁,之后调用sched_yield()在 AP 上运行进程。
  • trap(),当从用户态陷入内核态时获得内核锁,通过检查tf_cs的低 2bit 来确定该 trap 是由用户进程还是内核触发。
  • env_run(),在切换回用户模式前释放内核锁。

Exercise 5是在合适的地方调用lock_kernel()unlock_kernel()。在这些函数中,我们需要添加lock/unlocki386_init, mp_main, trap, env_run

i386_init,mp_main函数的lock都发生在初始化完成,准备通过sched_yield进入用户进程之前。这时候加锁,让处理器依次加载用户进程,保证同一时刻只有一个处理器在内核态运行。

其它操作内核锁发生在进入和退出内核态的时候。处理器进入内核态后处在函数trap,故在trap开头加锁,等待其它处理器退出内核态。处理器要进入用户态时放开锁,也就是在env_run的最后,允许其它处理器进入内核态。

其它加锁方式可能更有效率,但比Spin Lock复杂很多。不论如何,这些lock/unlock操作都是为了保证内核只运行在一个处理器上。

kern/init.ci386_init中加锁:

1
2
3
4
5
6
7
8
9
// Lab 4 multitasking initialization functions
pic_init();

// Acquire the big kernel lock before waking up APs
// Your code here:
lock_kernel();

// Starting non-boot CPUs
boot_aps();

kern/init.cmp_main中加锁:

1
2
3
4
5
6
7
8
// Now that we have finished some basic setup, call sched_yield()
// to start running processes on this CPU. But make sure that
// only one CPU can enter the scheduler at a time!
//
// Your code here:
lock_kernel();

sched_yield();

kern/trap.ctrap中加锁:

1
2
3
4
5
6
if ((tf->tf_cs & 3) == 3) {
// Trapped from user mode.
// Acquire the big kernel lock before doing any
// serious kernel work.
// LAB 4: Your code here.
lock_kernel();

kern/env.cenv_run中解锁:

1
2
unlock_kernel();
env_pop_tf(&e->env_tf);

关键要理解两点:

大内核锁的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void
spin_lock(struct spinlock *lk)
{
#ifdef DEBUG_SPINLOCK
if (holding(lk))
panic("CPU %d cannot acquire %s: already holding", cpunum(), lk->name);
#endif

// The xchg is atomic.
// It also serializes, so that reads after acquire are not
// reordered before it.
// 关键代码,体现了循环等待的思想
while (xchg(&lk->locked, 1) != 0)
asm volatile ("pause");

// Record info about lock acquisition for debugging.
#ifdef DEBUG_SPINLOCK
lk->cpu = thiscpu;
get_caller_pcs(lk->pcs);
#endif
}

其中,在inc/x86.h中可以找到xchg()函数的实现,使用它而不是用简单的if + 赋值是因为它是一个原子性的操作。

1
2
3
4
5
6
7
8
9
10
11
12
static inline uint32_t
xchg(volatile uint32_t *addr, uint32_t newval)
{
uint32_t result;

// The + in "+m" denotes a read-modify-write operand.
asm volatile("lock; xchgl %0, %1"
: "+m" (*addr), "=a" (result) // 输出
: "1" (newval) // 输入
: "cc");
return result;
}

lock确保了操作的原子性,其意义是将addr存储的值与newval交换,并返回addr中原本的值。于是,如果最初locked = 0,即未加锁,就能跳出这个while循环。否则就会利用pause命令自旋等待。这就确保了当一个 CPU 获得了 BKL,其他 CPU 如果也要获得就只能自旋等待。

为什么要在这几处加大内核锁

从根本上来讲,其设计的初衷就是保证独立性。由于分页机制的存在,内核以及每个用户进程都有自己的独立空间。而多进程并发的时候,如果两个进程同时陷入内核态,就无法保证独立性了。例如内核中有某个全局变量A,cpu1 让 A=1, 而后 cpu2 却让 A=2,显然会互相影响。最初 Linux 设计者为了使系统尽快支持 SMP,直接在内核入口放了一把大锁,保证其独立性。

其流程大致为:BPS 启动 AP 前,获取内核锁,所以 AP 会在mp_main执行调度之前阻塞,在启动完 AP 后,BPS 执行调度,运行第一个进程,env_run()函数中会释放内核锁,这样一来,其中一个 AP 就可以开始执行调度,运行其他进程。

Q:看起来大内核锁机制保证了同时只能有一个CPU在内核态运行。那为什么我们还需要将每个CPU的内核栈分开?请描述一个场景,我们不分开内核栈而导致错误。

假设CPU0因中断陷入内核并在内核栈中保留了相关的信息,此时若CPU1也发生中断而陷入内核,在同一个内核栈的情况下,CPU0中的信息将会被覆盖从而导致出现错误。

为什么要用不同栈

本标题对应Question 2。如果一次只有一个处理器运行内核,为什么每个处理器都要一个单独的栈?

这是个挺简单的问题。因为并不是真的只有一个处理器运行内核,处理器进入内核态之后才调用lock_kernel,进而抢锁。在中断发生进入trap函数时,这个处理器就已经在使用内核的代码了,只是可能没有运行真正的内核,而是在跑一个while循环,这也还是内核。

要处理这样同时跑内核的情况,自然要多个栈。可以设想,如果只有一个栈,一个处理器正在运行内核,一个处理器发生中断。被中断的处理器压栈,然后等待另一个处理器退出内核。在另一个处理器看来,栈没有变化,接着正常操作,把刚刚压栈的数据覆盖了。

轮询调度

下一个任务是让 JOS 内核能够以轮询方式在多个任务之间切换。其原理如下:

  • kern/sched.c中的sched_yield()函数用来选择一个新的进程运行。它将从上一个运行的进程开始,按顺序循环搜索envs[]数组,选取第一个状态为ENV_RUNNABLE的进程执行。
  • sched_yield()不能同时在两个CPU上运行同一个进程。如果一个进程已经在某个CPU上运行,其状态会变为ENV_RUNNING
  • 程序中已经实现了一个新的系统调用sys_yield(),进程可以用它来唤起内核的sched_yield()函数,从而将 CPU 资源移交给一个其他的进程。

如何找到目前正在运行的进程在envs[]中的序号?在kern/env.h中,可以找到指向struct Env的指针curenv,表示当前正在运行的进程。但是需要注意,不能直接由curenv->env_id得到其序号。在inc/env.h中有一个宏可以完成这个转换。

sched_yield()将找到下一个runable的进程并切换到这个进程上。主要步骤如下:

  • 从当前在running的进程 ( 也就是curenv) 开始 ( 如果curenv不存在,则从数组首部开始查找 ),顺序查找在envs数组 ( in circular fashion,也就是要取模做个环状查找 ),取出首个statusENV_RUNNABLE的进程,并调用env_run()唤醒取出的进程。
  • 如果上述查询中没有找到任何一个ENV_RUNNABLE的进程,则将观测curenv->env_status若其为ENV_RUNNING则继续运行这个进程。
  • 若以上两种情况都没发生. 则自然的停止调度.

这个函数必须阻止同一个进程在两个不同 CPU 上运行的情况 ( 由于正在运行 env 的状态必定是ENV_RUNNING,在前述中不会发生这种事情 )

1
2
// The environment index ENVX(eid) equals the environment's offset in the 'envs[]' array.
#define ENVX(envid) ((envid) & (NENV - 1))

查看kern/env.c可以发现curenv可能为NULL。因此要注意特例。

kern/sched.c中实现轮询调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void
sched_yield(void)
{
struct Env *idle;

idle = NULL;
if (curenv) {
size_t eidx = ENVX(curenv->env_id);
uint32_t mask = NENV - 1;
for (size_t i = (eidx + 1) & mask; i != eidx; i = (i + 1) & mask) {
if (envs[i].env_status == ENV_RUNNABLE) {
idle = &envs[i];
break;
}
}
if (!idle && curenv->env_status == ENV_RUNNING)
idle = curenv;
} else {
for (size_t i = 0; i < NENV; ++i) {
if (envs[i].env_status == ENV_RUNNABLE) {
idle = &envs[i];
break;
}
}
}
if (idle)
env_run(idle);
// sched_halt never returns
sched_halt();
}

kern/syscall.c中添加新的系统调用。

1
2
3
4
5
6
// syscall()
...
case SYS_yield:
sys_yield();
break;
...

kern/init.c中运行的用户进程改为以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// i386_init()
...
#if defined(TEST)
// Don't touch -- used by grading script!
ENV_CREATE(TEST, ENV_TYPE_USER);
#else
// Touch all you want.
ENV_CREATE(user_primes, ENV_TYPE_USER);
#endif // TEST*
ENV_CREATE(user_yield, ENV_TYPE_USER);
ENV_CREATE(user_yield, ENV_TYPE_USER);
ENV_CREATE(user_yield, ENV_TYPE_USER);
...

运行make qemu CPUS=2可以看到三个进程通过调用sys_yield切换了5次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Hello, I am environment 00001000.
Hello, I am environment 00001001.
Back in environment 00001000, iteration 0.
Hello, I am environment 00001002.
Back in environment 00001001, iteration 0.
Back in environment 00001000, iteration 1.
Back in environment 00001002, iteration 0.
Back in environment 00001001, iteration 1.
Back in environment 00001000, iteration 2.
Back in environment 00001002, iteration 1.
Back in environment 00001001, iteration 2.
Back in environment 00001000, iteration 3.
Back in environment 00001002, iteration 2.
Back in environment 00001001, iteration 3.
Back in environment 00001000, iteration 4.
Back in environment 00001002, iteration 3.
All done in environment 00001000.
[00001000] exiting gracefully
[00001000] free env 00001000
Back in environment 00001001, iteration 4.
Back in environment 00001002, iteration 4.
All done in environment 00001001.
All done in environment 00001002.
[00001001] exiting gracefully
[00001001] free env 00001001
[00001002] exiting gracefully
[00001002] free env 00001002
No runnable environments in the system!
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>

Q:我们在env_run()的实现中调用了lcr3()。在这个函数的调用之前以及调用之后,你的代码对env_run()的参数e进行了引用。在加载%cr3寄存器之后,MMU的寻址上下文就改变了(页目录切换了)。为什么我们在页目录改变前后都可以对e进行解引用?

A:在我们lab3实现的过程中,任务的env_pgdir是基于kern_pgdir产生的,也就是说对于UTOP上的地址映射关系在两个页表中是一样的。而e所对应的Env结构由操作系统管理,在虚拟空间地址都是UENVS-UPAGES的范围,因此在所有用户环境的映射也是一样的。

Q:当内核进行用户环境切换的时候,必须要保证旧的环境的寄存器值被保存起来以便之后恢复。这个过程是在哪里发生的?

A:用户环境进行环境切换是通过系统调用syscall(),最终通过kern/trap.c中的trap()产生异常然后陷入内核。因而中断触发会进入trapentry.S的代码入口然后调用trap(),系统会在栈上创建一个Trapframe然后赋给用户环境的env_tf从而保护用户环境寄存器。如下所示代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
_alltraps:
;ds es
push %ds
push %es
pushal #;其余寄存器

#;load DS and ES with GD_KD (不能用立即数设置段寄存器)
mov $GD_KD, %ax
mov %ax, %ds
mov %ax, %es
pushl %esp
call trap

这里我们将环境的现场全部保护起来压栈使得其结构与Trapframe一样,然后调用trap()就可以使得其作为tf被保存。

恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void
env_pop_tf(struct Trapframe *tf)
{
// Record the CPU we are running on for user-space debugging
curenv->env_cpunum = cpunum();

asm volatile(
"\tmovl %0,%%esp\n" // 恢复栈顶指针
"\tpopal\n" // 恢复其他寄存器
"\tpopl %%es\n" // 恢复段寄存器
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret\n"
: : "g" (tf) : "memory");
panic("iret failed"); /* mostly to placate the compiler */
}

系统调用:创建进程

现在我们的内核已经可以运行多个进程,并在其中切换了。不过,现在它仍然只能运行内核最初设定好的程序kern/init.c。现在我们即将实现一个新的系统调用,它允许进程创建并开始新的进程。

Unix 提供了fork()这个原始的系统调用来创建进程。fork()将会拷贝父进程的整个地址空间来创建子进程。在用户空间里,父子进程之间的唯一区别就是它们的进程ID分别为pidppidfork()在父进程中返回其子进程的进程 ID,而在子进程中返回 0。父子进程之间是完全独立的,任意一方修改内存,另一方都不会受到影响。

默认情况下,每一个进程都有其私有的地址空间,而且任意一个进程对于内核的修改都是对于其他进程而言不可见的。

我们将为 JOS 实现一个更原始的系统调用来创建新的进程。涉及到的系统调用如下:

  • sys_exofork:这个系统调用将会创建一个空白进程:在其用户空间中没有映射任何物理内存,并且它是不可运行的。刚开始时,它拥有和父进程相同的寄存器状态。sys_exofork将会在父进程返回其子进程的envid_t,子进程返回 0(当然,由于子进程还无法运行,也无法返回值,直到运行)。由于子环境最初被标记为不可执行,故在子环境中sys_exofork()直到父环境显式标记子环境为可执行,其才会在子环境中返回。
  • sys_env_set_status:设置指定进程的状态为ENV_RUNNABLE或者RUN_NOT_RUNNABLE。这个系统调用通常用于在新进程的地址空间和寄存器初始化完成后,将其标记为可运行。
  • sys_page_alloc:分配一个物理页并将其映射到指定进程的指定虚拟地址上。
  • sys_page_map:从一个进程中拷贝一个页面映射(而非物理页的内容)到另一个。即共享内存。
  • sys_page_unmap:删除到指定进程的指定虚拟地址的映射。

上述所有系统调用集都需要接受一个环境ID,jos的内核支持了环境号0代表当前环境。在kern/env.c中的envid2env()实现了这种映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int
envid2env(envid_t envid, struct Env **env_store, bool checkperm)
{
struct Env *e;

// 如果envid为零,则返回当前环境.
if (envid == 0) {
*env_store = curenv;
return 0;
}

// 通过envid的索引部分查找Env结构,然后检查该结构Env中的env_id字段以确保envid不是陈旧的
e = &envs[ENVX(envid)];
if (e->env_status == ENV_FREE || e->env_id != envid) {
*env_store = 0;
return -E_BAD_ENV;
}

// 检查调用环境是否具有操作指定环境的合法权限。
// 如果设置了 checkperm,则指定的环境必须是当前环境或当前环境的直接子环境。
if (checkperm && e != curenv && e->env_parent_id != curenv->env_id) {
*env_store = 0;
return -E_BAD_ENV;
}

*env_store = e;
return 0;
}

实现上述在kern/syscall.c中的系统调用集,确保syscall()可以调用它们。你可能需要用到kern/pmap.ckern/env.c中的一些函数,尤其是envid2env()

现在你使用envid2env()的时候,将checkperm参数设置为1,确保当你的一些系统调用参数无效的时候会返回-E_INVAL。使用user/dumpfork.c测试你实现的这些系统调用。

sys_exofork()的关键点在于如何让子环境对该系统调用返回0。这个用户态的触发系统调用的函数定义实际上在inc/lib.h中:

1
2
3
4
5
6
7
8
9
10
// This must be inlined. 
static inline envid_t __attribute__((always_inline))
sys_exofork(void)
{
envid_t ret;
asm volatile("int %2"
: "=a" (ret)
: "a" (SYS_exofork), "i" (T_SYSCALL));
return ret;
}

整个过程的控制流如下:父环境显式调用该系统调用,通过内联汇编int %2触发中断,然后硬件控制流通过trapentry.S中的入口地址进行保护现场并将控制流转到trap.c最终进入trap_dispatch():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void
trap_dispatch(struct Trapframe *tf)
{
...
if (tf->tf_trapno == T_SYSCALL)
{
int32_t retval = syscall(tf->tf_regs.reg_eax,
tf->tf_regs.reg_edx,
tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx,
tf->tf_regs.reg_edi,
tf->tf_regs.reg_esi);
if (retval < 0)
panic("[trap_dispatch] syscall : %e\n", retval);
tf->tf_regs.reg_eax = retval;
return;
}
...
}

可见这里系统调用获取返回值的方式是:env_tf中的reg_eax寄存器设置为将系统调用的返回值。然后回到trap中直接通过调用env_run()来返回用户态:

1
2
3
4
5
6
7
8
9
10
// Dispatch based on what type of trap occurred
trap_dispatch(tf); // <- 这里是上面的返回,返回值存在了其tf的reg_eax中

// If we made it to this point, then no other environment was
// scheduled, so we should return to the current environment
// if doing so makes sense.
if (curenv && curenv->env_status == ENV_RUNNING)
env_run(curenv);
else
sched_yield();

而在我们的sys_exofork()执行过程中,只有父环境发生了系统调用,在陷入内核之前保护了用户环境的下一条指令eip,子环境是没有产生中断以及系统调用的,子环境会从其地址空间的eip指定的代码处继续执行(别忘了子环境的寄存器实际上就是拷贝父环境的)。因此整个kern/trap.c以及kern/syscall.c并没有影响子环境,子环境只会等待内核调用sched_yield()开始执行,因此返回值就是我们伪造的eax=0

1
2
3
4
5
6
7
8
9
10
11
12
13
static envid_t
sys_exofork(void)
{
// LAB 4: Your code here.
// panic("sys_exofork not implemented");
struct Env *e;
int r = env_alloc(&e, curenv->env_id);
if (r < 0) return r;
e->env_status = ENV_NOT_RUNNABLE;
e->env_tf = curenv->env_tf;
e->env_tf.tf_regs.reg_eax = 0;
return e->env_id;
}

在该函数中,子进程复制了父进程的trapframe,此后把trapframe中的eax的值设为了0。最后,返回了子进程的id。注意,根据kern/trap.c中的trap_dispatch()函数,这个返回值仅仅是存放在了父进程的trapframe中,还没有返回。而是在返回用户态的时候,即在env_run()中调用env_pop_tf()时,才把trapframe中的值赋值给各个寄存器。这时候lib/syscall.c中的函数syscall()才获得真正的返回值。因此,在这里对子进程trapframe的修改,可以使得子进程返回0。

sys_page_alloc()函数在进程envid的目标地址va分配一个权限为perm的页面。

在做这个之前需要看一下duppage()函数,这是对这个系统调用的测试函数,里边列举了一些可能会出现的Corner case。duppage()函数利用sys_page_alloc()为子进程分配空闲物理页,再使用sys_page_map()将该新物理页映射到内核 (内核的env_id = 0) 的交换区UTEMP,方便在内核态进行memmove拷贝操作。在拷贝结束后,利用sys_page_unmap()将交换区的映射删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
duppage(envid_t dstenv, void *addr)
{
int r;

// This is NOT what you should do in your fork.
if ((r = sys_page_alloc(dstenv, addr, PTE_P|PTE_U|PTE_W)) < 0)
panic("sys_page_alloc: %e", r);
if ((r = sys_page_map(dstenv, addr, 0, UTEMP, PTE_P|PTE_U|PTE_W)) < 0)
panic("sys_page_map: %e", r);
memmove(UTEMP, addr, PGSIZE);
if ((r = sys_page_unmap(0, UTEMP)) < 0)
panic("sys_page_unmap: %e", r);
}

在写之前也要看一下注释里的提示,注意检查权限位,这个看一下位操作就能搞定。分配了page之后要检查是否没有分配成功,检查能否获得正确的env,检查插入page是否成功,完成这些检查并根据返回值返回相应的错误码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// Allocate a page of memory and map it at 'va' with permission
// 'perm' in the address space of 'envid'.
// The page's contents are set to 0.
// If a page is already mapped at 'va', that page is unmapped as a
// side effect.
//
// perm -- PTE_U | PTE_P must be set, PTE_AVAIL | PTE_W may or may not be set,
// but no other bits may be set. See PTE_SYSCALL in inc/mmu.h.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// -E_INVAL if va >= UTOP, or va is not page-aligned.
// -E_INVAL if perm is inappropriate (see above).
// -E_NO_MEM if there's no memory to allocate the new page,
// or to allocate any necessary page tables.

static int
sys_page_alloc(envid_t envid, void *va, int perm)
{
// Hint: This function is a wrapper around page_alloc() and
// page_insert() from kern/pmap.c.
// Most of the new code you write should be to check the
// parameters for correctness.
// If page_insert() fails, remember to free the page you
// allocated!
// LAB 4: Your code here.
// panic("sys_page_alloc not implemented");
if ((~perm & (PTE_U|PTE_P)) != 0)
return -E_INVAL;
if ((perm & (~(PTE_U|PTE_P|PTE_AVAIL|PTE_W))) != 0)
return -E_INVAL;
if ((uintptr_t)va >= UTOP || PGOFF(va) != 0)
return -E_INVAL;

struct PageInfo *page = page_alloc(ALLOC_ZERO);
if (!page)
return -E_NO_MEM;
struct Env *e;
int err = envid2env(envid, &e, 1);
if (err < 0)
return -E_BAD_ENV;
err = page_insert(e->env_pgdir, page, va, perm);
if (err < 0) {
page_free(page);
return -E_NO_MEM;
}
return 0;
}

sys_page_map()函数简单来说,就是建立跨进程的映射。注释中给出了一些需要做的检查,取得src中的页,使用page_insert把这页添加到dst_env->env_pgdir中即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Map the page of memory at 'srcva' in srcenvid's address space
// at 'dstva' in dstenvid's address space with permission 'perm'.
// Perm has the same restrictions as in sys_page_alloc, except
// that it also must not grant write access to a read-only
// page.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if srcenvid and/or dstenvid doesn't currently exist,
// or the caller doesn't have permission to change one of them.
// -E_INVAL if srcva >= UTOP or srcva is not page-aligned,
// or dstva >= UTOP or dstva is not page-aligned.
// -E_INVAL is srcva is not mapped in srcenvid's address space.
// -E_INVAL if perm is inappropriate (see sys_page_alloc).
// -E_INVAL if (perm & PTE_W), but srcva is read-only in srcenvid's
// address space.
// -E_NO_MEM if there's no memory to allocate any necessary page tables.
static int
sys_page_map(envid_t srcenvid, void *srcva,
envid_t dstenvid, void *dstva, int perm)
{
// Hint: This function is a wrapper around page_lookup() and
// page_insert() from kern/pmap.c.
// Again, most of the new code you write should be to check the
// parameters for correctness.
// Use the third argument to page_lookup() to
// check the current permissions on the page.

// LAB 4: Your code here.
// -E_BAD_ENV if srcenvid and/or dstenvid doesn't currently exist,
// or the caller doesn't have permission to change one of them.

if ((uintptr_t)srcva >= UTOP || PGOFF(srcva) != 0 || (uintptr_t)dstva >= UTOP || PGOFF(dstva) != 0)
return -E_INVAL;
if ((perm & PTE_U) == 0 || (perm & PTE_P) == 0 || (perm & ~PTE_SYSCALL) != 0)
return -E_INVAL;
struct Env *src_env, *dst_env;
if (envid2env(srcenvid, &src_env, 1) < 0 || envid2env(dstenvid, &dst_env, 1) < 0)
return -E_BAD_ENV;
pte_t *src_pte;
struct PageInfo *page = page_lookup(src_env->env_pgdir, srcva, &src_pte);
if ( (*src_pte & PTE_W == 0) && (perm & PTE_W == 1))
return -E_INVAL;
if (page_insert(dst_env->env_pgdir, page, dstva, perm) < 0)
return -E_NO_MEM;
return 0;

}

sys_page_unmap()函数取消映射。是对page_remove的封装。

1
2
3
4
5
6
7
8
9
10
static int
sys_page_unmap(envid_t envid, void *va)
{
// LAB 4: Your code here.
if ((uintptr_t)va >= UTOP || PGOFF(va) != 0) return -E_INVAL;
struct Env *e;
if (envid2env(envid, &e, 1) < 0) return -E_BAD_ENV;
page_remove(e->env_pgdir, va);
return 0;
}

sys_env_set_status()函数设置env的状态,在子进程内存map结束后再使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int
sys_env_set_status(envid_t envid, int status)
{
// LAB 4: Your code here.
// panic("sys_env_set_status not implemented");

if (status != ENV_RUNNABLE && status != ENV_NOT_RUNNABLE)
return -E_INVAL;
struct Env *e;
if (envid2env(envid, &e, 1) < 0)
return -E_BAD_ENV;
e->env_status = status;
return 0;
}

Part B: 写时拷贝的 Fork

在 Part A 中,我们通过把父进程的所有内存数据拷贝到子进程实现了fork(),这也是 Unix 系统早期的实现。这个拷贝到过程是fork()时最昂贵的操作。

然而,调用了fork()之后往往立即就会在子进程中调用exec(),将子进程的内存更换为新的程序。这样,复制父进程的内存这个操作就完全浪费了。

因此,后来的 Unix 系统让父、子进程共享同一片物理内存,直到某个进程修改了内存。这被称作 copy-on-write。为了实现它,fork()时内核只拷贝页面的映射关系,而不拷贝其内容,同时将共享的页面标记为只读 (read-only)。当父子进程中任一方向内存中写入数据时,就会触发 page fault。此时,Unix 就知道应该分配一个私有的可写内存给这个进程。

也就是说,这样只有在实际进行修改页面的时候,这个页面的内容才会真正被复制。那么这种机制使得exec()降低了开销:实际上子进程可能只需要在调用exec()之前复制当前栈上的一页。

下面,我们就需要实现一个Unix-like的具有写时复制的fork(),作为用户空间库函数例程。(之所以作为用户库函数而不是内核函数,是为了让内核保持简单,同时能让用户定制自身fork()的实现)

用户级别的页错误处理

内核必须要记录进程不同区域出现页面错误时的处理方法。例如,一个栈区域的page fault会分配并映射一个新的页。一个BSS区域(用于存放程序中未初始化的全局变量、静态变量)的页错误会分配一个新的页面,初始化为0,再映射。
用户级别的页错误处理流程为:

  • 页错误异常,陷入内核
  • 内核修改%esp切换到进程的异常栈,修改%eip让进程运行_pgfault_upcall
  • _pgfault_upcall将运行 page fault handler,此后不通过内核切换回正常栈

用户级的写时复制fork()的第一个关键点,在于能够使得用户发现由写权限问题引发的page fault的能力。

通常来说,是设置一个地址空间用以让page fault来指示发生错误时应该采取哪种行为。比如说,大多数的Unix内核通常会为新的进程的栈区域分配一个页的大小,随着进程栈逐渐增长一直到了未映射分配的区域,就会引发page fault,然后内核再按需分配。这时典型的Unix内核需要追踪进程空间不同区域在发生page fault时应该采取何种措施

  • 栈区发生page fault,则表示需要新的页面的分配和映射;
  • BSS区的page faule,则表示应该分配一个新页面并且填充0再映射;
  • 对于具有可执行文件的系统,text段的page fault则表示从磁盘上的二进制文件中读取页面并映射。

和传统的内存追踪大量信息的方法不同,这个实验中我们需要让用户自己决定如何处理用户空间的每个页面错误(这些bug的损害通常不大)。这种设计方式为用户定义储存区域带来了较强的灵活性,我们之后将会使用用户级别的错误处理程序来进行映射以及访问磁盘系统的文件。

为了能够处理页错误,用户环境必须向jos的内核注册一个页错误处理程序入口(page fault handler entrypoint)。用户环境通过系统调用sys_env_set_pgfault_upcall()来注册错误处理程序入口。实验中已经对于Env结构增加了新的成员env_pgfault_upcall来记录该信息。

实现sys_env_set_pgfault_upcall()系统调用,相当简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Set the page fault upcall for 'envid' by modifying the corresponding struct
// Env's 'env_pgfault_upcall' field. When 'envid' causes a page fault, the
// kernel will push a fault record onto the exception stack, then branch to
// 'func'.
//
// Returns 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
static int
sys_env_set_pgfault_upcall(envid_t envid, void *func)
{
// LAB 4: Your code here.
struct Env * env;
if(envid2env(envid, &env, 1) < 0){
return -E_BAD_ENV;
}
env->env_pgfault_upcall = func;
return 0;
}

进程的正常栈和异常栈

正常运行时,JOS 的进程会运行在正常栈上,ESPUSTACKTOP开始往下生长,栈上的数据存放在[USTACKTOP-PGSIZE, USTACKTOP-1]上。当出现页错误时,内核会把进程在一个新的栈(异常栈)上面重启,运行指定的用户级别页错误处理函数。也就是说完成了一次进程内的栈切换。这个过程与trap的过程很相似。

JOS 的异常栈也只有一个物理页大小,并且它的栈顶定义在虚拟内存UXSTACKTOP处。当用户环境在异常处理栈上运行时,用户级别页错误处理程序可以使用jos系统调用来映射新的页面以解决page fault,最终通过一些汇编代码回到正常栈。

每个需要支持用户级页错误处理的函数都需要分配自己的异常栈。可以使用sys_page_alloc()这个系统调用来实现。

用户页错误处理函数

现在我们需要修改kern/trap.c中的缺页异常处理函数,使其能够按照特定的方式处理用户模式页错误。我们将用户环境发生错误时的状态称为异常状态(trap-time state)。

如果没有注册page fault handler,JOS内核就直接销毁进程。否则,内核就会初始化一个trap frame记录寄存器状态,在异常栈上处理页错误,恢复进程的执行,fault_va是导致页错误发生的虚拟地址。UTrapframe在异常栈栈上如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
                    <-- UXSTACKTOP
trap-time esp
trap-time eflags
trap-time eip
trap-time eax start of struct PushRegs
trap-time ecx
trap-time edx
trap-time ebx
trap-time esp
trap-time ebp
trap-time esi
trap-time edi end of struct PushRegs
tf_err (error code)
fault_va <-- %esp when handler is run

相比trap时使用的Trapframe,多了记录错误位置的fault_va,少了段选择器%cs%ds%ss。这反映了两者最大的不同:是否发生了进程的切换。

如果异常发生时,进程已经在异常栈上运行了,这就说明 page fault handler 本身出现了问题。这时,我们就应该在tf->tf_esp处分配新的栈,而不是在UXSTACKTOP。首先需要 push 一个空的 32bit word (4 bytes,所以要减4)作为占位符,然后是一个UTrapframe结构体。

实现kern/trap.c中的page_fault_handler()。注意上述提及的异常处理栈的机制。

为检查tf->tf_esp是否已经在异常栈上了,只要检查它是否在区间[UXSTACKTOP-PGSIZE, UXSTACKTOP-1]上即可。

再次分析控制流,当用户环境陷入中断时将tf保护起来,这里传递的参数tf实际上就是用户环境的现场。我们在这个系统调用之后需要将控制权还给用户环境,但是需要让用户环境进入页异常处理函数(如果有)并且将栈切换为异常处理栈。也就是说我们需要改变curenvip以及esp。同时我们只需要将tf中保护的现场原样传递给utf即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;

// Read processor's CR2 register to find the faulting address
fault_va = rcr2();

// Handle kernel-mode page faults.

// LAB 3: Your code here.
if((tf->tf_cs & 3) == 0){
panic("[page_fault_handler] Page fault in kernel.\n");
}
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.

// Call the environment's page fault upcall, if one exists. Set up a
// page fault stack frame on the user exception stack (below
// UXSTACKTOP), then branch to curenv->env_pgfault_upcall.
//
// The page fault upcall might cause another page fault, in which case
// we branch to the page fault upcall recursively, pushing another
// page fault stack frame on top of the user exception stack.
//
// It is convenient for our code which returns from a page fault
// (lib/pfentry.S) to have one word of scratch space at the top of the
// trap-time stack; it allows us to more easily restore the eip/esp. In
// the non-recursive case, we don't have to worry about this because
// the top of the regular user stack is free. In the recursive case,
// this means we have to leave an extra word between the current top of
// the exception stack and the new stack frame because the exception
// stack _is_ the trap-time stack.
//
// If there's no page fault upcall, the environment didn't allocate a
// page for its exception stack or can't write to it, or the exception
// stack overflows, then destroy the environment that caused the fault.
// Note that the grade script assumes you will first check for the page
// fault upcall and print the "user fault va" message below if there is
// none. The remaining three checks can be combined into a single test.
//
// Hints:
// user_mem_assert() and env_run() are useful here.
// To change what the user environment runs, modify 'curenv->env_tf'
// (the 'tf' variable points at 'curenv->env_tf').

// LAB 4: Your code here.
struct UTrapframe * utf;
uintptr_t utf_top;
//curenv has pg fault handler
if(curenv -> env_pgfault_upcall){
//esp already in handler (recursive)
if ((tf->tf_esp >= UXSTACKTOP - PGSIZE) && (tf->tf_esp < UXSTACKTOP)){
// recursive exception stack
utf_top = tf->tf_esp - sizeof(struct UTrapframe) - 4;
}
else{
utf_top = UXSTACKTOP - sizeof(struct UTrapframe);
}

//check permission on exception stack
user_mem_assert(curenv, utf_top, sizeof(struct UTrapframe), PTE_W | PTE_U);

//push UTrapframe
utf = (struct UTrapframe *)utf_top;
utf->utf_fault_va = fault_va;
utf->utf_err = tf->tf_err;
utf->utf_regs = tf->tf_regs;
utf->utf_eip = tf->tf_eip;
utf->utf_eflags = tf->tf_eflags;
utf->utf_esp = tf->tf_esp;

//modify stack and ip
(&(curenv->env_tf))->tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
(&(curenv->env_tf))->tf_esp = utf_top;
env_run(curenv);
}
// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}

用户模式页错误入口

在处理完页错误之后,现在我们需要编写汇编语句实现从异常栈到正常栈的切换,该例程将会调用C页面错误处理程序(sys_env_set_pgfault_upcall())。

实现lib/pfentry.S中的_pgfault_upcall例程。这部分最有趣的在于如何返回用户触发page fault的代码。我们在这里将要直接返回而无需再陷入内核。难点在于如何同时切换栈以及重新加载eip

  • _pgfault_upcall():分析其汇编代码逻辑,我们知道它将_pgfault_handler()这个全局函数指针放入eax中并执行,这个全局函数_pgfault_handler()实际上就是我们的C处理页异常的例程(在pgfault.c中定义并且通过用户环境程序显式调用set_pgfault_handler()去定制该处理函数)。

仔细阅读注释,这里有一些坑点:

首先我们要跳转回发生异常的eip时,已经恢复了所有现场(包括esp),这里不能使用jmp进行跳转(因为需要一个目的地址,我们不能用寄存器存了)。而且我们不能直接ret(因为ret会改变esp)。所以我们应该在切换栈之前将异常时的eip装载到异常处理栈的栈顶,切换栈的时候设置esp = esp-4,然后这样使用ret返回时就会取出eip并且返回同时使esp=esp+4。这样做是完全合理的,非嵌套情况下的异常处理栈栈顶之上(更低的地址)是空的,而嵌套情况两个异常处理栈之间会存在32bits的空白空间,因此完全没问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
.text
.globl _pgfault_upcall
_pgfault_upcall:
// 调用用户定义的页错误处理函数
// Call the C page fault handler.
pushl %esp // function argument: pointer to UTF
movl _pgfault_handler, %eax
call *%eax
addl $4, %esp // pop function argument

// LAB 4: Your code here.
movl 48(%esp), %ebp
subl $4, %ebp
movl %ebp, 48(%esp)
movl 40(%esp), %eax
movl %eax, (%ebp)

// Restore the trap-time registers. After you do this, you
// can no longer modify any general-purpose registers.
// LAB 4: Your code here.
// 跳过 utf_err 以及 utf_fault_va
addl $8, %esp
// popal 同时 esp 会增加,执行结束后 %esp 指向 utf_eip
popal

// Restore eflags from the stack. After you do this, you can
// no longer use arithmetic operations or anything else that
// modifies eflags.
// LAB 4: Your code here.
// 跳过 utf_eip
addl $4, %esp
// 恢复 eflags
popfl

// Switch back to the adjusted trap-time stack.
// LAB 4: Your code here.
// 恢复 trap-time 的栈顶
popl %esp
// Return to re-execute the instruction that faulted.
// LAB 4: Your code here.
// ret 指令相当于 popl %eip
ret

首先必须要理解异常栈的结构,下图所示的是嵌套异常时的情况。其中左边表示内容,右边表示地址。需要注意的是,上一次异常的栈顶之下间隔 4byte,就是一个新的异常。

1
2
3
4
5
6
7
8
9
10
                 utf_esp
reserved 32 bit
utf_esp 48(%esp)
utf_eflags 44(%esp)
utf_eip 40(%esp)
utf_regs(end) 36(%esp)
...
utf_regs(start) 8(%esp)
utf_err 4(%esp)
utf_fault_va (%esp)

最难理解的是这一部分:

1
2
3
4
5
movl 48(%esp), %ebp  // 使 %ebp 指向 utf_esp
subl $4, %ebp // %ebp-4
movl %ebp, 48(%esp) // 更新 utf_esp 值为 utf_esp-4
movl 40(%esp), %eax
movl %eax, (%ebp) // 将 utf_esp-4 地址的内容改为 utf_eip

经过这一部分的修改,异常栈更新为:

1
2
3
4
5
6
7
8
9
10
                 utf_esp
utf_eip
utf_esp-4 48(%esp)
utf_eflags 44(%esp)
utf_eip 40(%esp)
utf_regs(end) 36(%esp)
...
utf_regs(start) 8(%esp)
utf_err 4(%esp)
utf_fault_va (%esp)

此后就是恢复各寄存器,最后的ret指令相当于popl %eip,指令寄存器的值修改为utf_eip,达到了返回的效果。

实现set_pgfault_handler()练习是用户用来指定缺页异常处理方式的函数。代码比较简单,但是需要区分清楚handler_pgfault_handler_pgfault_upcall三个变量。

handler是传入的用户自定义页错误处理函数指针。

_pgfault_upcall是一个全局变量,在lib/pfentry.S中完成的初始化。它是页错误处理的总入口,页错误除了运行 page fault handler,还需要切换回正常栈。

_pgfault_handler被赋值为handler,会在_pgfault_upcall中被调用,是页错误处理的一部分。具体代码是:

1
2
3
4
5
6
7
8
.text
.globl _pgfault_upcall
_pgfault_upcall:
// Call the C page fault handler.
pushl %esp // function argument: pointer to UTF
movl _pgfault_handler, %eax
call *%eax
addl $4, %esp

若是第一次调用,需要首先在这个env分配一个页面作为异常栈,并且将该进程的upcall设置为 Exercise 10 中的程序。此后如果需要改变handler,不需要再重复这个工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void
set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
int r;

if (_pgfault_handler == 0) {
// First time through!
// LAB 4: Your code here.
envid_t e_id = sys_getenvid();
r = sys_page_alloc(e_id, (void *)(UXSTACKTOP-PGSIZE), PTE_U | PTE_W | PTE_P);
if (r < 0) {
panic("pgfault_handler: %e", r);
}
// r = sys_env_set_pgfault_upcall(e_id, handler);
r = sys_env_set_pgfault_upcall(e_id, _pgfault_upcall);
if (r < 0) {
panic("pgfault_handler: %e", r);
}
}

// Save handler pointer for assembly to call.
_pgfault_handler = handler;
}

user/faultalloc的部分输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
envs: f0292000, e: f0292000, e->env_id: 1000
env_id, 1000
[00000000] new env 00001000
envs[0].env_status: 2
PAGE FAULT
fault deadbeef
this string was faulted in at deadbeef
PAGE FAULT
fault cafebffe
PAGE FAULT
fault cafec000
this string was faulted in at cafebffe
[00001000] exiting gracefully
[00001000] free env 00001000
envs[0].env_status: 0
envs[1].env_status: 0
envs[0].env_status: 0
envs[1].env_status: 0
No runnable environments in the system!
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.

user/faultallocbad的部分输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
envs: f0292000, e: f0292000, e->env_id: 1000
env_id, 1000
[00000000] new env 00001000
envs[0].env_status: 2
[00001000] user_mem_check assertion failure for va deadbeef
[00001000] free env 00001000
envs[0].env_status: 0
envs[1].env_status: 0
envs[0].env_status: 0
envs[1].env_status: 0
No runnable environments in the system!
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.

可以发现两个程序的输出有所不同,但是两者的page fault handler相同,因为一个使用cprintf()输出,一个使用sys_cput()输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// test user-level fault handler -- alloc pages to fix faults
// faultalloc.c
#include <inc/lib.h>

void
handler(struct UTrapframe *utf)
{
int r;
void *addr = (void*)utf->utf_fault_va;

cprintf("fault %x\n", addr);
if ((r = sys_page_alloc(0, ROUNDDOWN(addr, PGSIZE),
PTE_P|PTE_U|PTE_W)) < 0)
panic("allocating at %x in page fault handler: %e", addr, r);
snprintf((char*) addr, 100, "this string was faulted in at %x", addr);
}

void
umain(int argc, char **argv)
{
set_pgfault_handler(handler);
cprintf("%s\n", (char*)0xDeadBeef);
cprintf("%s\n", (char*)0xCafeBffe);
}


// test user-level fault handler -- alloc pages to fix faults
// doesn't work because we sys_cputs instead of cprintf (exercise: why?)
// faultallocbad.c
#include <inc/lib.h>

void
handler(struct UTrapframe *utf)
{
int r;
void *addr = (void*)utf->utf_fault_va;

cprintf("fault %x\n", addr);
if ((r = sys_page_alloc(0, ROUNDDOWN(addr, PGSIZE),
PTE_P|PTE_U|PTE_W)) < 0)
panic("allocating at %x in page fault handler: %e", addr, r);
snprintf((char*) addr, 100, "this string was faulted in at %x", addr);
}

void
umain(int argc, char **argv)
{
set_pgfault_handler(handler);
sys_cputs((char*)0xDEADBEEF, 4);
}

使用sys_cput()的时候会直接通过lib/syscall.c发起系统调用,其在kern/syscall.c中:

1
2
3
4
5
6
7
8
9
10
11
static void
sys_cputs(const char *s, size_t len)
{
// Check that the user has permission to read memory [s, s+len).
// Destroy the environment if not.

// LAB 3: Your code here.
user_mem_assert(curenv, s, len, PTE_U);
// Print the string supplied by the user.
cprintf("%.*s", len, s);
}

它检查了内存,因此在这里 panic 了。中途没有触发过页错误。

cprintf()的实现可以在lib/printf.c中找到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int
vcprintf(const char *fmt, va_list ap)
{
struct printbuf b;

b.idx = 0;
b.cnt = 0;
vprintfmt((void*)putch, &b, fmt, ap);
sys_cputs(b.buf, b.idx);

return b.cnt;
}

int
cprintf(const char *fmt, ...)
{
va_list ap;
int cnt;

va_start(ap, fmt);
cnt = vcprintf(fmt, ap);
va_end(ap);

return cnt;
}

它在调用sys_cputs()之前,首先在用户态执行了vprintfmt()将要输出的字符串存入结构体b中。在此过程中试图访问0xdeadbeef地址,触发并处理了页错误(其处理方式是在错误位置处分配一个字符串,内容是 “this string was faulted in at …”),因此在继续调用sys_cputs()时不会出现 panic。

实现 Copy-on-Write Fork

现在我们已经具备了在用户空间实现copy-on-write fork()的条件。

现在同样在lib/fork.c中给出了fork()的框架。如同dumbfork()一样,fork()也要创建一个新进程,并且在新进程中建立与父进程同样的内存映射。关键的不同点是,dumbfork()拷贝了物理页的内容,而fork()仅拷贝了映射关系,仅在某个进程需要改写某一页的内容时,才拷贝这一页的内容。其基本流程如下:

  • 父进程使用set_pgfault_handlerpgfault()设为page fault handler
  • 父进程使用sys_exofork()建立一个子进程
  • 对每个在UTOP之下可写页面以及 COW 页面(用PTE_COW标识),父进程调用duppage()将其“映射”到子进程,同时将其权限改为只读,并用PTE_COW位来与一般只读页面区别。
    • 这个函数的作用是把一个页面以写时复制权限PTE_COW映射到子环境,然后再以写时复制权限映射到父进程(这个顺序很重要)。
    • 以写时复制映射:先取消写权限(如果有),然后再加上写时复制权限用以区分普通只读页面。
    • 异常栈的分配方式与此不同,需要在子进程中分配一个新页面。因为page fault handler会实实在在地向异常栈写入内容,并在异常栈上运行。如果异常栈页面都用COW机制,那就没有能够执行拷贝这个过程的载体了
    • fork()同样需要处理PTE_P权限的页面(非可写或写时复制的)
  • 父进程会为子进程设置用户页错误处理入口。
  • 子进程已经就绪,父进程将其设为runnable

进程第一次往一个 COW page 写入内容时,会发生 page fault,其流程为:

  • 内核将 page fault 传递至_pgfault_upcall,它会调用pgfault() handler
  • pgfault()检查错误号(error code)是否为FEC_WR(写操作),即是由于写操作触发了异常。然后检查触发异常的页面是否为写时复制的,如果不是则直接panic内核。
  • pgfault()分配一个新的页面并将 fault page 的内容拷贝进去,然后将旧的映射覆盖,使其以可读可写权限映射到该新页面,并且取代原来的映射。

用户级别的lib/fork.c代码需要查看环境的页表(查询某个页面是否为写时复制),这也是内核将环境的页表映射到UVPT位置的原因(因为用户环境不能访问内核,不具备访问kern_pgdir以及使用pgdir_walk的权限)。所以为了能够让用户环境访问到PTEPDE,jos采取了这种clever mapping trick:UVPT

UVPT

页表的一个很好的概念模型是一个 2^20 条目的数组,它可以通过物理页号进行索引。x86 2 级分页方案通过将巨型页表分成许多页表和一个页目录来打破这个简单的模型。在内核中,我们使用pgdir_walk()通过遍历两级页表来查找条目。以某种方式恢复巨大的简单页表会很好——JOS 中的进程将查看它以弄清楚它们的地址空间中发生了什么。

这个页面描述了 JOS 通过利用分页硬件使用的一个巧妙的技巧——分页硬件非常适合将一组碎片页面放在一个连续的地址空间中。事实证明,我们已经有一个表,其中包含指向所有碎片页表的指针:它是页目录!

因此,我们可以使用页目录作为页表,在虚拟地址空间中某个连续的 2^22 字节范围内映射我们概念上的巨大 2^22 字节页表(由 1024 个页面表示)。我们可以通过将 PDE 条目标记为只读来确保用户进程不能修改他们的页表。

解释一下,每个4GB虚拟地址空间对应一个页目录,一个页目录包含2^10(10bits)个页表,每个页表有 2^10(10bits)页,每页的大小是 2^12B(12bits),最终形成4GB地址空间。

CR3指向页目录,解析一个线性地址,MMU会跟据其PDX,PTX和OFFSET三个部分依次去在页目录(通过目录项找到页表)和页表(通过表项找到页)中进行索引。

但是处理器分不清楚页表、页目录,它只是根据以下过程来进行查找:

1
2
3
pd = lcr3(); 
pt = *(pd+4*PDX);
page = *(pt+4*PTX);

UVPT是页目录中的一个特殊的entry,它指向的是页目录自身。若UVPT的索引值是V,如果我们用一个PDX和PTX都是V的线性地址去进行解析,就会发现由于在页目录中对第V个entry的索引仍然是页目录本身的地址,这个地址最终解析出的就是页目录的物理地址(你可以理解为页目录本身就是一个页表,这种方式下我们连续两次解析到页目录本身的地址)。在JOS中,V=0x3BD,所以UVPD的虚拟地址是(0x3BD << 22)|(0x3BD << 12)

同理,如果PDX为V而PTX不为V,则会解析出各个页表的地址。在JOS中,V=0x3BD,所以UVPT的虚拟地址是(0x3BD << 22)。通过这种方式,用户可以在UVPT内存区中访问到页目录和各个页表。

fork函数

首先从主函数fork()入手,其大体结构可以仿造user/dumbfork.c写,但是有关键几处不同:

  • 设置page fault handler,即page fault upcall调用的函数
  • duppage的范围不同,fork()不需要复制内核区域的映射
  • 为子进程设置page fault upcall,之所以这么做,是因为sys_exofork()并不会复制父进程的e->env_pgfault_upcall给子进程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
envid_t
fork(void)
{
// LAB 4: Your code here.
// panic("fork not implemented");

set_pgfault_handler(pgfault);
envid_t e_id = sys_exofork();
if (e_id < 0) panic("fork: %e", e_id);
if (e_id == 0) {
// child
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}

// parent
// extern unsigned char end[];
// for ((uint8_t *) addr = UTEXT; addr < end; addr += PGSIZE)
for (uintptr_t addr = UTEXT; addr < USTACKTOP; addr += PGSIZE) {
if ( (uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P) ) {
// dup page to child
duppage(e_id, PGNUM(addr));
}
}
// alloc page for exception stack
int r = sys_page_alloc(e_id, (void *)(UXSTACKTOP-PGSIZE), PTE_U | PTE_W | PTE_P);
if (r < 0) panic("fork: %e",r);

// DO NOT FORGET
extern void _pgfault_upcall();
r = sys_env_set_pgfault_upcall(e_id, _pgfault_upcall);
if (r < 0) panic("fork: set upcall for child fail, %e", r);

// mark the child environment runnable
if ((r = sys_env_set_status(e_id, ENV_RUNNABLE)) < 0)
panic("sys_env_set_status: %e", r);

return e_id;
}

duppage()函数

该函数的作用是复制父、子进程的页面映射。尤其注意一个权限问题。由于sys_page_map()页面的权限有硬性要求,因此必须要修正一下权限。之前没有修正导致一直报错,后来发现页面权限为0x865,不符合sys_page_map()要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static int
duppage(envid_t envid, unsigned pn)
{
int r;

// LAB 4: Your code here.
// panic("duppage not implemented");

envid_t this_env_id = sys_getenvid();
void * va = (void *)(pn * PGSIZE);

int perm = uvpt[pn] & 0xFFF;
if ( (perm & PTE_W) || (perm & PTE_COW) ) {
// marked as COW and read-only
perm |= PTE_COW;
perm &= ~PTE_W;
}
// IMPORTANT: adjust permission to the syscall
perm &= PTE_SYSCALL;
// cprintf("fromenvid = %x, toenvid = %x, dup page %d, addr = %08p, perm = %03x\n",this_env_id, envid, pn, va, perm);
if((r = sys_page_map(this_env_id, va, envid, va, perm)) < 0)
panic("duppage: %e",r);
if((r = sys_page_map(this_env_id, va, this_env_id, va, perm)) < 0)
panic("duppage: %e",r);
return 0;
}

pgfault() 函数

这是_pgfault_upcall中调用的页错误处理函数。在调用之前,父子进程的页错误地址都引用同一页物理内存,该函数作用是分配一个物理页面使得两者独立。

首先,它分配一个页面,映射到了交换区PFTEMP这个虚拟地址,然后通过memmove()函数将addr所在页面拷贝至PFTEMP,此时有两个物理页保存了同样的内容。再将addr也映射到PFTEMP对应的物理页,最后解除了PFTEMP的映射,此时就只有addr指向新分配的物理页了,如此就完成了错误处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
static void
pgfault(struct UTrapframe *utf)
{
void *addr = (void *) utf->utf_fault_va;
uint32_t err = utf->utf_err;
int r;

// Check that the faulting access was (1) a write, and (2) to a
// copy-on-write page. If not, panic.
// Hint:
// Use the read-only page table mappings at uvpt
// (see <inc/memlayout.h>).

// LAB 4: Your code here.
if ((err & FEC_WR)==0 || (uvpt[PGNUM(addr)] & PTE_COW)==0) {
panic("pgfault: invalid user trap frame");
}
// Allocate a new page, map it at a temporary location (PFTEMP),
// copy the data from the old page to the new page, then move the new
// page to the old page's address.
// Hint:
// You should make three system calls.

// LAB 4: Your code here.
// panic("pgfault not implemented");
envid_t envid = sys_getenvid();
if ((r = sys_page_alloc(envid, (void *)PFTEMP, PTE_P | PTE_W | PTE_U)) < 0)
panic("pgfault: page allocation failed %e", r);

addr = ROUNDDOWN(addr, PGSIZE);
memmove(PFTEMP, addr, PGSIZE);
if ((r = sys_page_unmap(envid, addr)) < 0)
panic("pgfault: page unmap failed (%e)", r);
if ((r = sys_page_map(envid, PFTEMP, envid, addr, PTE_P | PTE_W |PTE_U)) < 0)
panic("pgfault: page map failed (%e)", r);
if ((r = sys_page_unmap(envid, PFTEMP)) < 0)
panic("pgfault: page unmap failed (%e)", r);
}

Part C: 抢占式多进程处理 & 进程间通信

作为 lab4 的最后一步,我们要修改内核使之能抢占一些不配合的进程占用的资源,以及允许进程之间的通信。

Part I: 时钟中断以及抢占

尝试运行一下 user/spin 测试,该测试建立一个子进程,该子进程获得 CPU 资源后就进入死循环,这样内核以及父进程都无法再次获得 CPU。这显然是操作系统需要避免的。为了允许内核从一个正在运行的进程抢夺 CPU 资源,我们需要支持来自硬件时钟的外部硬件中断。

Interrupt discipline

外部中断用 IRQ(Interrupt Request) 表示。一共有 16 种 IRQ,IRQ的编号到IDT表项的映射并不是固定的。picirq.cpic_init()将会把0-15号IRQ映射到IDT表项中对应的IRQ_OFFSET-IRQ_OFFSET+15

IRQ_OFFSET被定义为32(在inc/trap.h中),那么IDT表项的32-47就对应了15种IRQ,时钟中断是IRQ 0。这样设置不会让处理器异常和IRQ重叠。

Lab3中介绍 x86 的所有异常可以用中断向量 0~31 表示,对应 IDT 的第 0~31 项。例如,页错误产生一个中断向量为 14 的异常。大于 32 的中断向量表示的都是中断

相对 xv6,在 JOS 中我们中了一个关键的简化:在内核态时禁用外部设备中断。外部中断使用%eflag寄存器的FL_IF位控制。当该位置 1 时,开启中断。由于我们的简化,我们只在进入以及离开内核时需要修改这个位。

我们需要确保在用户态时FL_IF置 1,使得当有中断发生时,可以被处理。我们在bootloader的第一条指令cli就关闭了中断,然后再也没有开启过。

exercise13要求我们修改kern/trap.ckern/trapentry.S,来初始化IDT中的入口,为IRQ 0到15提供处理函数。然后修改env_alloc,确保用户环境能够在使能中断时运行。

比较简单,跟 Lab3 中的 Exercise 4 大同小异。相关的常数定义在inc/trap.h中可以找到。

kern/trapentry.S中加入:

1
2
3
4
5
6
7
// IRQs
TRAPHANDLER(handler32, IRQ_OFFSET + IRQ_TIMER)
TRAPHANDLER(handler33, IRQ_OFFSET + IRQ_KBD)
TRAPHANDLER(handler36, IRQ_OFFSET + IRQ_SERIAL)
TRAPHANDLER(handler39, IRQ_OFFSET + IRQ_SPURIOUS)
TRAPHANDLER(handler46, IRQ_OFFSET + IRQ_IDE)
TRAPHANDLER(handler51, IRQ_OFFSET + IRQ_ERROR)

kern/trap.ctrap_init()中加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    // IRQs
void handler32();
void handler33();
void handler36();
void handler39();
void handler46();
void handler51();
...
// IRQs
SETGATE(idt[IRQ_OFFSET + IRQ_TIMER], 0, GD_KT, handler32, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_KBD], 0, GD_KT, handler33, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_SERIAL], 0, GD_KT, handler36, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_SPURIOUS], 0, GD_KT, handler39, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_IDE], 0, GD_KT, handler46, 0);
SETGATE(idt[IRQ_OFFSET + IRQ_ERROR], 0, GD_KT, handler51, 0);

这里需要注意,SETGATEistrap参数需要设置为0。根据SETGATE的注释,两个值的区别在于,设为1就会在开始处理中断时将FL_IF位重新置1,而设为0则保持FL_IF位不变。根据这里的需求,显然应该置0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Set up a normal interrupt/trap gate descriptor.
// - istrap: 1 for a trap (= exception) gate, 0 for an interrupt gate.
// see section 9.6.1.3 of the i386 reference: "The difference between
// an interrupt gate and a trap gate is in the effect on IF (the
// interrupt-enable flag). An interrupt that vectors through an
// interrupt gate resets IF, thereby preventing other interrupts from
// interfering with the current interrupt handler. A subsequent IRET
// instruction restores IF to the value in the EFLAGS image on the
// stack. An interrupt through a trap gate does not change IF."
// - sel: Code segment selector for interrupt/trap handler
// - off: Offset in code segment for interrupt/trap handler
// - dpl: Descriptor Privilege Level -
// the privilege level required for software to invoke
// this interrupt/trap gate explicitly using an int instruction.
#define SETGATE(gate, istrap, sel, off, dpl) \
{ \
(gate).gd_off_15_0 = (uint32_t) (off) & 0xffff; \
(gate).gd_sel = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t) (off) >> 16; \
}

kern/env.cenv_alloc()中加入:

1
2
3
// Enable interrupts while in user mode.
// LAB 4: Your code here.
e->env_tf.tf_eflags |= FL_IF;

Handling Clock Interrupts

user/spin程序中,子进程开启后就陷入死循环,此后 kernel 无法再获得控制权。我们需要让硬件周期性地产生时钟中断,强制将控制权交给 kernel,使得我们能够切换到其他进程。

Exercise 14需要修改trap_dispatch()函数,当时钟中断到达时,执行新的环境。

直接在trap_dispatch()中添加时钟中断的分支即可。

1
2
3
4
5
6
7
8
// Handle clock interrupts. Don't forget to acknowledge the
// interrupt using lapic_eoi() before calling the scheduler!
// LAB 4: Your code here.
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
lapic_eoi();
sched_yield();
return;
}

Part II: 进程间通信(IPC)

在之前的 Lab 中,我们一直在讲操作系统是如何隔离各个进程的,怎么让程序感觉独占一台机器的。操作系统的另一个重要功能就是允许进程之间相互通信。

IPC in JOS

我们将实现两个系统调用:sys_ipc_recv以及sys_ipc_try_send,再将他们封装为两个库函数,ipc_recvipc_send以支持通信。

用户环境可以通过jos系统的IPC机制向其他用户环境发送“消息”(message)。这个消息分为两部分:一个32-bit的值,以及一个可选的单页映射。允许用户环境传递页映射是一种比发送32-bit更有效的数据传递方式(很容易实现共享内存)。

发送和接收消息

进程使用sys_ipc_recv来接收消息。该系统调用会将程序挂起,让出 CPU 资源,直到收到消息。在这个时期,任一进程都能给他发送信息,不限于父子进程。
为了发送信息,进程会调用sys_ipc_try_send,以接收者的进程id以及要发送的值为参数。如果接收者已经调用了sys_ipc_recv,则成功发送消息并返回0。否则返回E_IPC_NOT_RECV表明目标进程并没有接收消息。

用户空间的库函数ipc_recv()将会负责调用sys_ipc_recv(),然后在当前环境的struct Env中查找接收值的信息。相似地,库函数ipc_send()负责重复调用sys_ipc_try_send()直到发送成功。

传递页面

当进程调用sys_ipc_recv并提供一个UTOP以下的合法虚拟地址dstva(必须位于用户空间)时,进程表示它希望能接收一个页面映射。如果发送者发送一个页面,该页面就会被映射到接收者的dstva。同时,之前位于dstva的页面映射会被覆盖。

当进程调用sys_ipc_try_send并提供一个UTOP以下的合法虚拟地址srcva(必须位于用户空间),表明发送者希望发送位于srcva的页面给接收者,权限设置为perm。当IPC成功进行之后,发送方会保持srcva处的映射关系,但是接受方同样也会在其地址dstva处映射这个页面。那此时这个页面就成了发送方到接受方的共享页面。

在一个成功的 IPC 之后,发送者和接受者将共享一个物理页。

注意,如果发送方和接收方之间的任意一方没有声明需要传输的是一个页面,那就不会有页面进行传输。

在任何IPC结束之后,内核将会设置接受方的struct Envenv_ipc_perm成员。如果没有接受页面则设置为0,如果接受页面则设置为接收到的页面权限perm

Implementing IPC

exercise 15实现kern/syscall.c中的sys_ipc_recv()sys_ipc_try_send()

当我们需要在这些有关IPC的例程中调用envid2env()时,需要将参数checkperm设置为0,这意味着任何环境都被允许与其他环境进行IPC。

然后实现lib/ipc.c中的ipc_recv()ipc_send()的封装。

首先需要仔细阅读inc/env.h了解用于传递消息的数据结构。

1
2
3
4
5
6
// Lab 4 IPC
bool env_ipc_recving; // Env is blocked receiving
void *env_ipc_dstva; // VA at which to map received page
uint32_t env_ipc_value; // Data value sent to us
envid_t env_ipc_from; // envid of the sender
int env_ipc_perm; // Perm of page mapping received

然后需要注意的是通信流程。

  1. 调用ipc_recv,设置好Env结构体中的相关field
  2. 调用ipc_send,它会通过envid找到接收进程,并读取Env中刚才设置好的field,进行通信。
  3. 最后返回实际上是在ipc_send中设置好reg_eax,在调用结束,退出内核态时返回。

首先从调用过程入手,这部分比较简单。

lib 部分

ipc_recv中,如果pg不为空,则收到的页会被映射到这里。如果from_env_store不为空,则把sender的envid存到这里。如果系统调用失败了,*fromenv*perm这两个都会被赋值为0。

如果不需要共享页面,则把作为参数的虚拟地址设为UTOP,这个地址在下面的系统调用实现中,会被忽略掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store)
{
// LAB 4: Your code here.
// panic("ipc_recv not implemented");

int r;
if (pg != NULL) {
r = sys_ipc_recv(pg);
} else {
r = sys_ipc_recv((void *) UTOP);
}
if (r < 0) {
// failed
if (from_env_store != NULL) *from_env_store = 0;
if (perm_store != NULL) *perm_store = 0;
return r;
} else {
if (from_env_store != NULL)
*from_env_store = thisenv->env_ipc_from;
if (perm_store != NULL)
*perm_store = thisenv->env_ipc_perm;
return thisenv->env_ipc_value;
}
}

这个函数就是会不停地尝试,在这个while里也要调用sys_yield防止一直占用CPU。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void
ipc_send(envid_t to_env, uint32_t val, void *pg, int perm)
{
// LAB 4: Your code here.
// panic("ipc_send not implemented");

int r;
if (pg == NULL) pg = (void *)UTOP;
do {
r = sys_ipc_try_send(to_env, val, pg, perm);
if (r < 0 && r != -E_IPC_NOT_RECV)
panic("ipc send failed: %e", r);
sys_yield();
} while (r != 0);
}

sys_ipc_recv()首先检查dstva是否合法,这里如果dstva等于UTOP的其实也是合法的,只是不需要去映射地址。然后获取到相应的env对象,设置其ipc数据域,并把当前的env设置成不能运行,直至接收完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 接收
static int
sys_ipc_recv(void *dstva)
{
// LAB 4: Your code here.
// panic("sys_ipc_recv not implemented");

// wrong, because when we don't want to share page, we set dstva=UTOP
// but we can still pass value
// if ( (uintptr_t) dstva >= UTOP) return -E_INVAL;
if ((uintptr_t) dstva < UTOP && PGOFF(dstva) != 0) return -E_INVAL;

envid_t envid = sys_getenvid();
struct Env *e;
// do not check permission
if (envid2env(envid, &e, 0) < 0) return -E_BAD_ENV;

e->env_ipc_recving = true;
e->env_ipc_dstva = dstva;
e->env_status = ENV_NOT_RUNNABLE;
sys_yield();

return 0;
}

sys_ipc_try_send()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// Try to send 'value' to the target env 'envid'.
// If srcva < UTOP, then also send page currently mapped at 'srcva',
// so that receiver gets a duplicate mapping of the same page.
//
// The send fails with a return value of -E_IPC_NOT_RECV if the
// target is not blocked, waiting for an IPC.
//
// The send also can fail for the other reasons listed below.
//
// Otherwise, the send succeeds, and the target's ipc fields are
// updated as follows:
// env_ipc_recving is set to 0 to block future sends;
// env_ipc_from is set to the sending envid;
// env_ipc_value is set to the 'value' parameter;
// env_ipc_perm is set to 'perm' if a page was transferred, 0 otherwise.
// The target environment is marked runnable again, returning 0
// from the paused sys_ipc_recv system call. (Hint: does the
// sys_ipc_recv function ever actually return?)
//
// If the sender wants to send a page but the receiver isn't asking for one,
// then no page mapping is transferred, but no error occurs.
// The ipc only happens when no errors occur.
//
// Returns 0 on success, < 0 on error.
// Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist.
// (No need to check permissions.)
// -E_IPC_NOT_RECV if envid is not currently blocked in sys_ipc_recv,
// or another environment managed to send first.
// -E_INVAL if srcva < UTOP but srcva is not page-aligned.
// -E_INVAL if srcva < UTOP and perm is inappropriate
// (see sys_page_alloc).
// -E_INVAL if srcva < UTOP but srcva is not mapped in the caller's
// address space.
// -E_INVAL if (perm & PTE_W), but srcva is read-only in the
// current environment's address space.
// -E_NO_MEM if there's not enough memory to map srcva in envid's
// address space.
static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)
{
// LAB 4: Your code here.
struct Env * tar_env;
// check target env
if(envid2env(envid, &tar_env, 0) < 0){
return -E_BAD_ENV;
}
// check recver status
if(!tar_env->env_ipc_recving){
return -E_IPC_NOT_RECV;
}

// page send
if((uintptr_t)srcva < UTOP){
//page valid check
if(PGOFF(srcva)){
return -E_INVAL;
}
// perm valid check
if(perm & (~PTE_SYSCALL) || !(perm & PTE_U) || !(perm & PTE_P)){
return -E_INVAL;
}
// page find
pte_t * pte;
struct PageInfo * pginfo;
pginfo = page_lookup(curenv->env_pgdir, srcva, &pte);
if(!pginfo){
return -E_INVAL;
}
// sender & receiver PTE_W
if((perm & PTE_W) && !((*pte) & PTE_W)){
return -E_INVAL;
}
// dst check
if((uintptr_t)(tar_env->env_ipc_dstva) < UTOP){
// insert page map
if(page_insert(tar_env->env_pgdir, pginfo, tar_env->env_ipc_dstva, perm) < 0){
return -E_NO_MEM;
}
// insert success
tar_env->env_ipc_perm = perm;
}
}
tar_env->env_ipc_perm = 0;
tar_env->env_ipc_value = value;
// tar status
tar_env->env_ipc_from = curenv->env_id;
tar_env->env_ipc_recving = 0;
tar_env->env_status = ENV_RUNNABLE;
tar_env->env_tf.tf_regs.reg_eax = 0;
return 0;
}

lab5

Introduction

在这次lab中,您将实现spawn,这是一个加载和运行磁盘可执行文件的库调用。然后,您将充实kernel和库操作系统,以在控制台上运行Shell。

文件系统初步

JOS文件系统设计相比Linux等系统的文件系统如ext2,ext3等,要简化不少。它不支持用户和权限特性,也不支持硬链接,符号链接,时间戳以及特殊设备文件等。

磁盘文件系统结构

大部分Unix文件系统会将磁盘空间分为inode和data两个部分,如linux就是这样的,其中inode用于存储文件的元数据,比如文件类型(常规、目录、符号链接等),权限,文件大小,创建/修改/访问时间,文件数据块信息等,我们运行的ls -l看到的内容,都是存储在inode而不是数据块中的。数据部分通常分为很多数据块,数据块用于存储文件的数据信息以及目录的元数据(目录元数据包括目录下文件的inode,文件名,文件类型等)。

文件和目录在逻辑上都由一系列数据块组成,这些数据块可能分散在整个磁盘中,就像虚拟地址空间的页面可以分散在整个物理内存中一样。文件系统环境隐藏了块布局的细节,提供了用于读取和写入文件内任意偏移量的字节序列的接口。文件系统环境在内部处理对目录的所有修改,作为执行文件创建和删除等操作的一部分。我们的文件系统允许用户环境直接读取目录元数据(例如,使用 read),这意味着用户环境可以自己执行目录扫描操作(例如,实现 ls 程序),而不必依赖到文件系统的特殊系统调用。这种目录扫描方法的缺点,也是大多数现代 UNIX 变体不鼓励它的原因,是它使应用程序依赖于目录元数据的格式,从而很难在不改变或至少改变文件系统的内部布局的情况下重新编译应用程序。

磁盘扇区、数据块

扇区是磁盘的物理属性,通常一个扇区大小为512字节,而数据块则是操作系统使用磁盘的一个逻辑属性,一个块大小通常是扇区的整数倍,在JOS中一个扇区是512Bytes,一个块大小为4KB,跟我们物理内存的页大小一致。UNIX xv6 文件系统使用 512 字节的块大小,与底层磁盘的扇区大小相同。然而,大多数现代文件系统使用更大的块大小,因为存储空间变得更便宜,并且以更大的粒度管理存储更有效。我们的文件系统将使用 4096 字节的块大小,方便地匹配处理器的页面大小。

文件系统实际上以块为单位分配和使用磁盘存储。注意这两个术语之间的区别:扇区大小是磁盘硬件的属性,而块大小是使用磁盘的操作系统的一个方面。文件系统的块大小必须是底层磁盘扇区大小的倍数。

超级块

文件系统通常在磁盘上“易于查找”的位置(例如开头或结尾)保留某些磁盘块,以保存描述整个文件系统属性的元数据,例如块大小、磁盘大小、查找根目录所需的任何元数据、文件系统上次挂载的时间、文件系统上次检查错误的时间等。这些特殊块被称为超级块。

我们的文件系统将只有一个超级块,它始终位于磁盘上的块 1。它的布局由inc/fs.h中的struct Super定义。块 0 通常保留用于保存引导加载程序和分区表,因此文件系统通常不使用第一个磁盘块。许多“真正的”文件系统维护多个超级块,在磁盘的多个间隔很宽的区域中复制,因此如果其中一个被损坏或磁盘在该区域出现介质错误,则仍然可以找到其他超级块并用于访问文件系统。

文件元数据

在我们的文件系统中描述文件的元数据的布局由inc/fs.h中的struct File描述。此元数据包括文件的名称、大小、类型(常规文件或目录)以及指向组成文件的块的指针。如上所述,我们没有inode,因此此元数据存储在磁盘上的目录条目中。与大多数“真实”文件系统不同,为简单起见,我们将使用这种文件结构来表示文件元数据,因为它同时出现在磁盘和内存中。

1
2
3
4
5
struct Super {
uint32_t s_magic; // Magic number: FS_MAGIC
uint32_t s_nblocks; // Total number of blocks on disk
struct File s_root; // Root directory node
};

struct File中的f_direct数组包含空间来存储文件的前 10 个(NDIRECT)块的块号,我们称之为文件的直接块。对于大小不超过10*4096 = 40KB的小文件,这意味着所有文件块的块号将直接适合文件结构本身。然而,对于较大的文件,我们需要一个地方来保存文件的其余块编号。因此,对于任何大于 40KB 的文件,我们分配一个额外的磁盘块,称为文件的间接块,以容纳多达4096/4 = 1024个额外的块号。因此,我们的文件系统允许文件最大为 1034 个块,或略高于 4 兆字节。为了支持更大的文件,“真正的”文件系统通常也支持双重和三重间接块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct File {
char f_name[MAXNAMELEN]; // filename
off_t f_size; // file size in bytes
uint32_t f_type; // file type

// Block pointers.
// A block is allocated iff its value is != 0.
uint32_t f_direct[NDIRECT]; // direct blocks
uint32_t f_indirect; // indirect block

// Pad out to 256 bytes; must do arithmetic in case we're compiling
// fsformat on a 64-bit machine.
uint8_t f_pad[256 - MAXNAMELEN - 8 - 4*NDIRECT - 4];
} __attribute__((packed)); // required only on some 64-bit machines

目录与常规文件

我们文件系统中的 File 结构可以表示一个普通文件或一个目录; 这两种类型的“文件”通过文件结构中的类型字段来区分。文件系统以完全相同的方式管理常规文件和目录文件,除了它根本不解释与常规文件关联的数据块的内容,而文件系统将目录文件的内容解释为一系列 描述目录中的文件和子目录的文件结构。

我们文件系统中的超级块包含一个文件结构(结构 Super 中的根字段),它保存文件系统根目录的元数据。该目录文件的内容是描述位于文件系统根目录中的文件和目录的文件结构序列。根目录中的任何子目录又可能包含更多表示子子目录的文件结构,依此类推。

文件系统

Disk Access

操作系统中的文件系统环境需要能够访问磁盘,但是我们还没有在内核中实现任何磁盘访问功能。我们没有采用传统的“单片”操作系统策略,即在内核中添加IDE磁盘驱动程序以及允许文件系统访问它的必要的系统调用,而是将IDE磁盘驱动程序实现为用户级文件系统环境的一部分。我们仍然需要稍微修改内核,以便进行设置,使文件系统环境具有实现磁盘访问本身所需的特权。

只要我们依赖于polling(轮询)、基于“programmed I/O”(PIO)的磁盘访问,并且不使用磁盘中断,就很容易在用户空间中实现磁盘访问。也可以在用户模式下实现中断驱动的设备驱动程序(例如,L3和L4内核是这样做的),但是难度更大,因为内核必须实现设备中断并将它们分派到正确的用户模式环境中。

x86处理器使用EFLAGS寄存器中的IOPL位来确定是否允许保护模式代码执行特殊的设备I/O指令,比如IN和OUT指令。由于我们需要访问的所有IDE磁盘寄存器都位于x86的I/O空间中,而不是内存映射,所以为了允许文件系统访问这些寄存器,我们只需要给文件系统环境提供“I/O privilege”。实际上,EFLAGS寄存器中的IOPL位为内核提供了一个简单的“all-or-nothing”(全有或全无)方法来控制用户模式代码是否可以访问I/O空间。在我们的示例中,我们希望文件系统environment能够访问I/O空间,但是我们根本不希望任何其他environment能够访问I/O空间。

Exercise 1. i386_init通过将类型ENV_TYPE_FS传递给环境创建函数env_create来标识文件系统环境。在env.c中修改env_create,以便它赋予文件系统environment I/O特权,但永远不要将该特权授予任何其他环境。

这个地方代码还是比较简单的,毕竟之前为用户环境开中断也是设置的eflagsFL_IF位,这里就是设置eflagsIOPL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void
env_create(uint8_t *binary, size_t size, enum EnvType type)
{
// LAB 3: Your code here.
struct Env *env;
int result = env_alloc(&env, 0);
if(result < 0)
panic("env_create: env_alloc error");
load_icode(env, binary, size);
env->env_type = type;
// If this is the file server (type == ENV_TYPE_FS) give it I/O privileges.
// LAB 5: Your code here.
if (type == ENV_TYPE_FS)
env->env_tf.tf_eflags |= FL_IOPL_MASK;
}

Question1.当您随后从一种environment切换到另一种environment时,是否还需要执行其他操作以确保正确保存和恢复此I/O特权设置? 为什么?

answer:这肯定是不用的,每次切换进程都会保存上下文,切回来的时候恢复上下文。

The Block Cache

在我们的文件系统中,我们将借助处理器的虚拟内存系统实现一个简单的“buffer cache”(实际上只是一个block cache)。 block cache的代码在fs/bc.c中。(这里其实就暗示了文件系统读写的单位是一个block而不是扇区)。

我们的文件系统将仅限于处理3GB或更小的磁盘。我们保留文件系统environment的地址空间的一个大的、固定的3GB区域,从0x10000000 (DISKMAP)到0xD0000000(DISKMAP+DISKMAX),作为磁盘的“内存映射”版本。例如,disk block 0映射到虚拟地址0x10000000,disk block 1映射到虚拟地址0x10001000(一个块4KB),以此类推。fs/bc.c中的diskaddr函数实现了从 disk block numbers到虚拟地址的转换(以及一些完整性(sanity)检查)。

由于我们的文件系统environment具有自己的虚拟地址空间,而与系统中其他environment的虚拟地址空间无关,并且文件系统environment唯一需要做的就是实现文件访问,因此我们以这种方式保留大多数文件系统environment的地址空间。 由于现代磁盘大于3GB,因此在32位计算机上执行真实文件系统会很尴尬。 在具有64位地址空间的机器上,这种buffer cache管理方法仍然是合理的。

当然,将整个磁盘读取到内存中要花很长时间,所以我们以请求分页(demand paging)的形式实现,其中我们只在磁盘映射区域分配页和从磁盘读取相应的块来响应一个在这个地区发生的页面错误。

ide_read()的单位是扇区,不是磁盘块,通过outb指令设置读取的扇区数,通过insl指令读取磁盘数据到对应的虚拟地址addr处。bc_pgfault中分配了一页物理页,然后从磁盘中读取出错的addr那一块数据(8个扇区)到分配的物理页中,然后清除分配页的dirty标记,最后调用block_is_free检查对应磁盘块确保磁盘块已经分配。注意这里检查磁盘块是否已经分配要在最后检查,是因为bitmap的值是在fs_init时指定的为diskaddr(2),即0x10002000,在准备读取第二个磁盘块发生页错误进入bgfault时,此时bitmap对应块还没有从磁盘读取并映射好,所以要在最后检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int
ide_read(uint32_t secno, void *dst, size_t nsecs)
{
int r;

assert(nsecs <= 256);

ide_wait_ready(0);

outb(0x1F2, nsecs);
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, 0xE0 | ((diskno&1)<<4) | ((secno>>24)&0x0F));
outb(0x1F7, 0x20); // CMD 0x20 means read sector

for (; nsecs > 0; nsecs--, dst += SECTSIZE) {
if ((r = ide_wait_ready(1)) < 0)
return r;
insl(0x1F0, dst, SECTSIZE/4);
}

return 0;
}

fs/fs.c中的fs_init函数是如何使用block cache的一个主要示例。在初始化块缓存之后,它简单将指向块缓存的指针存储到super全局变量中的磁盘映射区域。在这之后,我们可以简单地从super structure中读取,就像它们在内存中一样,并且我们的页面错误处理程序将根据需要从磁盘中读取它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Initialize the file system
void
fs_init(void)
{
static_assert(sizeof(struct File) == 256);

// Find a JOS disk. Use the second IDE disk (number 1) if available.
if (ide_probe_disk1())
ide_set_disk(1);
else
ide_set_disk(0);

bc_init();

// Set "super" to point to the super block.
super = diskaddr(1);
check_super();
}

Exercise 2. 在fs/bc.c中实现bc_pgfaultflush_block函数。bc_pgfault是一个页面错误处理程序,就像您在上一个lab中为copy-on-write fork的写的页面处理程序一样,不同之处在于bc_pgfault的工作是响应页面错误从磁盘加载页面。 编写此代码时,请记住,addr可能未与block对齐,并且ide_read在扇区而不是block中操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Fault any disk block that is read in to memory by
// loading it from disk.只说从disk又不说disk哪个扇区
static void
bc_pgfault(struct UTrapframe *utf)
{
void *addr = (void *) utf->utf_fault_va;
uint32_t blockno = ((uint32_t)addr - DISKMAP) / BLKSIZE;
int r;

// Check that the fault was within the block cache region
if (addr < (void*)DISKMAP || addr >= (void*)(DISKMAP + DISKSIZE))
panic("page fault in FS: eip %08x, va %08x, err %04x",
utf->utf_eip, addr, utf->utf_err);

// Sanity check the block number.
if (super && blockno >= super->s_nblocks)
panic("reading non-existent block %08x\n", blockno);

// Allocate a page in the disk map region, read the contents
// of the block from the disk into that page.
// Hint: first round addr to page boundary. fs/ide.c has code to read
// the disk.
//
// LAB 5: you code here:
addr = (void *)ROUNDDOWN(addr, BLKSIZE);
if((r=sys_page_alloc(0, addr, PTE_P | PTE_U | PTE_W))<0)
panic("in bc_pgfault,out of memory: %e", r);
if((r=ide_read(blockno*8, addr, BLKSECTS))<0)
panic("in bc_pgfault, ide_read: %e", r);
// Clear the dirty bit for the disk block page since we just read the
// block from disk
if ((r = sys_page_map(0, addr, 0, addr, uvpt[PGNUM(addr)] & PTE_SYSCALL)) < 0)
panic("in bc_pgfault, sys_page_map: %e", r);

// Check that the block we read was allocated. (exercise for
// the reader: why do we do this *after* reading the block
// in?)
if (bitmap && block_is_free(blockno))
panic("reading free block %08x\n", blockno);
}

flush_block()函数用于在写入磁盘数据到块缓存后,调用ide_write()写入块缓存数据到磁盘中。写入完成后,也要通过sys_page_map()清除块缓存的dirty标记(每次写入物理页的时候,处理器会自动标记该页为dirty,即设置PTE_D标记)。注意,在flush_block()中,如果该地址并没有映射或者并没有dirty,则不需要做任何事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Flush the contents of the block containing VA out to disk if
// necessary, then clear the PTE_D bit using sys_page_map.
// If the block is not in the block cache or is not dirty, does
// nothing.
// Hint: Use va_is_mapped, va_is_dirty, and ide_write.
// Hint: Use the PTE_SYSCALL constant when calling sys_page_map.
// Hint: Don't forget to round addr down.

void
flush_block(void *addr)
{
uint32_t blockno = ((uint32_t)addr - DISKMAP) / BLKSIZE;

if (addr < (void*)DISKMAP || addr >= (void*)(DISKMAP + DISKSIZE))
panic("flush_block of bad va %08x", addr);

int r;
// LAB 5: Your code here.
addr = ROUNDDOWN(addr, BLKSIZE);
if (va_is_mapped(addr) && va_is_dirty(addr)) {
ide_write(blockno*BLKSECTS, addr, BLKSECTS);
if ((r = sys_page_map(0, addr, 0, addr, uvpt[PGNUM(addr)]&PTE_SYSCALL)) < 0)
panic("in flush_block, sys_page_map: %e", r);
}
}

bc.c中的bc_init用于完成块缓存初始化,它完成下面几件事:

  1. 设置页错误处理函数为bc_pgfault
  2. 调用check_bc()。
  3. 读取磁盘块1的数据到函数局部变量super对应的地址中。
1
2
3
4
5
6
7
8
9
void
bc_init(void)
{
struct Super super;
set_pgfault_handler(bc_pgfault);

// cache the super block by reading it once
memmove(&super, diskaddr(1), sizeof super);
}

块位图

fs_init设置bitmap指针后,可以认为bitmap就是一个位数组,每个块占据一位。可以通过block_is_free检查块位图中的对应块是否空闲,如果为1表示空闲,为0已经使用。JOS中第0,1,2块分别给bootloadersuperblock以及bitmap使用了。此外,因为在文件系统中加入了user目录和fs目录的文件,导致JOS文件系统一共用掉了0-110这111个文件块,下一个空闲文件块从111开始。

使用free_block作为模型在fs/fs.c中实现alloc_block。它应该在位图中找到一个空闲磁盘块,标记它该磁盘块已被使用,并返回该磁盘块号。当您分配一个块时,您应该立即使用flush_block将更改后的位图块刷新到磁盘,以保持文件系统的一致性。

使用位图标记一个块是否被使用过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Mark a block free in the bitmap
void
free_block(uint32_t blockno)
{
// Blockno zero is the null pointer of block numbers.

// 0 块启动块
if (blockno == 0)
panic("attempt to free zero block");
bitmap[blockno/32] |= 1<<(blockno%32);
}

// Search the bitmap for a free block and allocate it. When you
// allocate a block, immediately flush the changed bitmap block
// to disk.
//
// Return block number allocated on success,
// -E_NO_DISK if we are out of blocks.
int
alloc_block(void)
{
// The bitmap consists of one or more blocks. A single bitmap block
// contains the in-use bits for BLKBITSIZE blocks. There are
// super->s_nblocks blocks in the disk altogether.

// LAB 5: Your code here.
size_t i;
for(i=1; i < super->s_nblocks; i++) {
if (block_is_free(i)) {
bitmap[i/32] &= ~(1<<(i%32));
// 或者
// bitmap[blockno/32] ^= 1<<(blockno%32);
flush_block(&bitmap[i/32]);
return i;
}
}
// panic("alloc_block not implemented");

return -E_NO_DISK;
}

文件操作

fs/fs.c中有很多文件操作相关的函数,这里的主要几个结构体要说明下:

  • struct File用于存储文件元数据,前面提到过。
  • struct Fd用于文件模拟层,类似文件描述符,如文件ID,文件打开模式,文件偏移都存储在Fd中。一个进程同时最多打开 MAXFD(32) 个文件。

文件系统进程还维护了一个打开文件的描述符表,即opentab数组,数组元素为struct OpenFileOpenFile结构体用于存储打开文件信息,包括文件IDstruct File以及struct Fd。JOS同时打开的文件数一共为 MAXOPEN(1024) 个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct OpenFile {                                                              
uint32_t o_fileid; // file id
struct File *o_file; // mapped descriptor for open file
int o_mode; // open mode
struct Fd *o_fd; // Fd page
};

struct Fd {
int fd_dev_id;
off_t fd_offset;
int fd_omode;
union {
// File server files
struct FdFile fd_file;
};
};

文件操作函数如下:

1
file_block_walk(struct File *f, uint32_t filebno, uint32_t **ppdiskbno, bool alloc)

这个函数是查找文件第filebno块的数据块的地址,查到的地址存储在ppdiskbno中。注意这里要检查间接块,如果alloc为1且寻址的块号>=NDIRECT,而间接块没有分配的话需要分配一个间接块。

1
file_get_block(struct File *f, uint32_t filebno, char **blk)

查找文件第filebno块的块地址,并将块地址在虚拟内存中映射的地址存储在blk中(即将diskaddr(blockno)存到blk中)。

1
dir_lookup(struct File *dir, const char *name, struct File **file)

在目录dir中查找名为name的文件,如果找到了设置*file为找到的文件。因为目录的数据块存储的是struct File列表,可以据此来查找文件。

1
file_open(const char *path, struct File **pf)

打开文件,设置*pf为查找到的文件指针。

1
file_create(const char *path, struct File *pf)

创建路径/文件,在pf存储创建好的文件指针。

1
file_read(struct File *f, void *buf, size_t count, off_t offset)

从文件的offset处开始读取count个字节到buf中,返回实际读取的字节数。

1
file_write(struct File *f, const void *buf, size_t count, off_t offset)

从文件offset处开始写入buf中的count字节,返回实际写入的字节数。

1
file_truncate_blocks(struct File *f, off_t newsize);

将文件设置为缩小后的新大小,清空那些被释放的物理块。

Exercise 4:实现file_block_walk函数和file_get_block函数。
  
回答:file_block_walk函数寻找一个文件结构f中的第fileno个块指向的磁盘块编号放入ppdiskbno

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static int
file_block_walk(struct File *f, uint32_t filebno, uint32_t **ppdiskbno, bool alloc)
{
int r;

if (filebno >= NDIRECT + NINDIRECT)
return -E_INVAL;
if (filebno < NDIRECT) {
if (ppdiskbno)
*ppdiskbno = f->f_direct + filebno;
//把f->f_direct第filebno个槽的地址给它
return 0;
}
if (!alloc && !f->f_indirect)
return -E_NOT_FOUND;
if (!f->f_indirect) {
if ((r = alloc_block()) < 0)
return -E_NO_DISK;
f->f_indirect = r;
memset(diskaddr(r), 0, BLKSIZE);
flush_block(diskaddr(r));
//每次对磁盘映射区域的块修改后都应该刷新回磁盘
}
//捋一下,现在我们要的是存着f第filebno块块号的那个槽的地址
//即f->f_indirect与f->f_direct都是存着块号,而*ppdiskbno要的是存着块号的那个槽的地址

if (ppdiskbno)
*ppdiskbno = (uint32_t*)diskaddr(f->f_indirect) + filebno - NDIRECT;
return 0;
}

file_get_block函数先调用file_walk_block函数找到文件中的目标块,然后将其转换为地址空间中的地址赋值给blk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int
file_get_block(struct File *f, uint32_t filebno, char **blk)
{
// LAB 5: Your code here.
int r;
uint32_t *ppdiskbno;

if ((r = file_block_walk(f, filebno, &ppdiskbno, 1)) < 0)
return r;
//ppdiskbno是f的第filebno块的块号所在的槽的地址
//blk要的是这个块映射到内存里的地址

if (*ppdiskbno == 0) {
//就算是直接块也是有可能还未分配
if ((r = alloc_block()) < 0)
return -E_NO_DISK;
*ppdiskbno = r;
memset(diskaddr(r), 0, BLKSIZE);
flush_block(diskaddr(r));
//每次对磁盘映射区域的块修改后都应该刷新回磁盘
}
*blk = diskaddr(*ppdiskbno);
return 0;
}

The file system interface

既然我们已经在文件系统environment本身中拥有了必要的功能,那么我们必须让希望使用文件系统的其他environment也可以访问它。由于其他environment不能直接调用文件系统environment中的函数,所以我们将通过构建在JOS IPC机制之上的remote procedure call(远程过程调用)或者RPC、抽象来公开对文件系统环境的访问。从图形上看,下面是其他environment对 the file system server (比如read)的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
	  Regular env           FS env
+---------------+ +---------------+
| read | | file_read |
| (lib/fd.c) | | (fs/fs.c) |
...|.......|.......|...|.......^.......|...............
| v | | | | RPC mechanism
| devfile_read | | serve_read |
| (lib/file.c) | | (fs/serv.c) |
| | | | ^ |
| v | | | |
| fsipc | | serve |
| (lib/file.c) | | (fs/serv.c) |
| | | | ^ |
| v | | | |
| ipc_send | | ipc_recv |
| | | | ^ |
+-------|-------+ +-------|-------+
| |
+--------->---------+

在虚线下的部分是普通进程如何发送一个读请求到文件系统服务进程的机制。首先read操作文件描述符,分发给合适的设备读函数devfile_readdevfile_read函数实现读取磁盘文件,作为客户端文件操作函数。然后建立请求结构的参数,调用fsipc函数来发送IPC请求并解析返回的结果。

文件系统服务端的代码在fs/serv.c中,服务进程在serve函数中循环,循环等待直到收到1个IPC请求。然后分发给合适的处理函数,最后通过IPC发回结果。对于读请求,服务端会分发给serve_read函数

在JOS实现的IPC机制中,允许进程发送1个32位数和1个页。为了实现发送1个请求从客户端到服务端,我们使用32位数来表示请求类型,存储参数在联合Fsipc位于共享页中。在客户端我们一直共享fsipcbuf所在页,在服务端我们映射请求页到fsreq地址(0x0ffff000)。

服务端也会通过IPC发送结果。我们使用32位数作为函数的返回码。FSREQ_READFSREQ_STAT函数也会返回数据,它们将数据写入共享页返回给客户端。不需要在响应 IPC 中发送此页面,因为客户端首先与文件系统服务器共享它。 此外,在其响应中,FSREQ_OPEN与客户端共享一个新的“Fd页面”。 我们将很快返回到文件描述符页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
union Fsipc {
struct Fsreq_open {
char req_path[MAXPATHLEN];
int req_omode;
} open;
struct Fsreq_set_size {
int req_fileid;
off_t req_size;
} set_size;
struct Fsreq_read {
int req_fileid;
size_t req_n;
} read;
struct Fsret_read {
char ret_buf[PGSIZE];
} readRet;
struct Fsreq_write {
int req_fileid;
size_t req_n;
char req_buf[PGSIZE - (sizeof(int) + sizeof(size_t))];
} write;
struct Fsreq_stat {
int req_fileid;
} stat;
struct Fsret_stat {
char ret_name[MAXNAMELEN];
off_t ret_size;
int ret_isdir;
} statRet;
struct Fsreq_flush {
int req_fileid;
} flush;
struct Fsreq_remove {
char req_path[MAXPATHLEN];
} remove;

// Ensure Fsipc is one page
char _pad[PGSIZE];
};

这里需要了解一下union Fsipc,文件系统中客户端和服务端通过IPC进行通信,通信的数据格式就是union Fsipc,它里面的每一个成员对应一种文件系统的操作请求。每次客户端发来请求,都会将参数放入一个union Fsipc映射的物理页到服务端。同时服务端还会将处理后的结果放入到Fsipc内,传递给客户端。文件服务端进行的地址空间布局如下:

OpenFile结构是服务端进程维护的一个映射,它将一个真实文件struct File和用户客户端打开的文件描述符struct Fd对应到一起。每个被打开文件对应的struct Fd都被映射到FILEEVA(0xd0000000)往上的1个物理页,服务端和打开这个文件的客户端进程共享这个物理页。客户端进程和文件系统服务端通信时使用0_fileid来指定要操作的文件。

1
2
3
4
5
6
struct OpenFile {
uint32_t o_fileid; // file id
struct File *o_file; // mapped descriptor for open file
int o_mode; // open mode
struct Fd *o_fd; // Fd page
};

文件系统默认最大同时可以打开的文件个数为1024,所以有1024个strcut Openfile,对应着服务端进程地址空间0xd0000000往上的1024个物理页,用于映射这些对应的struct Fd

struct Fd是1个抽象层,JOS和Linux一样,所有的IO都是文件,所以用户看到的都是Fd代表的文件。但是Fd会记录其对应的具体对象,比如真实文件、Socket和管道等等。现在只用文件,所以union中只有1个FdFile

1
2
3
4
5
6
7
8
9
struct Fd {
int fd_dev_id;
off_t fd_offset;
int fd_omode;
union {
// File server files
struct FdFile fd_file;
};
};

Exercise 5.在fs/servlet.c中实现serve_readserve_read的繁重工作将由fs/fs.c中已经实现的file_read来完成(反过来,它只是对file_get_block的一系列调用)。serve_read只需要提供用于文件读取的RPC接口。查看serve_set_size中的注释和代码,了解应该如何构造server函数。

做这个得弄清楚这些概念:

  • regular进程访问文件的整个流程。
  • 在IPC通信过程中,fsipcbuf(客户端)与fsreq(服务端)共享页面。
  • 保存着open file基本信息的Fd page页面(在内存空间0xD0000000以上)
  • 服务端的私有结构体OpenFile
  • 设备结构体dev,设备有三种,devfile、devpipe、devcons
  • OpenFile->o_fileidOpenFile->o_fd->fd_file.id以及Fsipc->read->req_fileid的关系!

devfile_read()里,fsipcbuf.read.req_fileid = fd->fd_file.id;这是客户端根据在0xD0000000以上的第fdnumfd pagefd->fd_file.id告诉服务器端要读的是id为这个的文件。

serve_open()里,o->o_fd->fd_file.id = o->o_fileid;这是服务器端将open file与它的Fd page对应起来。

首先来看一下整个read的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//inc/fd.h
struct Fd {
int fd_dev_id;
off_t fd_offset;
int fd_omode;
union {
// File server files
// 这应该就是目标文件id,在客户端赋值给了fsipcbuf.read.req_fileid
struct FdFile fd_file; //struct FdFile {int id; };
};
};

//fs/serv.c
struct OpenFile {
//This memory is kept private to the file server.
uint32_t o_fileid; // file id。 The client uses file IDs to communicate with the server.
struct File *o_file; // mapped descriptor for open file应该是打开的那个文件的file pointer
int o_mode; // open mode
struct Fd *o_fd; // Fd page是一个专门记录着这个open file的基本信息的页面
};

//inc/fs.h
struct File {
char f_name[MAXNAMELEN]; // filename
off_t f_size; // file size in bytes
uint32_t f_type; // file type

// Block pointers.
// A block is allocated iff its value is != 0.
// 这里存的是块号还是块的地址?
uint32_t f_direct[NDIRECT]; // direct blocks
uint32_t f_indirect; // indirect block

// Pad out to 256 bytes; must do arithmetic in case we're compiling
// fsformat on a 64-bit machine.
// 扩展到256字节;必须做算术,以防我们在64位机器上编译fsformat。
uint8_t f_pad[256 - MAXNAMELEN - 8 - 4*NDIRECT - 4];
} __attribute__((packed)); // required only on some 64-bit machines

lib/fd.c/read()根据fdnum在内存空间0xD0000000以上找到一个struct Fd页面命名为fd,页面内保存着一个open file的基本信息。然后根据fd内的fd_dev_id找到对应设备dev,很明显这里是devfile,然后调用(*devfile->dev_read)(fd, buf, n)。该函数返回读到的字节总数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ssize_t read(int fdnum, void *buf, size_t n)
{
int r;
struct Dev *dev;
struct Fd *fd;

if ((r = fd_lookup(fdnum, &fd)) < 0
|| (r = dev_lookup(fd->fd_dev_id, &dev)) < 0)
return r;
if ((fd->fd_omode & O_ACCMODE) == O_WRONLY) {
cprintf("[%08x] read %d -- bad mode\n", thisenv->env_id, fdnum);
return -E_INVAL;
}
if (!dev->dev_read)
return -E_NOT_SUPP;
return (*dev->dev_read)(fd, buf, n);
}

lib/file.c/devfile_read()通过IPC共享的页面上的union Fsipc中存储请求的参数。在客户端,我们总是在fsipcbuf共享页面。设置好fsipcbuf的参数,调用fsipc去向服务器端发送read请求。请求成功后结果也是保存在共享页面fsipcbuf中,然后读到指定的buf就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static ssize_t devfile_read(struct Fd *fd, void *buf, size_t n)
{
// Make an FSREQ_READ request to the file system server after
// filling fsipcbuf.read with the request arguments. The
// bytes read will be written back to fsipcbuf by the file
// system server.
int r;

fsipcbuf.read.req_fileid = fd->fd_file.id;
//这个id就是指的当前位置?current position?
fsipcbuf.read.req_n = n;
if ((r = fsipc(FSREQ_READ, NULL)) < 0)
return r;
assert(r <= n);
assert(r <= PGSIZE);
memmove(buf, fsipcbuf.readRet.ret_buf, r);
return r;
}

lib/file.c/fsipc()这个函数就是负责跟文件系统server进程间通信的。发送请求并接受结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int fsipc(unsigned type, void *dstva)
{
static envid_t fsenv;
if (fsenv == 0)
fsenv = ipc_find_env(ENV_TYPE_FS);

static_assert(sizeof(fsipcbuf) == PGSIZE);

if (debug)
cprintf("[%08x] fsipc %d %08x\n", thisenv->env_id, type, *(uint32_t *)&fsipcbuf);

ipc_send(fsenv, type, &fsipcbuf, PTE_P | PTE_W | PTE_U);
return ipc_recv(NULL, dstva, NULL);
}

fs/serv.c/serve()ipc_recv的返回值是32位字env_ipc_value,即fsipcipc_send过来的type,根据这个type判断进入哪个处理函数,这里很明显type==FSREQ_READ

  1. 从IPC接受1个请求类型req以及数据页fsreq
  2. 然后根据req来执行相应的服务端处理函数
  3. 将相应服务端函数的执行结果(如果产生了数据也则有pg)通过IPC发送回调用进程
  4. 将映射好的物理页fsreq取消映射
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void serve(void)
{
uint32_t req, whom;
int perm, r;
void *pg;

while (1) {
perm = 0;
req = ipc_recv((int32_t *) &whom, fsreq, &perm);

// All requests must contain an argument page
if (!(perm & PTE_P)) {
cprintf("Invalid request from %08x: no argument page\n",
whom);
continue; // just leave it hanging...
}

pg = NULL;
if (req == FSREQ_OPEN) {
r = serve_open(whom, (struct Fsreq_open*)fsreq, &pg, &perm);
} else if (req < ARRAY_SIZE(handlers) && handlers[req]) {
r = handlers[req](whom, fsreq);
} else {
cprintf("Invalid request code %d from %08x\n", req, whom);
r = -E_INVAL;
}
ipc_send(whom, r, pg, perm);
sys_page_unmap(0, fsreq);
}
}

服务端函数定义在handler数组,通过请求号进行调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef int (*fshandler)(envid_t envid, union Fsipc *req);

fshandler handlers[] = {
// Open is handled specially because it passes pages
/* [FSREQ_OPEN] = (fshandler)serve_open, */
[FSREQ_READ] = serve_read,
[FSREQ_STAT] = serve_stat,
[FSREQ_FLUSH] = (fshandler)serve_flush,
[FSREQ_WRITE] = (fshandler)serve_write,
[FSREQ_SET_SIZE] = (fshandler)serve_set_size,
[FSREQ_SYNC] = serve_sync
};
#define NHANDLERS (sizeof(handlers)/sizeof(handlers[0]))

这个结构体定义了一些函数指针,做题的时候需要注意。

1
2
3
4
5
6
7
8
9
struct Dev devfile =
{
.dev_id = 'f',
.dev_name = "file",
.dev_read = devfile_read,
.dev_close = devfile_flush,
.dev_stat = devfile_stat,
.dev_write = devfile_write,
};

对于读文件请求,调用serve_read函数来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int
serve_read(envid_t envid, union Fsipc *ipc)
{
struct Fsreq_read *req = &ipc->read;
struct Fsret_read *ret = &ipc->readRet;

if (debug)
cprintf("serve_read %08x %08x %08x\n", envid, req->req_fileid, req->req_n);

struct OpenFile *o;
int r, req_n;

if ((r = openfile_lookup(envid, req->req_fileid, &o)) < 0)
return r;
req_n = req->req_n > PGSIZE ? PGSIZE : req->req_n;
if ((r = file_read(o->o_file, ret->ret_buf, req_n, o->o_fd->fd_offset)) < 0)
return r;
o->o_fd->fd_offset += r;

return r;
}

先从Fsipc中获取读请求的结构体,然后在openfile中查找fileid对应的Openfile结构体,紧接着从openfile长相的o_file中读取内容到保存返回结果的ret_buf中,并移动文件偏移指针。

然后我们可以看一下用户进程发送读取请求的函数devfile_read,主要操作是封装Fsipc设置请求类型为FSREQ_READ,在接受到返回后,将返回结果拷贝到自己的buf中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static ssize_t
devfile_read(struct Fd *fd, void *buf, size_t n)
{
int r;

fsipcbuf.read.req_fileid = fd->fd_file.id;
fsipcbuf.read.req_n = n;
if ((r = fsipc(FSREQ_READ, NULL)) < 0)
return r;
assert(r <= n);
assert(r <= PGSIZE);
memmove(buf, fsipcbuf.readRet.ret_buf, r);
return r;
}

Exercise 6.在fs/server.c中实现serve_write,在lib/file.c中实现devfile_write。实现与read请求类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// fs/serv.c
int
serve_write(envid_t envid, struct Fsreq_write *req)
{
if (debug)
cprintf("serve_write %08x %08x %08x\n", envid, req->req_fileid, req->req_n);

struct OpenFile *o;
int r, req_n;

if ((r = openfile_lookup(envid, req->req_fileid, &o)) < 0)
return r;
req_n = req->req_n > PGSIZE ? PGSIZE : req->req_n;
if ((r = file_write(o->o_file, req->req_buf, req_n, o->o_fd->fd_offset)) < 0)
return r;
o->o_fd->fd_offset += r;

return r;
}

// lib/file.c
static ssize_t
devfile_write(struct Fd *fd, const void *buf, size_t n)
{
int r;

if (n > sizeof(fsipcbuf.write.req_buf))
n = sizeof(fsipcbuf.write.req_buf);
fsipcbuf.write.req_fileid = fd->fd_file.id;
fsipcbuf.write.req_n = n;
memmove(fsipcbuf.write.req_buf, buf, n);
if ((r = fsipc(FSREQ_WRITE, NULL)) < 0)
return r;

return r;
}

Spawning Processes(衍生程序,派生程序)

我们已经给出了spawn的代码(参见lib/spawn.c),它创建一个新环境,从文件系统加载一个程序映像到其中,然后启动运行这个程序的子环境。然后父进程继续独立于子进程运行。spawn函数的作用类似于UNIX中的fork,然后在子进程中立即执行exec。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// Spawn a child process from a program image loaded from the file system.
// prog: the pathname of the program to run.
// argv: pointer to null-terminated array of pointers to strings,
// which will be passed to the child as its command-line arguments.
// Returns child envid on success, < 0 on failure.
int
spawn(const char *prog, const char **argv)
{
unsigned char elf_buf[512];
struct Trapframe child_tf;
envid_t child;

int fd, i, r;
struct Elf *elf;
struct Proghdr *ph;
int perm;

// This code follows this procedure:
//
// - Open the program file.
//
// - Read the ELF header, as you have before, and sanity check its
// magic number. (Check out your load_icode!)
//
// - Use sys_exofork() to create a new environment.
//
// - Set child_tf to an initial struct Trapframe for the child.
//
// - Call the init_stack() function above to set up
// the initial stack page for the child environment.
//
// - Map all of the program's segments that are of p_type
// ELF_PROG_LOAD into the new environment's address space.
// Use the p_flags field in the Proghdr for each segment
// to determine how to map the segment:
//
// * If the ELF flags do not include ELF_PROG_FLAG_WRITE,
// then the segment contains text and read-only data.
// Use read_map() to read the contents of this segment,
// and map the pages it returns directly into the child
// so that multiple instances of the same program
// will share the same copy of the program text.
// Be sure to map the program text read-only in the child.
// Read_map is like read but returns a pointer to the data in
// *blk rather than copying the data into another buffer.
//
// * If the ELF segment flags DO include ELF_PROG_FLAG_WRITE,
// then the segment contains read/write data and bss.
// As with load_icode() in Lab 3, such an ELF segment
// occupies p_memsz bytes in memory, but only the FIRST
// p_filesz bytes of the segment are actually loaded
// from the executable file - you must clear the rest to zero.
// For each page to be mapped for a read/write segment,
// allocate a page in the parent temporarily at UTEMP,
// read() the appropriate portion of the file into that page
// and/or use memset() to zero non-loaded portions.
// (You can avoid calling memset(), if you like, if
// page_alloc() returns zeroed pages already.)
// Then insert the page mapping into the child.
// Look at init_stack() for inspiration.
// Be sure you understand why you can't use read_map() here.
//
// Note: None of the segment addresses or lengths above
// are guaranteed to be page-aligned, so you must deal with
// these non-page-aligned values appropriately.
// The ELF linker does, however, guarantee that no two segments
// will overlap on the same page; and it guarantees that
// PGOFF(ph->p_offset) == PGOFF(ph->p_va).
//
// - Call sys_env_set_trapframe(child, &child_tf) to set up the
// correct initial eip and esp values in the child.
//
// - Start the child process running with sys_env_set_status().
if ((r = open(prog, O_RDONLY)) < 0)
return r;
fd = r;

// Read elf header
elf = (struct Elf*) elf_buf;
if (readn(fd, elf_buf, sizeof(elf_buf)) != sizeof(elf_buf) || elf->e_magic != ELF_MAGIC) {
close(fd);
cprintf("elf magic %08x want %08x\n", elf->e_magic, ELF_MAGIC);
return -E_NOT_EXEC;
}

// Create new child environment
if ((r = sys_exofork()) < 0)
return r;
child = r;

// Set up trap frame, including initial stack.
child_tf = envs[ENVX(child)].env_tf;
child_tf.tf_eip = elf->e_entry;

if ((r = init_stack(child, argv, &(child_tf.tf_esp))) < 0)
return r;

// Set up program segments as defined in ELF header.
ph = (struct Proghdr*) (elf_buf + elf->e_phoff);
for (i = 0; i < elf->e_phnum; i++, ph++) {
if (ph->p_type != ELF_PROG_LOAD)
continue;
perm = PTE_P | PTE_U;
if (ph->p_flags & ELF_PROG_FLAG_WRITE)
perm |= PTE_W;
if ((r = map_segment(child, ph->p_va, ph->p_memsz, fd, ph->p_filesz, ph->p_offset, perm)) < 0)
goto error;
}
close(fd);
fd = -1;

// Copy shared library state.
if ((r = copy_shared_pages(child)) < 0)
panic("copy_shared_pages: %e", r);

if ((r = sys_env_set_trapframe(child, &child_tf)) < 0)
panic("sys_env_set_trapframe: %e", r);

if ((r = sys_env_set_status(child, ENV_RUNNABLE)) < 0)
panic("sys_env_set_status: %e", r);

return child;

error:
sys_env_destroy(child);
close(fd);
return r;
}

我们实现了spawn而不是unix风格的exec,因为spawn更容易从用户空间以“exokernel fashion”(一种方式)实现,而不需要内核的特殊帮助。考虑一下要在用户空间中实现exec需要做些什么,并确保您理解为什么这么做更难些。

Exercise 7. 依赖于新的系统调用sys_env_set_trapframe来初始化新创建环境的状态的spawn。在kern/syscall.c中实现sys_env_set_trapframe(不要忘记在syscall()中添加新的系统调用的分派)。

sys_env_set_trapframe函数实现简单,主要是用来拷贝父进程的寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

static int
sys_env_set_trapframe(envid_t envid, struct Trapframe *tf)
{
struct Env *e;
int r;

if ((r = envid2env(envid, &e, true)) < 0)
return -E_BAD_ENV;
user_mem_assert(e, tf, sizeof(struct Trapframe), PTE_U);
e->env_tf = *tf;
e->env_tf.tf_cs |= 3;
e->env_tf.tf_eflags |= FL_IF;

return 0;
}

Sharing library state across fork and spawn

UNIX文件描述符是一个通用的概念,它还包括pipes, console I/O等。在JOS中,每种设备类型都有一个对应的struct Dev,带有指向为该类型实现的read/write等函数的指针。lib/fd.c在此基础上实现了通用的类unix文件描述符接口。每个struct Fd都指示其设备类型,lib/fd.c中的大多数函数只是简单地将操作分派给适当struct Dev中的函数。

lib/fd.c还在每个应用程序环境的地址空间中维护从FDTABLE(0xD0000000)开始的 file descriptor table region。在这个区域每个struct Fd都保留着一个页。在任何给定时间,只有在使用相应的文件描述符时才映射特定的文件描述符表页。每个文件描述符在从FILEDATA开始的区域中都有一个可选的“data page”,设备可以使用这些“data page”。

我们希望跨forkspawn共享文件描述符状态,但是文件描述符状态保存在用户空间内存中。而且在fork时,内存将被标记为copy-on-write,因此状态将被复制而不是共享。(这意味着环境无法在自己没有打开的文件中进行查找,而且管道也不能跨fork工作)。在spawn时,内存将被留在后面,根本不复制。(实际上,派生的环境一开始没有打开的文件描述符)

我们将更改fork,以确定“library operating system”使用的内存区域应该总是共享的。我们将在页表条目中设置一个未使用的位,而不是在某个地方hard-code(硬编码)一个区域列表(就像我们在fork中使用PTE_COW位一样)。

我们在inc/lib.h中定义了一个新的PTE_SHARE位。这个位是三个PTE位之一,在 Intel and AMD manuals中被标记为“available for software use”。我们将建立这样一个约定:如果页表条目设置了这个位,那么PTE应该在forkspawn时从父环境直接复制到子环境。注意,这与标记为copy-on-write不同:如第一段所述,我们希望确保共享页面的更新。

Exercise 8:改变duppage函数实现上述变化,如果页表入口有设置PTE_SHARE位,那么直接拷贝映射。类似地,实现copy_shared_pages函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static int
duppage(envid_t envid, unsigned pn)
{

int r;

void *addr;
pte_t pte;
int perm;

addr = (void *)((uint32_t)pn * PGSIZE);
pte = uvpt[pn];
if (pte & PTE_SHARE) {
if ((r = sys_page_map(sys_getenvid(), addr, envid, addr, pte & PTE_SYSCALL)) < 0) {

panic("duppage: page mapping failed %e", r);
return r;
}
}
else {
perm = PTE_P | PTE_U;
if ((pte & PTE_W) || (pte & PTE_COW))
perm |= PTE_COW;
if ((r = sys_page_map(thisenv->env_id, addr, envid, addr, perm)) < 0) {
panic("duppage: page remapping failed %e", r);
return r;
}
if (perm & PTE_COW) {
if ((r = sys_page_map(thisenv->env_id, addr, thisenv->env_id, addr, perm)) < 0) {
panic("duppage: page remapping failed %e", r);
return r;
}
}
}
return 0;
}

copy_shared_pages应该循环遍历当前进程中的所有页表条目(就像fork所做的那样),将设置了PTE_SHARE位的任何页映射复制到子进程中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int
copy_shared_pages(envid_t child)
{
// LAB 5: Your code here.
int r,i;
for (i = 0; i < PGNUM(USTACKTOP); i ++){
// uvpd、uvpt应该是个全局数组变量,
// 但是数组元素对应的pde、pte具体是什么应该取决于lcr3设置的是哪个环境的内存空间
if((uvpd[i/1024] & PTE_P) && (uvpt[i] & PTE_P) && (uvpt[i] & PTE_SHARE)){
//i跟pte一一对应,而i/1024就是该pte所在的页表
if ((r = sys_page_map(0, PGADDR(i/1024, i%1024, 0), child, PGADDR(i/1024, i%1024, 0), uvpt[i] & PTE_SYSCALL)) < 0)
return r;
}
}
return 0;
}

要让shell工作,我们需要一种方法来键入它。QEMU一直在显示我们写入到CGA显示器和串行端口的输出,但到目前为止,我们只在内核监视器中接受输入。在QEMU中,在图形化窗口中键入的输入显示为从键盘到JOS的输入,而在控制台中键入的输入显示为串行端口上的字符。kern/console.c已经包含了自lab 1以来内核监视器一直使用的键盘和串行驱动程序,但是现在您需要将它们附加到系统的其他部分。

Exercise 9. 在你的kern/trap.c,调用kbd_intr处理trap IRQ_OFFSET+IRQ_KBD,调用serial_intr处理trap IRQ_OFFSET+IRQ_SERIAL

我们在lib/console.c中为您实现了控制台输入/输出文件类型。kbd_intrserial_intr用最近读取的输入填充缓冲区,而控制台文件类型耗尽缓冲区(控制台文件类型默认用于stdin/stdout,除非用户重定向它们)。

1
2
3
4
5
6
7
8
9
//kern/trap.c/trap_dispatch()
if (tf->tf_trapno == IRQ_OFFSET + IRQ_KBD){
kbd_intr();
return;
}
else if (tf->tf_trapno == IRQ_OFFSET + IRQ_SERIAL){
serial_intr();
return;
}

稍微看一下这两个函数。kbd_proc_data()是从键盘读入character就返回,如果没输入就返回-1

1
2
3
4
5
6
7
8
9
void kbd_intr(void){
cons_intr(kbd_proc_data);
}

//serial_proc_data()很明显就是从串行端口读一个data
void serial_intr(void){
if (serial_exists)
cons_intr(serial_proc_data);
}

两个函数都调用下边这个cons_intr,这个函数就是从键盘读入的一行填充到cons.buf。而cons如下:

1
2
3
4
5
static struct {
uint8_t buf[CONSBUFSIZE];
uint32_t rpos;
uint32_t wpos;
} cons;

当函数指针所接收到的数据不为-1时,把收到的数据加入到buf中。

1
2
3
4
5
6
7
8
9
10
11
12
static void cons_intr(int (*proc)(void))
{
int c;

while ((c = (*proc)()) != -1) {
if (c == 0)
continue;
cons.buf[cons.wpos++] = c;
if (cons.wpos == CONSBUFSIZE)
cons.wpos = 0;
}
}

kbd_intr有关的是下边这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/*
* Get data from the keyboard. If we finish a character, return it. Else 0.
* Return -1 if no data.
*/
static int
kbd_proc_data(void)
{
int c;
uint8_t data;
static uint32_t shift;

if ((inb(KBSTATP) & KBS_DIB) == 0)
return -1;

data = inb(KBDATAP);

if (data == 0xE0) {
// E0 escape character
shift |= E0ESC;
return 0;
} else if (data & 0x80) {
// Key released
data = (shift & E0ESC ? data : data & 0x7F);
shift &= ~(shiftcode[data] | E0ESC);
return 0;
} else if (shift & E0ESC) {
// Last character was an E0 escape; or with 0x80
data |= 0x80;
shift &= ~E0ESC;
}

shift |= shiftcode[data];
shift ^= togglecode[data];

c = charcode[shift & (CTL | SHIFT)][data];
if (shift & CAPSLOCK) {
if ('a' <= c && c <= 'z')
c += 'A' - 'a';
else if ('A' <= c && c <= 'Z')
c += 'a' - 'A';
}

// Process special keys
// Ctrl-Alt-Del: reboot
if (!(~shift & (CTL | ALT)) && c == KEY_DEL) {
cprintf("Rebooting!\n");
outb(0x92, 0x3); // courtesy of Chris Frost
}

return c;
}

The Shell

运行make run-icode或者make run-icode-nox。这将运行内核并启动user/icodeicode执行init,它将把控制台设置为文件描述符0和1(标准输入和标准输出)。然后它会spawn sh,也就是shell。你应该能够运行以下命令:

1
2
3
4
5
echo hello world | cat
cat lorem |cat
cat lorem |num
cat lorem |num |num |num |num |num
lsfd

注意,用户库例程cprintf直接打印到控制台,而不使用文件描述符代码。这对于调试非常有用,但是对于piping into other programs却不是很有用。要将输出打印到特定的文件描述符(例如,1,标准输出),请使用fprintf(1, “…”, …)。 printf(“…”, …)是打印到FD 1的捷径。有关示例,请参见user/lsfd.c。

Exercise 10. shell不支持I/O重定向。如果能运行sh <script就更好,而不是像上面那样手工输入script中的所有命令。将<的I/O重定向添加到user/sh.c。通过在shell中键入sh <script测试您的实现

运行make run-testshell来测试您的shelltestshell只是将上面的命令(也可以在fs/testshell.sh中找到)提供给shell,然后检查输出是否匹配fs/testshell.key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
case '<':	// Input redirection
// Grab the filename from the argument list
if (gettoken(0, &t) != 'w') {
cprintf("syntax error: < not followed by word\n");
exit();
}
// LAB 5: Your code here.
if ((fd = open(t, O_RDONLY)) < 0) {
cprintf("open %s for read: %e", t, fd);
exit();
}
if (fd != 0) {
dup(fd, 0); //应该是让文件描述符0也作为fd对应的那个open file的struct Fd页面
close(fd);
}
//panic("< redirection not implemented");
break;

为什么好多函数的envid_t参数总是设成0?在envid2env()函数中有这样如下定义。所以设成0就e就默认是curenv

1
2
3
4
5
// If envid is zero, return the current environment.
if (envid == 0) {
*env_store = curenv;
return 0;
}

Challenge的要求即为清空掉所有的没有被访问的页面。那么对于单个页面,只需要调用flush_block(),之后通过系统调用unmap就可以了。evict_policy()即对于所有的block做一个便利,清除所有从未被访问过的页面。具体代码内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// challenge
void
evict_block(void *addr){
uint32_t blockno = ((uint32_t)addr - DISKMAP) / BLKSIZE;
if(addr < (void*)DISKMAP || addr >= (void*)(DISKMAP + DISKSIZE))
panic("evict_block of bad va %08x", addr);

int r;
addr = ROUNDDOWN(addr, BLKSIZE);
flush_block(addr);
if((r = sys_page_unmap(0, addr)) < 0)
panic("in evict block, sys_page_unmap: %e", r);
}

void
evict_policy(){
uint32_t blockno;
for(blockno = 3; blockno < DISKSIZE / BLKSIZE; ++blockno){
if(!(uvpt[PGNUM(diskaddr(blockno))]&PTE_A)){
evict_block(diskaddr(blockno));
}
}
}

lab6

Introduction

我们已经实现了1个文件系统,当然OS还需要1个网络栈,在本次实验中我们将实现1个网卡驱动,这个网卡基于Intel 82540EM芯片,也就是熟知的E1000网卡。

网卡驱动不足以使你的OS能连接上Internet。在LAB6新增加的代码中,我们提供了1个网络栈(network stack)和网络服务器(network server)在net/目录和kern/目录下。

本次新增加的文件如下:

  • net/lwip目录:开源轻量级TCP/IP协议组件包括1个网络栈
  • net/timer.c:定时器功能测试程序
  • net/ns.h:网卡驱动相关的参数宏定义和函数声明
  • net/testinput.c:收包功能测试程序
  • net/input.c:收包功能的用户态函数
  • net/testoutput.c:发包功能测试程序
  • net/output.c:发包功能的用户态函数
  • net/serv.c:网络服务器的实现
  • kern/e1000.c:网卡驱动的内核实现
  • kern/e1000.h:网卡驱动实现相关的参数宏定义和函数声明

除了实现网卡驱动,我们还要实现1个系统调用接口来访问驱动。我们需要实现网络服务器代码来传输网络数据包在网络栈和驱动之间。同时网络服务器也能使用文件系统中的文件。

大部分内核驱动代码必须从零开始编写,这次实验比前面的实验提供更少的指导:没有骨架文件、没有系统调用接口等。总之一句话,要实现这次实验需要阅读很多提供的指导说明手册,才能完成实验。

QEMU’s virtual network

我们将会使用QEMU用户态网络栈,因为它运行不需要管理员权限。

在默认情况下,QEMU会提供一个运行在IP为10.0.2.2的虚拟路由器并且分配给JOS一个10.0.2.15的IP地址。为了简单起见,我们把这些默认设置硬编码在了net/ns.h中。

尽管QEMU的虚拟网络允许JOS和互联网做任意的连接,但是JOS的10.0.2.15 IP地址在QEMU运行的虚拟网络之外没有任何意义(QEMU就像一个NAT),所以我们不能直接和JOS中运行的se服务器连接,即使是运行QEMU的宿主机上也不行。为了解决这个问题,我们通过配置QEMU,让JOS的一些端口和宿主机的某些端口相连,让服务器运行在这些端口上,从而让数据在宿主机和虚拟网络之间进行交换。

我们将在端口7(echo)和80(http)运行端口。为了避免端口冲突,makefile里实现了端口转发。可以通过运行make which-ports来找出QEMU转发的端口,也可以通过make nc-7make nc-80来和运行在这些端口上的服务器交互。

Packet Inspection

makefile也配置了QEMU的网络栈来记录各种进入和出去的数据包到qemu.pcap文件中。为了获得hex/ASCII的转换,我们可以使用tcpdump命令(Linux下非常有用的网络抓包分析工具,具体的参数说明可以用man tcpdump):

1
tcpdump -XXnr qemu.pcap

Debugging the E1000  

很幸运我们使用的是模拟硬件,E1000网卡运行为软件,模拟的E1000网卡能以用户可读的形式,向我们汇报有用的信息,比如内部状态和问题。

模拟E1000网卡能产生一系列debug输出,通过打开特殊的日志通道,来捕获输出信息:

Flag Meaning
tx Log packet transmit operations
txerr Log transmit ring errors
rx Log changes to RCTL
rxfilter Log filtering of incoming packets
rxerr Log receive ring errors
unknown Log reads and writes of unknown registers
eeprom Log reads from the EEPROM
interrupt Log interrupts and changes to interrupt registers.

The Network Server

从零开始写1个网络栈是很难的。这里,我们使用lwIP开源TCP/IP协议组件来实现网络栈。在这个实验中,我们只需知道lwIP是一个黑盒,它实现了BSD的socket接口并且有一个数据包input port和数据包output port。

网络服务器其实是由以下四个environments组成的

  1. 核心网络服务 environment(包括socket调用分发和lwIP
  2. 输入environment
  3. 输出environment
  4. 计时environment

下图显示了各个environments以及它们之间的关系。图中展示了整个系统包括设备驱动。在本次实验中,我们将实现被标记为绿色的那些部分。

其实整个网络服务器实现与文件系统的实现类似,也是通过IPC机制来在各个environment之间进行数据交互。

本次实验中QEMU因为不是MIT修改过的版本,所以改为:

1
/home/yuhao/6.828/qemu/i386-softmmu/qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::26000 -D qemu.log -smp 1 -drive file=obj/fs/fs.img,index=1,media=disk,format=raw -netdev user,id=u1 -device e1000,netdev=u1 -nic user,hostfwd=tcp::26001-:7 -nic user,hostfwd=tcp::26002-:80 -nic user,hostfwd=udp::26001-:7 -object filter-dump,id=f1,netdev=u1,file=qemu.pcap

The Core Network Server Environment  

核心网络服务environment由socket调用分发器和lwIP组成。socket调用分发和文件服务器的工作方式类似。用户 environment通过stubs(定义在lib/nsipc.c)向核心网络environment发送IPC消息。查看lib/nsipc.c可以发现,核心网络服务器的工作方式和文件服务器是类似的:i386_init创建了NS environment,类型为NS_TYPE_NS,因此我们遍历envs,找到这个特殊的environment type。对于每一个用户environment的IPC,网络服务器中的IPC分发器会调用由lwIP提供的BSD socket接口来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Send an IP request to the network server, and wait for a reply.
// The request body should be in nsipcbuf, and parts of the response
// may be written back to nsipcbuf.
// type: request code, passed as the simple integer IPC value.
// Returns 0 if successful, < 0 on failure.
static int
nsipc(unsigned type)
{
static envid_t nsenv;
if (nsenv == 0)
nsenv = ipc_find_env(ENV_TYPE_NS);

static_assert(sizeof(nsipcbuf) == PGSIZE);

if (debug)
cprintf("[%08x] nsipc %d\n", thisenv->env_id, type);

ipc_send(nsenv, type, &nsipcbuf, PTE_P|PTE_W|PTE_U);
return ipc_recv(NULL, NULL, NULL);
}

普通的用户environment不直接使用nsipc_*调用。通常它们都使用lib/sockets.c中提供的基于文件描述符的sockets API。因此,用户environment通过文件描述符来引用socket,就像引用普通的磁盘文件一样。虽然socket有许多特殊的操作(比如connectaccept等等),但是像readwriteclose这样的操作也是通过lib/fd.c中正常的文件描述符device-dispatcher代码。就像文件服务器会为所有打开的文件维护一个内部独有的ID,lwIP也会为每个打开的socket维护一个独有的ID。在文件服务器或者网络服务器中,我们使用存储在struct Fd中的信息来映射每个environment的文件描述符到相应的ID空间中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
static int
fd2sockid(int fd)
{
struct Fd *sfd;
int r;

if ((r = fd_lookup(fd, &sfd)) < 0)
return r;
if (sfd->fd_dev_id != devsock.dev_id)
return -E_NOT_SUPP;
return sfd->fd_sock.sockid;
}

static int
alloc_sockfd(int sockid)
{
struct Fd *sfd;
int r;

if ((r = fd_alloc(&sfd)) < 0
|| (r = sys_page_alloc(0, sfd, PTE_P|PTE_W|PTE_U|PTE_SHARE)) < 0) {
nsipc_close(sockid);
return r;
}

sfd->fd_dev_id = devsock.dev_id;
sfd->fd_omode = O_RDWR;
sfd->fd_sock.sockid = sockid;
return fd2num(sfd);
}

int
accept(int s, struct sockaddr *addr, socklen_t *addrlen)
{
int r;
if ((r = fd2sockid(s)) < 0)
return r;
if ((r = nsipc_accept(r, addr, addrlen)) < 0)
return r;
return alloc_sockfd(r);
}

int
bind(int s, struct sockaddr *name, socklen_t namelen)
{
int r;
if ((r = fd2sockid(s)) < 0)
return r;
return nsipc_bind(r, name, namelen);
}

int
shutdown(int s, int how)
{
int r;
if ((r = fd2sockid(s)) < 0)
return r;
return nsipc_shutdown(r, how);
}

static int
devsock_close(struct Fd *fd)
{
if (pageref(fd) == 1)
return nsipc_close(fd->fd_sock.sockid);
else
return 0;
}

int
connect(int s, const struct sockaddr *name, socklen_t namelen)
{
int r;
if ((r = fd2sockid(s)) < 0)
return r;
return nsipc_connect(r, name, namelen);
}

int
listen(int s, int backlog)
{
int r;
if ((r = fd2sockid(s)) < 0)
return r;
return nsipc_listen(r, backlog);
}

static ssize_t
devsock_read(struct Fd *fd, void *buf, size_t n)
{
return nsipc_recv(fd->fd_sock.sockid, buf, n, 0);
}

static ssize_t
devsock_write(struct Fd *fd, const void *buf, size_t n)
{
return nsipc_send(fd->fd_sock.sockid, buf, n, 0);
}

static int
devsock_stat(struct Fd *fd, struct Stat *stat)
{
strcpy(stat->st_name, "<sock>");
return 0;
}

int
socket(int domain, int type, int protocol)
{
int r;
if ((r = nsipc_socket(domain, type, protocol)) < 0)
return r;
return alloc_sockfd(r);
}

虽然看起来文件服务器和网络服务器的IPC分发器工作方式相同,但是事实上有一个非常重要的区别。有些BSD socket的操作,例如accept和recv可能会永远阻塞。如果分发器让lwIP运行其中一个堵塞调用,那么很可能分发器会阻塞,因此整个系统在某一时刻只能有一个网络调用,显然,这是不能让人接收的。因此网络服务器使用用户级线程去避免整个服务器environment的阻塞。对于每一个到来的IPC,分发器都会创建一个线程,然后由它对请求进行处理。即使这个线程阻塞了,那么也仅仅只是它进入休眠状态,而其他的线程照样能继续运行。

除了核心网络environment之外,还有其他三个辅助的environment。除了从用户程序中获取消息以外,核心网络 environment的分发器还从input environment和timer environment处获取信息。

The Output Environment 

当处理用户environment的socket调用时,lwIP会产生packet用于网卡的传输。lwIP会将需要发送的packet通过NSREQ_OUTPUT IPC发送给output helper environment,packet的内容存放在IPC的共享页中。output environment负责接收这些信息并且通过系统调用接口将这些packet转发到相应的设备驱动(我们即将实现)。

The Input Environment

网卡得到的packet需要注入到lwIP中。对于设备驱动获得的每一个packet,input environment需要通过相应的系统调用将它们从内核中抽取出来,然后通过NSREQ_INPUT IPC发送给核心服务器environment。

packet input的功能从核心网络environment中剥离出来了,因为接收IPC并且同时接收或等待来自设备驱动的packet对于JOS是非常困难的。因为JOS中没有select这样能够允许environment监听多个输入源并且判断出哪个源已经准备好了。

当我们实现完网卡驱动和系统调用接口后net/input.cnet/output.c中就是我们要实现的2个用户态函数。

The Timer Environment       

timer environment会定期地向核心网络服务器发送NSREQ_TIMER IPC,通知它又过去了一个时间间隔,而lwIP会利用这些时间信息去实现各种的网络超时。

Part A: Initialization and transmitting packets

我们的内核中还没有时间的概念,所以我们需要加上它。现在每隔10ms都有一个由硬件产生的时钟中断。每次出现一个时钟中断的时候,我们都对一个变量进行加操作,表示过去了10ms。这实现在kern/time.c中,但是并未归并到内核中。
  
Exercise 1:在kern/trap.c中增加1个time_tick调用来处理每次时钟中断,实现sys_time_msec系统调用,使用户空间能读取时间。
 
首先在kern/trap.ctrap_dispatch函数中,对于IRQ_OFFSET + IRQ_TIMER中断添加time_tick调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//kern/trap.c
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
lapic_eoi();
time_tick();
sched_yield();
return;
}

//kern/time.c
void
time_tick(void)
{
ticks++;
if (ticks * 10 < ticks)
panic("time_tick: time overflowed");
}

接下去就是添加获取时间的系统调用,具体流程和之前的一样,主要是在kern/syscall.c的中实现sys_time_msec函数,在该函数中调用time_msec函数来获得系统时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//kern/syscall.c
// Return the current time.
static int
sys_time_msec(void)
{
return time_msec();
}

//kern/time.c
unsigned int
time_msec(void)
{
return ticks * 10;
}

通过运行make INIT_CFLAGS=-DTEST_NO_NS run-testtime来测试计时器共,将会看到从5到1的倒计时。其中-DTEST_NO_NS禁止启动网络服务器environment,因为我们暂时还没实现。

The Network Interface Card

  要写1个驱动必须要深入硬件和软件接口,在本次实验中我们将给1个高层次综述关于如何与E1000网卡交互,但是你需要去使用Intel的帮助手册来实现驱动。

PCI Interface

E1000网卡是一个PCI设备,这说明它是插入主板的PCI总线。PCI总线有地址总线、数据总线和中断总线,从而允许CPU能访问PCI设备,PCI设备也能读写内存。一个PCI设备在使用之前需要被发现并且初始化。发现的过程是指遍历PCI总线找到已经连接的设备。初始化是指为设备分配IO和内存空间并且指定IRQ线的过程。

PCI是外围设备互连(Peripheral Component Interconnect)的简称,是在目前计算机系统中得到广泛应用的通用总线接口标准:  

  • 在一个PCI系统中,最多可以有256根PCI总线,一般主机上只会用到其中很少的几条。
  • 在一根PCI总线上可以连接多个物理设备,可以是一个网卡、显卡或者声卡等,最多不超过32个。
  • 一个PCI物理设备可以有多个功能,比如同时提供视频解析和声音解析,最多可提供8个功能。
  • 每个功能对应1个256字节的PCI配置空间。

我们在kern/pci.c中已经提供了PCI相关的代码。为了在启动过程中实现PCI的初始化,相关的PCI代码遍历了PCI总线进行设备查找。当发现一个设备时,它会读取它的vendor ID和device ID,把这两个值作为key去查询pci_attach_vendor数组。该数组元素是struct pci_driver类型的,如下所示:

1
2
3
4
struct pci_driver {
  uint32_t key1, key2;
  int (*attachfn) (struct pci_func *pcif);
};

如果被发现设备的vendor ID和device ID和数组中的某个表项是匹配的,那么接下来就会调用该表项的attachfn函数进行初始化工作。(设备也能被class识别,我们在kern/pci.c中也提供了其它驱动表)

当我们向查询1个特定PCI设备的配置空间时,需要向I/O地址[0cf8,0cfb]写入1个4字节的查询码指定总线号:设备号:功能号以及其配置地址空间中的查询位置。PCI Host Bridge将监听对于这个I/O端口的写入,并将查询结果写入到[0cfc,0cff],我们可以从这个地址读出1个32位整数表示查询到的相应信息。

attach函数通过一个 PCI 函数来初始化。 PCI 卡可以提供多种功能,而 E1000 只提供一种功能。以下是我们在 JOS 中表示 PCI 功能的方式:

1
2
3
4
5
6
7
8
9
10
struct pci_func {
  struct pci_bus   *bus;
  uint32_t     dev;
  uint32_t     func;
  uint32_t     dev_id;
  uint32_t     dev_clasee;
  uint32_t     reg_base[6];
  uint32_t     reg_size[6];
  uint8_t      irq_line;
}

上述结构的最后三个表项是最吸引我们的地方,其中记录了该设备的内存、IO和中断资源的信息。reg_basereg_size数组包含了最多6个Base Address Register(BAR)的信息。reg_base记录了memory-mapped IO region的基内存地址或者基IO端口,reg_size则记录了reg_base对应的内存区域的大小或者IO端口的数目,irq_line则表示分配给设备中断用的IRQ线。

当设备的attachfn被调用时,设备已经被找到了,但是还不能用。这说明相关代码还没有确定分配给设备的资源,比如地址空间和IRQ线,其实就是struct pci_fun中的后三项还没被填充。attachfn函数需要调用pci_func_enable来分配相应的资源,填充struct pci_func,使设备运行起来。

每一个PCI设备都有它映射的内存地址空间和I/O区域,除此之外,PCI设备还有配置空间,一共有256字节,其中前64字节是标准化的,提供了厂商号、设备号、版本号等信息,唯一标示1个PCI设备,同时提供最多6个的IO地址区域。

Exercise 3:实现1个attach函数来初始化E1000网卡,在pci_attach_vendor数组中增加1个表项来触发,可以在参考手册的5.2章节来找到82450EM的vendor ID和device ID。目前暂时使用pci_func_enable来使能E1000网卡设备,初始化工作放到后面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
void
pci_func_enable(struct pci_func *f)
{
pci_conf_write(f, PCI_COMMAND_STATUS_REG,
PCI_COMMAND_IO_ENABLE |
PCI_COMMAND_MEM_ENABLE |
PCI_COMMAND_MASTER_ENABLE);

uint32_t bar_width;
uint32_t bar;
for (bar = PCI_MAPREG_START; bar < PCI_MAPREG_END;
bar += bar_width)
{
uint32_t oldv = pci_conf_read(f, bar);

bar_width = 4;
pci_conf_write(f, bar, 0xffffffff);
uint32_t rv = pci_conf_read(f, bar);

if (rv == 0)
continue;

int regnum = PCI_MAPREG_NUM(bar);
uint32_t base, size;
if (PCI_MAPREG_TYPE(rv) == PCI_MAPREG_TYPE_MEM) {
if (PCI_MAPREG_MEM_TYPE(rv) == PCI_MAPREG_MEM_TYPE_64BIT)
bar_width = 8;

size = PCI_MAPREG_MEM_SIZE(rv);
base = PCI_MAPREG_MEM_ADDR(oldv);
if (pci_show_addrs)
cprintf(" mem region %d: %d bytes at 0x%x\n",
regnum, size, base);
} else {
size = PCI_MAPREG_IO_SIZE(rv);
base = PCI_MAPREG_IO_ADDR(oldv);
if (pci_show_addrs)
cprintf(" io region %d: %d bytes at 0x%x\n",
regnum, size, base);
}

pci_conf_write(f, bar, oldv);
f->reg_base[regnum] = base;
f->reg_size[regnum] = size;

if (size && !base)
cprintf("PCI device %02x:%02x.%d (%04x:%04x) "
"may be misconfigured: "
"region %d: base 0x%x, size %d\n",
f->bus->busno, f->dev, f->func,
PCI_VENDOR(f->dev_id), PCI_PRODUCT(f->dev_id),
regnum, base, size);
}

cprintf("PCI function %02x:%02x.%d (%04x:%04x) enabled\n",
f->bus->busno, f->dev, f->func,
PCI_VENDOR(f->dev_id), PCI_PRODUCT(f->dev_id));
}

回答:在JOS中是如何对PCI设备进行初始化的,这部分模块主要定义在pci.c中,JOS会在系统初始化时调用pci_init函数来进行设备初始化(在kern/init.ci386_init函数中)。

首先来看一些最基本的变量和函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// pci_attach_class matches the class and subclass of a PCI device
struct pci_driver pci_attach_class[] = {
{ PCI_CLASS_BRIDGE, PCI_SUBCLASS_BRIDGE_PCI, &pci_bridge_attach },
{ 0, 0, 0 },
};

// pci_attach_vendor matches the vendor ID and device ID of a PCI device. key1
// and key2 should be the vendor ID and device ID respectively
struct pci_driver pci_attach_vendor[] = {
{ PCI_E1000_VENDOR, PCI_E1000_DEVICE, &pci_e1000_attach },
{ 0, 0, 0 },
};

static void
pci_conf1_set_addr(uint32_t bus,
uint32_t dev,
uint32_t func,
uint32_t offset)
{
assert(bus < 256);
assert(dev < 32);
assert(func < 8);
assert(offset < 256);
assert((offset & 0x3) == 0);

uint32_t v = (1 << 31) | // config-space
(bus << 16) | (dev << 11) | (func << 8) | (offset);
outl(pci_conf1_addr_ioport, v);
}

static uint32_t
pci_conf_read(struct pci_func *f, uint32_t off)
{
pci_conf1_set_addr(f->bus->busno, f->dev, f->func, off);
return inl(pci_conf1_data_ioport);
}

static void
pci_conf_write(struct pci_func *f, uint32_t off, uint32_t v)
{
pci_conf1_set_addr(f->bus->busno, f->dev, f->func, off);
outl(pci_conf1_data_ioport, v);
}

pci_attach_classpci_attach_vendor2个数组就是设备数组,3个函数是堆PCI设备最基本的读状态和写状态的函数:   

  • pci_conf_read函数是读取PCI配置空间中特定位置的配置值
  • pci_conf_write函数是设置PCI配置空间中特定位置的配置值
  • pci_conf1_set_addr函数是负责设置需要读写的具体设备

这里涉及的2个I/O端口正是我们上面提到的操作PCI设备的IO端口。接下来我们看看如何初始化PCI设备,进入pic_init函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int
pci_init(void)
{
static struct pci_bus root_bus;
memset(&root_bus, 0, sizeof(root_bus));

return pci_scan_bus(&root_bus);
}

static int
pci_scan_bus(struct pci_bus *bus)
{
int totaldev = 0;
struct pci_func df;
memset(&df, 0, sizeof(df));
df.bus = bus;

for (df.dev = 0; df.dev < 32; df.dev++) {
uint32_t bhlc = pci_conf_read(&df, PCI_BHLC_REG);
if (PCI_HDRTYPE_TYPE(bhlc) > 1) // Unsupported or no device
continue;

totaldev++;

struct pci_func f = df;
for (f.func = 0; f.func < (PCI_HDRTYPE_MULTIFN(bhlc) ? 8 : 1);
f.func++) {
struct pci_func af = f;

af.dev_id = pci_conf_read(&f, PCI_ID_REG);
if (PCI_VENDOR(af.dev_id) == 0xffff)
continue;

uint32_t intr = pci_conf_read(&af, PCI_INTERRUPT_REG);
af.irq_line = PCI_INTERRUPT_LINE(intr);

af.dev_class = pci_conf_read(&af, PCI_CLASS_REG);
if (pci_show_devs)
pci_print_func(&af);
pci_attach(&af);
}
}

return totaldev;
}

pci_init函数中,root_bus被全部清0,然后交给pci_scan_bus函数来扫描这条总线上的所有设备,说明在JOS中E1000网卡是连接在0号总线上的。pci_scan_bus函数来顺次查找0号总线上的32个设备,如果发现其存在,那么顺次扫描它们每个功能对应的配置地址空间,将一些关键的控制参数读入到pci_func中进行保存。

得到pci_func函数后,被传入pci_attach函数去查找是否为已存在的设备,并用相应的初始化函数来初始化设备。

通过查阅手册,我们知道E1000网卡的Vendor ID为0x8086,Device ID为0x100E,所以我们先实现1个e1000网卡初始化函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//kern/e1000.h
#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H

#include <kern/pci.h>

int pci_e1000_attach(struct pci_func *pcif);

#endif // SOL >= 6


// kern/e1000.c
int
pci_e1000_attach(struct pci_func *pcif)
{
pci_func_enable(pcif);
return 1;
}

//kern/pci.c
// pci_attach_vendor matches the vendor ID and device ID of a PCI device. key1
// and key2 should be the vendor ID and device ID respectively
struct pci_driver pci_attach_vendor[] = {
{ PCI_VENDOR_ID, PCI_DEVICE_ID, &e1000_init },
{ 0, 0, 0 },
};

//kern/pcireg.h
#define PCI_VENDOR_ID 0x8086
#define PCI_DEVICE_ID 0x100E

Memory-mapped I/O

软件通过memory-mapped IO(MMIO)和E1000网卡进行通信。我们已经在JOS两次见到过它了:对于CGA和LAPIC都是通过直接读写“内存”来控制和访问的。但是这些读写操作都是不经过DRAM的,而是直接进入设备。

pci_func_enable为E1000网卡分配了一个MMIO区域,并且将它的基地址和大小存储在了BAR0中,也就是reg_base[0]reg_size[0]中。这是一段为设备分配的物理地址,意味着你需要通过虚拟内存访问它。因为MMIO区域通常都被放在非常高的物理地址上(通常高于3GB),因此我们不能直接使用KADDR去访问它,因为JOS 256MB的内存限制。所以我们需要建立一个新的内存映射。我们将会使用高于MMIOBASE的区域(lab4中的mmio_map_region将会保证我们不会复写LAPIC的映射)。因为PCI设备的初始化发生在JOS创建user environment之前,所以我们可以在kern_pgdir创建映射,从而保证它永远可用。

Exercise 4:在E1000网卡的初始化函数中,通过调用mmio_map_region函数来为E1000网卡的BAR0建立一个虚拟内存映射。你需要使用1个变量记录下该映射地址以便之后可以访问映射的寄存器。查看在kern/lapic.c中的lapic变量,效仿它的做法。假如你使用1个指针指向设备寄存器映射地址,那么你必须声明它为volatile,否则编译器会运行缓存该值和重新排序内存访问序列。

为了测试你的映射,可以尝试答应处设备状态寄出去,该寄存器为4个字节,值为0x80080783,表示全双工1000MB/S。

根据练习的提示,仿照lapic中的做法,在kern/e1000.c中声明1个全局变量e1000,该变量是1个指针,指向映射地址。然后调用mmio_map_region函数来申请内存建立映射,输出状态寄存器的值。关于寄存器位置和相关掩码,我们需要查看开发手册,设置宏定义,这一步可以借鉴QEMU的e1000_hw.h文件,拷贝相关定义到kern/e1000.h中。代码如下,具体的宏定义可以参考github。

1
2
3
4
5
6
7
8
9
int
pci_e1000_attach(struct pci_func *pcif)
{
pci_func_enable(pcif);

e1000 = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
cprintf("e1000: bar0 %x size0 %x\n", pcif->reg_base[0], pcif->reg_size[0]);
cprintf("e1000: status %x\n", e1000[STATUS/4]);
}

DMA

我们可以想象通过读写E1000网卡的寄存器来发送和接收packet,但这实在是太慢了,而且需要E1000暂存packets。因此E1000使用Direct Access Memory(DMA)来直接从内存中读写packets而不通过CPU。驱动的作用就是负责为发送和接收队列分配内存,建立DMA描述符,以及配置E1000网卡,让它知道这些队列的位置,不过之后的所有事情都是异步。在发送packet的时候,驱动会将它拷贝到transmit队列的下一个DMA描述符中,然后通知E1000网卡另外一个包到了。E1000网卡会在能够发送下一个packet的时候,将packet从描述符中拷贝出来。同样,当E1000网卡接收到一个packet的时候,就会将它拷贝到接收队列的下一个DMA描述符中,并且在合适的时机,驱动会将它从中读取出来。
  
从高层次来看,接收和发送队列是非常相似的,都是由一系列的描述符组成。但是这些descriptor具体的结构是不同的,每个描述符都包含了一些flag以及存储packet数据的物理地址。
  
队列由循环数组构成,这表示当网卡或者驱动到达了数组的末尾时,它又会转回数组的头部。每个循环数组都有一个head指针和tail指针,这两个指针之间的部分就是队列的内容。网卡总是从head消耗描述符并且移动head指针,同时,驱动总是向尾部添加描述符并且移动tail指针。发送队列的描述符代表等待被发送的packet。对于接收队列,队列中的描述符是一些闲置的描述符,网卡可以将收到的packet放进去。

这些指向数组的指针和描述符中packet buffer的地址都必须是物理地址,因为硬件直接和物理RAM发生DMA,并不经过MMU。

Transmitting Packets

E1000网卡的发送和接收函数是独立的,因此我们能一次处理其中一个。我们将首先实现发送packet的操作,因为没有发送就不能接收。
  
首先,我们要做的是初始化网卡的发包。根据14.5章节描述的步骤,发送操作初始化的第一步就是建立发送队列,具体队列结构的描述在3.4章节,描述符的结构在3.3.3章节。我们不会使用E1000网卡的TCP offload特性,所以我们专注于”legacy transmit descriptor format”。

C Structures

我们会发现用C的结构描述E1000网卡的结构是相当容易的。就像我们之前遇到过的struct Trapframe,C结构能让你精确地控制数据在内存中的布局。C会在结构的各个元素间插入空白用于对齐,但是对于E1000里的结构这都不是问题。例如,传统的发送描述符如下图所示:

1
2
3
4
5
6
63      48 47 40 39   32 31  24 23   16 15      0
+-----------------------------------------------+
| buffer address |
+---------+-----+--------+-----+-------+--------+
| special | CSS | status | cmd | CSO | length |
+---------+-----+--------+-----+-------+--------+

按照从上往下,从右往左的顺序读取,我们可以发现,struct tx_desc刚好是对齐的,因此不会有空白填充。

1
2
3
4
5
6
7
8
9
10
struct tx_desc
{
uint64_t addr;
uint16_t length;
uint8_t cso;
uint8_t cmd;
uint8_t status;
uint8_t css;
uint16_t special;
};

我们的驱动需要为发送描述符数组和发送描述符指向的packet buffers预留内存。对于这一点,我们有很多实现方法,包括可以通过动态地分配页面并将它们存放在全局变量中。我们用哪种方法,需要记住的是E1000总是直接访问物理内存的,这意味着任何它访问的buffer都必须在物理空间上是连续的。
  
同样,我们有很多方法处理packet buffer。最简单的就是像最开始我们说的那样,在驱动初始化的时候为每个描述符的packet buffer预留空间,之后就在这些预留的buffer中对packet进行进出拷贝。Ethernet packet最大有1518个byte,这就表明了这些buffer至少要多大。更加复杂的驱动可以动态地获取packet buffer(为了降低网络使用率比较低的时候带来的浪费)或者直接提供由用户空间提供的buffers,不过一开始简单点总是好的。
  
Exercise 5:根据14.5章节的描述,实现发包初始化,同时借鉴13章节(寄存器初始化)、3.3.3章节((发送描述符)和3.4章节(发送描述符数组)。
  
记住发送描述数组的对弈要求和数组长度的限制。TDLEN必须是128字节对齐的,每个发送描述符是16字节的,你的发送描述符数组大小需要是8的倍数。在JOS中不要超过64个描述符,以防不好测试发送环形队列溢出情况。

这里需要查看开发手册14.5章节关于发送初始化的描述,主要步骤如下:

  • 为发送描述符队列分配一块连续空间,设置TDBALTDBAH寄存器的值指向起始地址,其中TDBAL为32位地址,TDBALTDBAH表示64位地址。
  • 设置TDLEN寄存器的值为描述符队列的大小,以字节计算。
  • 设置发送队列的Head指针(TDH)和Tail指针(TDT)寄存器的值为0。
  • 初始化发送控制TCTL寄存器的值,包括设置Enable位为1(TCTL.EN)、TCTL.PSP位为1、TCTL.CT位为10h、TCTL.COLD位为40h。
  • 设置TIPG寄存器为期望值

首先是发送队列的设置,这里采用最简单的方法,声明发送描述符结构体和packet buffer结构体,并定义1个64大小的全局发送描述符数组和1个64大小的packet buffer数组,即都使用静态分配的方法。由于packet最大为1518字节,根据后面接收描述符的配置,将packet buffer设置为2048字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//kern/e1000.h
struct tx_desc
{
uint64_t addr;
uint16_t length;
uint8_t cso;
uint8_t cmd;
uint8_t status;
uint8_t css;
uint16_t special;
} __attribute__((packed));

struct packet
{
char body[2048];
};

//kern/e1000.c
struct tx_desc tx_d[TXRING_LEN] __attribute__((aligned (PGSIZE)))
= {{0, 0, 0, 0, 0, 0, 0}};
struct packet pbuf[TXRING_LEN] __attribute__((aligned (PGSIZE)))
= {{{0}}};

pci_enable_attach函数中初始化相关寄存器的设置和发送描述符初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static void
init_desc(){
int i;

for(i = 0; i < TXRING_LEN; i++){
memset(&tx_d[i], 0, sizeof(tx_d[i]));
tx_d[i].addr = PADDR(&pbuf[i]);
tx_d[i].status = TXD_STAT_DD;
tx_d[i].cmd = TXD_CMD_RS | TXD_CMD_EOP;
}
}

int
pci_e1000_attach(struct pci_func *pcif)
{
pci_func_enable(pcif);
init_desc();

e1000 = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
cprintf("e1000: bar0 %x size0 %x\n", pcif->reg_base[0], pcif->reg_size[0]);

e1000[TDBAL/4] = PADDR(tx_d);
e1000[TDBAH/4] = 0;
e1000[TDLEN/4] = TXRING_LEN * sizeof(struct tx_desc);
e1000[TDH/4] = 0;
e1000[TDT/4] = 0;
e1000[TCTL/4] = TCTL_EN | TCTL_PSP | (TCTL_CT & (0x10 << 4)) | (TCTL_COLD & (0x40 << 12));
e1000[TIPG/4] = 10 | (8 << 10) | (12 << 20);
cprintf("e1000: status %x\n", e1000[STATUS/4]);
return 1;
}

在完成了exercise 5之后,发送已经初始化完成。我们需要实现包的发送工作,然后让用户空间能够通过系统调用获取这些包。为了发送一个包,我们需要将它加入到发送队列的尾部,这意味着我们要将packet拷贝到下一个packet buffer,并且更新TDT寄存器,从而告诉网卡,已经有另一个packet进入发送队列了。(需要注意的是,TDT是一个指向transmit descriptor array的index,而不是一个byte offset)

但是,发送队列只有这么大。如果网卡迟迟没有发送packet,发送队列满了怎么办?为了检测这种情况,我们需要反馈给E1000网卡一些信息。不幸的是,我们并不能直接使用TDH寄存器,文档中明确声明,读取该寄存器的值是不可靠的。然而,如果我们在发送描述符的command filed设置了RS位,那么当网卡发送了这个描述符中的包之后,就会设置该描述符的status域的DD位。如果一个描述符的DD位被设置了,那么我们就可以知道循环利用这个描述符是安全的,可以利用它去发送下一个packet。

如果当用户调用了发包的系统调用,但是下一个描述符的DD位没有设置怎么办?这是否代表发送队列满了么?遇到这种情况我们应该如何处理?我们可以选择简单地直接丢弃这个packet。许多网络协议都对这种情况有弹性的设置,但是如果我们丢弃了很多packet的话,协议可能就无法恢复了。我们也许可以告诉user environment我们需要重新发送,就像sys_ipc_try_send中做的一样。我们可以让驱动一直处于自旋状态,直到有一个发送描述符被释放,但是这可能会造成比较大的性能问题,因为JOS内核不是设计成能阻塞的。最后,我们可以让transmitting environment睡眠并且要求网卡在有transmit descriptor被释放的时候发送一个中断。

Exercise 6:写一个函数通过检查下一个描述符是否可用来发送一个包,拷贝数据包内容到下一个描述符中,更新TDT,确保你能正确解决发送队列满了的情况。

回答:在初始化工作中我们已经设置发送描述符的状态位为DD,即表示可用,只要在发送函数里获取Tail指针寄存器的值,判断该指针指向的发送描述符是否可用,如果可用将数据包内容拷贝到描述符中,并更新描述符的状态位和TDT寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int
e1000_transmit(void *addr, size_t len)
{
uint32_t tail = e1000[TDT/4];
struct tx_desc *nxt = &tx_d[tail];
// find the last ex_desc with tail.

if((nxt->status & TXD_STAT_DD) != TXD_STAT_DD)
return -1;
if (len > TBUFFSIZE)
len = TBUFFSIZE;

memmove(&pbuf[tail], addr, len);
nxt->length = (uint16_t)len;
nxt->status &= !TXD_STAT_DD;
e1000[TDT/4] = (tail + 1) % TXRING_LEN;
return 0;
}

当你完成发包代码后,可以在内核中调用该函数来测试代码正确性。运行make E1000_DEBUG=TXERR,TX qemu测试,你会看到如下输出:

1
e1000: index 0: 0x271f00 : 9000002a 0

其中每一行表示1个发送的数据包,index给出了在发送描述符数组中的索引,之后的为该描述符中packet buffer的地址,然后是cmd/CSO/length标志位,最后是special/CSS/status标志位。

Exercise 7:添加1个系统调用来让用户空间可以发送数据包。具体的接口实现取决于自己。

仿照sys_ipc_try_send调用,在系统调用涉及的文件中添加调用号和接口函数。

1
2
3
4
5
6
7
8
//kern/syscall.c
// Send network packet
static int
sys_netpacket_try_send(void *addr, size_t len)
{
user_mem_assert(curenv, addr, len, PTE_U);
return e1000_transmit(addr, len);
}

Transmitting Packets: Network Server

现在我们已经有了访问设备驱动发送端的系统调用接口,那么该发送一些packets了。output helper environment的作用就是不断做如下的循环:从核心网络服务器中接收NSREQ_OUTPUT类型的IPC消息,然后用我们自己写的系统调用将含有这些IPC消息的packet发送到网卡驱动。

NSREQ_OUTPUT的IPC消息是由net/lwip/jos/jif/jif.c中的low_level_output发送的,它将lwIP stack和JOS的网络系统连在了一起。每一个IPC都会包含一个由union Nsipc组成的页,其中packet存放在struct jif_pkt字段中(见inc/ns.h)。struct jif_pkt如下所示:

1
2
3
4
struct jif_pkt {
  int   jp_len;
  char  jp_data[0];
}

其中jp_len代表了packet的长度。IPC page之后的所有字节都代表了packet的内容。使用一个长度为0的数组,例如jp_data,在struct jif_pkt的结尾,是C中一种比较通用的方式,用于代表一个未提前指定长度的buffer。因为C中并没有做任何边界检测,只要你确定struct之后有足够的未被使用的内存,我们就可以认为jp_data是任意大小的数组。

我们需要搞清楚当设备驱动的发送队列中没有空间的时候,设备驱动,output environment和核心网络服务器三者之间的关系。核心网络服务器通过IPC将packet发送给output environment。如果output environment因为驱动中没有足够的缓存空间用于存放新的packet而阻塞,核心网络服务器会一直阻塞直到output environment接受了IPC为止。

Exercise 8:实现net/output.c

回答:这里主要是实现output environment的工作。net/testoutput.c是测试发包的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static envid_t output_envid;
static struct jif_pkt *pkt = (struct jif_pkt*)REQVA;

void
umain(int argc, char **argv)
{
envid_t ns_envid = sys_getenvid();
int i, r;

binaryname = "testoutput";

output_envid = fork();
if (output_envid < 0)
panic("error forking");
else if (output_envid == 0) {
output(ns_envid);
return;
}

for (i = 0; i < TESTOUTPUT_COUNT; i++) {
if ((r = sys_page_alloc(0, pkt, PTE_P|PTE_U|PTE_W)) < 0)
panic("sys_page_alloc: %e", r);
pkt->jp_len = snprintf(pkt->jp_data,
PGSIZE - sizeof(pkt->jp_len),
"Packet %02d", i);
cprintf("Transmitting packet %d\n", i);
ipc_send(output_envid, NSREQ_OUTPUT, pkt, PTE_P|PTE_W|PTE_U);
sys_page_unmap(0, pkt);
}

// Spin for a while, just in case IPC's or packets need to be flushed
for (i = 0; i < TESTOUTPUT_COUNT*2; i++)
sys_yield();
}

testoutput.c中,先fork1个environment,即output environment,然后运行需要实现的output函数,在原先environment中通过ipc_send发送数据包的内容。所以在output environment中,就需要实现通过ipc_recv接受到IPC信息时,如果为NSREQ_OUTPUT,那么调用发包系统调用来发送数据包到网卡驱动。

Part B: Receiving packets and the web server

Receiving Packets

与发包类似,我们必须配置E1000网卡来接受包并提供接收描述符队列和接收描述符。

接收队列和发送队列非常相似,不同的是它由空的packet buffer组成,等待被即将到来的packet填充。因此,当网络暂停的时候,发送队列是空的,但是接收队列是满的。当E1000接收到一个packet时,它会首先检查这个packet是否满足该网卡的configured filters(比如,这个包的目的地址是不是该E1000的MAC地址)并且忽略那些不符合这些filter的packet。否则,E1000尝试获取从接收队列获取下一个空闲的描述符。如果Head指针(RDH)已经追赶上了Tail指针(RDT),那么说明接收队列已经用完了空闲的descriptor,因此网卡就会丢弃这个packet。如果还有空闲的接收描述符,它会将packet data拷贝到描述符包含的buffer中,并且设置描述符的DD(descriptor done)和EOP(End of Packet)状态位,然后增加RDH

如果E1000网卡收到一个packet,它的数据大于一个接收描述符的packet buffer,它会继续从接收队列中获取尽可能多的描述符,用来存放packet的所有内容。为了表明这样的情况,它会在每个descriptor中都设置DD状态位,但只在最后一个descriptor中设置EOP状态位。我们可以让驱动对这种情况进行处理,或者只是简单地对对网卡进行配置,让它不接收这样的“long packet”,但是我们要确保我们的receive buffer能够接收最大的标志Ethernet packet(1518字节)。

Exercise 10:建立接收队列和配置E1000网卡,无须支持”long packets”和multicast。暂时不要配置使用中断,同时忽略CRC。

默认情况下,网卡会过滤所有的packet,我们必须配置接收地址寄存器(RALRAH)为网卡的MAC地址以使得能接受发送给该网卡的包。目前可以简单地硬编码QEMU的默认MAC地址52:54:00:12:34:56。注意字节顺序MAC地址从左到右是从低地址到高地址的,所以52:54:00:12为低32位,34:56为高16位

E1000网卡只支持一系列特殊的receive buffer大小。假如我们配置receive packet buffers足够大并关闭long packets,那么我们就无需担心跨越多个receive buffer的包。同时记住接收队列和packet buffer也必须是连续的物理内存。我们必需使用至少128个接收描述符。

整个流程跟发包初始化配置类似。主要相关工作如下:

  • 设置接受地址寄存器(RAL/RAH)为网卡的MAC地址。
  • 初始化multicast表数组为0。
  • 设置中断相关寄存器的值,这里我们关闭中断
  • 为接收描述符队列分配一块连续空间,设置RDBALRDBAH寄存器的值指向起始地址,其中RDBAL为32位地址,RDBALRDBAH表示64位地址。
  • 设置RDLEN寄存器的值为描述符队列的大小,以字节计算。
  • 设置接收队列的Head指针(RDH)和Tail指针(RDT)寄存器的值为0。Head指针指向第1个可用的描述符,Tail指向最后1个可用描述符的下一个描述符。如果将Head指针和Tail指针初始化为0,那么将接收不到数据包,应该将Tail指针初始化为最后1个可用描述符即RDLEN-1,因为像上面描述的当RDH等于RDT的时候,网卡认为队列满了,会丢弃数据包。
  • 设置接收控制寄存器RCTL的值,主要包括设置RCTL.EN标志位为1(激活)、RCTL.LBM标志位为00(关闭回环)、RCTL.BSIZE标志位为00和RCTL.BSEX位为0(buffer大小为2048字节)、RCTL.SECRC标志位为1(忽略校验)。

下边的代码与上述描述一一对应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/* 为描述符列表分配静态内存 */
struct tx_desc tx_d[TXRING_LEN] __attribute__((aligned(PGSIZE)))
= {{0, 0, 0, 0, 0, 0, 0}};
struct packet pbuf[TXRING_LEN] __attribute__((aligned(PGSIZE)))
= {{{0}}};
struct rx_desc rx_d[RXRING_LEN] __attribute__((aligned(PGSIZE)))
= {{0, 0, 0, 0, 0, 0, 0}};
struct packet prbuf[RXRING_LEN] __attribute__((aligned(PGSIZE)))
= {{{0}}};

static void
init_desc(){
......
for(i = 0; i < RXRING_LEN; i++){
memset(&rx_d[i], 0, sizeof(rx_d[i]));
rx_d[i].addr = PADDR(&prbuf[i]);
rx_d[i].status = 0;
}
}

int
pci_e1000_attach(struct pci_func *pcif)
{
......
e1000[RA/4] = mac[0];
e1000[RA/4+1] = mac[1];
e1000[RA/4+1] |= RAV;

cprintf("e1000: mac address %x:%x\n", mac[1], mac[0]);

memset((void*)&e1000[MTA/4], 0, 128 * 4);
e1000[ICS/4] = 0;
e1000[IMS/4] = 0;
//e1000[IMC/4] = 0xFFFF;
e1000[RDBAL/4] = PADDR(rx_d);
e1000[RDBAH/4] = 0;
e1000[RDLEN/4] = RXRING_LEN * sizeof(struct rx_desc);
e1000[RDH/4] = 0;
e1000[RDT/4] = RXRING_LEN - 1;
e1000[RCTL/4] = RCTL_EN | RCTL_LBM_NO | RCTL_SECRC | RCTL_BSIZE;
return 1;
}

完成后,运行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinputtestinput会发送ARP广播,QEMU会自动回应。

现在我们要实现接收包。为了接收packet,我们的驱动需要跟踪到底从哪个描述符中获取下一个received packet。和发送时相似,文档中说明从软件中读取RDH寄存器也是不可靠的。所以,为了确定一个packet是否被发送到描述符的packet buffer中,我们需要读取该描述符的DD状态位。如果DD已经被置位,那么我们可以将packet data从描述符的packet buffer中拷贝出来,然后通过更新队列的RDT告诉网卡该描述符已经被释放了。

如果DD没有被置位,那么说明没有接收到任何packet。这和发送端队列已满的情况是一样的,在这种情况下,我们可以做很多事情。我们可以简单地返回一个“try again”的error并且要求调用者继续尝试。这种方法对于发送队列已满的情况是有效的,因为那种情况是短暂的,但是对于空的接收队列就不合适了,因为接收队列可能很长时间处于空的状态。

第二种方法就是将calling environment挂起,直到接收队列中有packet可以处理。这种方法和sys_ipc_recv和相似。就像在IPC中所做的,每个CPU只有一个kernel stack,一旦我们离开kernel,那么栈上的state就会消失。我们需要设置一个flag来表明这个environment是因为接收队列被挂起的并且记录下系统调用参数。这种方法的缺点有点复杂:E1000网卡必须被配置成能产生接收中断并且驱动还需要能够对中断进行处理,为了让等待packet的environment能恢复过来。

Exercise 11:写1个函数来从E1000网卡接收1个包,并添加1个系统调用暴露给用户空间。确保你能处理接收队列为空的情况。

与发包类似,读取RDT寄存器的值,判断最后1个可用描述符的下一个描述符的标志位是否为DD,如果是则拷贝该描述符中的buffer,清除DD位,并增加RDT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int
e1000_receive(void *addr, size_t buflen)
{
uint32_t tail = (e1000[RDT/4] + 1) % RXRING_LEN;
struct rx_desc *nxt = &rx_d[tail];

if((nxt->status & RXD_STAT_DD) != RXD_STAT_DD) {
return -1;
}
if(nxt->length < buflen)
buflen = nxt->length;

memmove(addr, &prbuf[tail], buflen);
nxt->status &= !RXD_STAT_DD;
e1000[RDT/4] = tail;

return buflen;
}

Receiving Packets: Network Server

在网络服务器input environment中,我们将需要使用新添加的收包系统调用来接收数据包并通过NSREQ_INPUT IPC消息传递给核心网络服务器environment。

Exercise 12:实现net/input.c

回答:这里主要是实现input environment的工作。net/testinput.c是测试收包的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
void
umain(int argc, char **argv)
{
envid_t ns_envid = sys_getenvid();
int i, r, first = 1;

binaryname = "testinput";

output_envid = fork();
if (output_envid < 0)
panic("error forking");
else if (output_envid == 0) {
output(ns_envid);
return;
}

input_envid = fork();
if (input_envid < 0)
panic("error forking");
else if (input_envid == 0) {
input(ns_envid);
return;
}

cprintf("Sending ARP announcement...\n");
announce();

while (1) {
envid_t whom;
int perm;

int32_t req = ipc_recv((int32_t *)&whom, pkt, &perm);
if (req < 0)
panic("ipc_recv: %e", req);
if (whom != input_envid)
panic("IPC from unexpected environment %08x", whom);
if (req != NSREQ_INPUT)
panic("Unexpected IPC %d", req);

hexdump("input: ", pkt->jp_data, pkt->jp_len);
cprintf("\n");

// Only indicate that we're waiting for packets once
// we've received the ARP reply
if (first)
cprintf("Waiting for packets...\n");
first = 0;
}
}

fork了2个新的environment,其中1个执行output,发送ARP广播,另外1个执行input,接收QEMU的回应。通过ipc_recv来获得input environment收到的数据包。

net/input.cinput函数中通过调用收包系统调用从网卡驱动处获得数据包,这里的注意点是根据注释有可能收包太快,发送给网络服务器,但是网络服务器可能读取过慢,导致相应的内容被冲刷,所以我们采用10页的缓冲来存放从网卡驱动获得的数据包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int input(envid_t ns_envid)
{
binaryname = "ns_input";

int i, r;
int32_t length;
struct jif_pkt *cpkt = pkt;

for(i = 0; i < 10; i++)
if ((r = sys_page_alloc(0, (void*)((uintptr_t)pkt + i * PGSIZE), PTE_P | PTE_U | PTE_W)) < 0)
panic("sys_page_alloc: %e", r);

i = 0;
while(1) {
while((length = sys_netpacket_recv((void*)((uintptr_t)cpkt + sizeof(cpkt->jp_len)), PGSIZE - sizeof(cpkt->jp_len))) < 0) {
// cprintf("len: %d\n", length);
sys_yield();
}

cpkt->jp_len = length;
ipc_send(ns_envid, NSREQ_INPUT, cpkt, PTE_P | PTE_U);
i = (i + 1) % 10;
cpkt = (struct jif_pkt*)((uintptr_t)pkt + i * PGSIZE);
sys_yield();
}
}

The Web Server

1个简单的web服务器将发送1个文件内容给请求客户端。JOS已经在user/httpd.c文件中提供可骨架代码,处理socket连接和Http头转义。

Exercise 13:实现user/httpd.c文件中的send_file函数和send_data函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static int
send_file(struct http_request *req)
{
int r;
off_t file_size = -1;
int fd;
struct Stat st;

if ((fd = open(req->url, O_RDONLY)) < 0)
return send_error(req, 404);

if ((r = fstat(fd, &st)) < 0)
return send_error(req, 404);

if (st.st_isdir)
return send_error(req, 404);

file_size = st.st_size;

if ((r = send_header(req, 200)) < 0)
goto end;

if ((r = send_size(req, file_size)) < 0)
goto end;

if ((r = send_content_type(req)) < 0)
goto end;

if ((r = send_header_fin(req)) < 0)
goto end;

r = send_data(req, fd);

end:
close(fd);
return r;
}

static int
send_data(struct http_request *req, int fd)
{
char buf[128];
int r;

while(1){
r = read(fd, buf, 128);
if(r <= 0)
return r;
if(write(req->sock, buf, r) != r)
return -1;
}
}

求二进制数中1的个数

问题描述

任意给定一个32位无符号整数n,求n的二进制表示中1的个数,比如n = 5(0101)时,返回2,n = 15(1111)时,返回4

这也是一道比较经典的题目了,相信不少人面试的时候可能遇到过这道题吧,下面介绍了几种方法来实现这道题。

普通法

移位+计数,这种方法的运算次数与输入n最高位1的位置有关,最多循环32次。

1
2
3
4
5
6
7
8
9
10
11
int BitCount(unsigned int n)
{
unsigned int c =0 ; // 计数器
while (n >0)
{
if((n &1) ==1) // 当前位是1
++c ; // 计数器加1
n >>=1 ; // 移位
}
return c ;
}

一个更精简的版本如下
1
2
3
4
5
6
7
int BitCount1(unsigned int n)
{
unsigned int c =0 ; // 计数器
for (c =0; n; n >>=1) // 循环移位
c += n &1 ; // 如果当前位是1,则计数器加1
return c ;
}

快速法

这种方法速度比较快,其运算次数与输入n的大小无关,只与n中1的个数有关。如果n的二进制表示中有k个1,那么这个方法只需要循环k次即可。其原理是不断清除n的二进制表示中最右边的1,同时累加计数器,直至n为0,代码如下

1
2
3
4
5
6
7
8
9
int BitCount2(unsigned int n)
{
unsigned int c =0 ;
for (c =0; n; ++c)
{
n &= (n -1) ; // 清除最低位的1
}
return c ;
}

为什么n &= (n – 1)能清除最右边的1呢?因为从二进制的角度讲,n相当于在n - 1的最低位加上1。举个例子,8(1000)= 7(0111)+ 1(0001),所以8 & 7 = (1000)&(0111)= 0(0000),清除了8最右边的1(其实就是最高位的1,因为8的二进制中只有一个1)。再比如7(0111)= 6(0110)+ 1(0001),所以7 & 6 = (0111)&(0110)= 6(0110),清除了7的二进制表示中最右边的1(也就是最低位的1)。

查表法

动态建表

由于表示在程序运行时动态创建的,所以速度上肯定会慢一些,把这个版本放在这里,有两个原因

  1. 介绍填表的方法,因为这个方法的确很巧妙。
  2. 类型转换,这里不能使用传统的强制转换,而是先取地址再转换成对应的指针类型。也是常用的类型转换方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int BitCount3(unsigned int n) 
{
// 建表
unsigned char BitsSetTable256[256] = {0} ;

// 初始化表
for (int i =0; i <256; i++)
{
BitsSetTable256[i] = (i &1) + BitsSetTable256[i /2];
}

unsigned int c =0 ;

// 查表
unsigned char* p = (unsigned char*) &n ;

c = BitsSetTable256[p[0]] +
BitsSetTable256[p[1]] +
BitsSetTable256[p[2]] +
BitsSetTable256[p[3]];

return c ;
}

先说一下填表的原理,根据奇偶性来分析,对于任意一个正整数n

  1. 如果它是偶数,那么n的二进制中1的个数与n/2中1的个数是相同的,比如4和2的二进制中都有一个1,6和3的二进制中都有两个1。为啥?因为n是由n/2左移一位而来,而移位并不会增加1的个数。
  2. 如果n是奇数,那么n的二进制中1的个数是n/2中1的个数+1,比如7的二进制中有三个1,7/2 = 3的二进制中有两个1。为啥?因为当n是奇数时,n相当于n/2左移一位再加1。

再说一下查表的原理

对于任意一个32位无符号整数,将其分割为4部分,每部分8bit,对于这四个部分分别求出1的个数,再累加起来即可。而8bit对应2^8 = 256种01组合方式,这也是为什么表的大小为256的原因。

注意类型转换的时候,先取到n的地址,然后转换为unsigned char*,这样一个unsigned int(4 bytes)对应四个unsigned char(1 bytes),分别取出来计算即可。举个例子吧,以87654321(十六进制)为例,先写成二进制形式-8bit一组,共四组,以不同颜色区分,这四组中1的个数分别为4,4,3,2,所以一共是13个1,如下面所示。

10000111 01100101 01000011 00100001 = 4 + 4 + 3 + 2 = 13

静态表-4bit

原理和8-bit表相同,详见8-bit表的解释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int BitCount4(unsigned int n)
{
unsigned int table[16] =
{
0, 1, 1, 2,
1, 2, 2, 3,
1, 2, 2, 3,
2, 3, 3, 4
} ;

unsigned int count =0 ;
while (n)
{
count += table[n &0xf] ;
n >>=4 ;
}
return count ;
}

静态表-8bit

首先构造一个包含256个元素的表table,table[i]即i中1的个数,这里的i是[0-255]之间任意一个值。然后对于任意一个32bit无符号整数n,我们将其拆分成四个8bit,然后分别求出每个8bit中1的个数,再累加求和即可,这里用移位的方法,每次右移8位,并与0xff相与,取得最低位的8bit,累加后继续移位,如此往复,直到n为0。所以对于任意一个32位整数,需要查表4次。以十进制数2882400018为例,其对应的二进制数为10101011110011011110111100010010,对应的四次查表过程如下:红色表示当前8bit,绿色表示右移后高位补零。

第一次(n & 0xff) 10101011110011011110111100010010

第二次((n >> 8) & 0xff) 00000000101010111100110111101111

第三次((n >> 16) & 0xff)00000000000000001010101111001101

第四次((n >> 24) & 0xff)00000000000000000000000010101011

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int BitCount7(unsigned int n)
{
unsigned int table[256] =
{
0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8,
};

return table[n &0xff] +
table[(n >>8) &0xff] +
table[(n >>16) &0xff] +
table[(n >>24) &0xff] ;
}

当然也可以搞一个16bit的表,或者更极端一点32bit的表,速度将会更快。

平行算法

网上都这么叫,我也这么叫吧,不过话说回来,的确有平行的意味在里面,先看代码,稍后解释

1
2
3
4
5
6
7
8
9
10
int BitCount4(unsigned int n) 
{
n = (n &0x55555555) + ((n >>1) &0x55555555) ;
n = (n &0x33333333) + ((n >>2) &0x33333333) ;
n = (n &0x0f0f0f0f) + ((n >>4) &0x0f0f0f0f) ;
n = (n &0x00ff00ff) + ((n >>8) &0x00ff00ff) ;
n = (n &0x0000ffff) + ((n >>16) &0x0000ffff) ;

return n ;
}

速度不一定最快,但是想法绝对巧妙。 说一下其中奥妙,其实很简单,先将n写成二进制形式,然后相邻位相加,重复这个过程,直到只剩下一位。

1
2
3
4
5
int BitCount5(unsigned int n)
{
unsigned int tmp = n - ((n >>1) &033333333333) - ((n >>2) &011111111111);
return ((tmp + (tmp >>3)) &030707070707) %63;
}

最喜欢这个,代码太简洁啦,只是有个取模运算,可能速度上慢一些。区区两行代码,就能计算出1的个数,到底有何奥妙呢?为了解释的清楚一点,我尽量多说几句。

第一行代码的作用:先说明一点,以0开头的是8进制数,以0x开头的是十六进制数,上面代码中使用了三个8进制数。

将n的二进制表示写出来,然后每3bit分成一组,求出每一组中1的个数,再表示成二进制的形式。比如n = 50,其二进制表示为110010,分组后是110和010,这两组中1的个数本别是2和3。2对应010,3对应011,所以第一行代码结束后,tmp = 010011,具体是怎么实现的呢?由于每组3bit,所以这3bit对应的十进制数都能表示为2^2 a + 2^1 b + c的形式,也就是4a + 2b + c的形式,这里a,b,c的值为0或1,如果为0表示对应的二进制位上是0,如果为1表示对应的二进制位上是1,所以a + b + c的值也就是4a + 2b + c的二进制数中1的个数了。举个例子,十进制数6(0110)= 4 1 + 2 1 + 0,这里a = 1, b = 1, c = 0, a + b + c = 2,所以6的二进制表示中有两个1。现在的问题是,如何得到a + b + c呢?注意位运算中,右移一位相当于除2,就利用这个性质!

4a + 2b + c 右移一位等于2a + b

4a + 2b + c 右移量位等于a

然后做减法

4a + 2b + c –(2a + b) – a = a + b + c,这就是第一行代码所作的事,明白了吧。

第二行代码的作用:在第一行的基础上,将tmp中相邻的两组中1的个数累加,由于累加到过程中有些组被重复加了一次,所以要舍弃这些多加的部分,这就是&030707070707的作用,又由于最终结果可能大于63,所以要取模。

需要注意的是,经过第一行代码后,从右侧起,每相邻的3bit只有四种可能,即000, 001, 010, 011,为啥呢?因为每3bit中1的个数最多为3。所以下面的加法中不存在进位的问题,因为3 + 3 = 6,不足8,不会产生进位。

tmp + (tmp >> 3)-这句就是是相邻组相加,注意会产生重复相加的部分,比如tmp = 659 = 001 010 010 011时,tmp >> 3 = 000 001 010 010,相加得

001 010 010 011

000 001 010 010


001 011 100 101

011 + 101 = 3 + 5 = 8。(感谢网友Di哈指正。)注意,659只是个中间变量,这个结果不代表659这个数的二进制形式中有8个1。

注意我们想要的只是第二组和最后一组(绿色部分),而第一组和第三组(红色部分)属于重复相加的部分,要消除掉,这就是&030707070707所完成的任务(每隔三位删除三位),最后为什么还要%63呢?因为上面相当于每次计算相连的6bit中1的个数,最多是111111 = 77(八进制)= 63(十进制),所以最后要对63取模。

位标志法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct _byte 
{
unsigned a:1;
unsigned b:1;
unsigned c:1;
unsigned d:1;
unsigned e:1;
unsigned f:1;
unsigned g:1;
unsigned h:1;
};

long get_bit_count( unsigned char b )
{
struct _byte *by = (struct _byte*)&b;
return (by->a+by->b+by->c+by->d+by->e+by->f+by->g+by->h);
}

指令法

使用微软提供的指令,首先要确保你的CPU支持SSE4指令,用Everest和CPU-Z可以查看是否支持。

1
2
unsigned int n =127 ;
unsigned int bitCount = _mm_popcnt_u32(n) ;

快速幂、快速幂取模

大数模幂运算的缺陷

快速幂取模算法的引入是从大数的小数取模的朴素算法的局限性所提出的,在朴素的方法中我们计算一个数比如5^1003%31是非常消耗我们的计算资源的,在整个计算过程中最麻烦的就是我们的5^1003这个过程

  • 缺点1:在我们在之后计算指数的过程中,计算的数字不都拿得增大,非常的占用我们的计算资源(主要是时间,还有空间)
  • 缺点2:我们计算的中间过程数字大的恐怖,我们现有的计算机是没有办法记录这么长的数据的,所以说我们必须要想一个更加高效的方法来解决这个问题

快速幂的引入

我们首先从优化的过程开始一步一步优化我们的模幂算法
1.朴素模幂运算过程:

1
2
3
4
5
#define ans=1
for(int i=1;i<=b;i++)
{
ans*=a;
}

根据我们上面说的,这种算法是非常的无法容忍的,我们在计算的过程中出现的两个缺点在这里都有体现
在这里我们如果要做优化的话,我肥就是每个过程中都加一次模运算,但是我们首先要记住模运算是非常的消耗内存资源的,在计算的次数非常的大的时候,我们是没有办法忍受这种时间耗费的
2.快速幂引入:
在讲解快速幂取模算法之前,我们先将几个必备的知识

1.对于取模运算:(a*b)%c=(a%c)*(b%c)%c,这个是成立的:也是我们实现快速幂的基础。之后我们来看看快速幂的核心本质。

在这里,我们对指数动了一些手脚,核心思想在于:将大数的幂运算拆解成了相对应的乘法运算,利用上面的式子,始终将我们的运算的数据量控制在c的范围以下,这样我们可以客服朴素的算法的缺点二,我们将计算的数据量压缩了很大一部分,当指数非常大的时候这个优化是更加显著的,我们用Python来做一个实验来看看就知道我们优化的效率有多高了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from time import *
def orginal_algorithm(a,b,c): #a^b%c
ans=1
a=a%c #预处理,防止出现a比c大的情况
for i in range(b):
ans=(ans*a)%c
return ans

def quick_algorithm(a,b,c):
a=a%c
ans=1
#这里我们不需要考虑b<0,因为分数没有取模运算
while b!=0:
if b&1:
ans=(ans*a)%c
b>>=1
a=(a*a)%c
return ans

time=clock()
a=eval(input("底数:"))
b=eval(input("指数:"))
c=eval(input("模:"))
print("朴素算法结果%d"%(orginal_algorithm(a,b,c)))
print("朴素算法耗时:%f"%(clock()-time))
time=clock()
print("快速幂算法结果%d"%(quick_algorithm(a,b,c)))
print("快速幂算法耗时:%f"%(clock()-time))

实验结果:

1
2
3
4
5
6
7
底数:5
指数:1003
模:12
朴素算法结果5
朴素算法耗时:3.289952
快速幂算法结果5
快速幂算法耗时:0.006706

我们现在知道了快速幂取模算法的强大了,我们现在来看核心原理:对于任何一个整数的模幂运算:a^b%c,对于b我们可以拆成二进制的形式:

1
b=b0+b1*2+b2*2^2+...+bn*2^n

这里我们的b0对应的是b二进制的第一位,那么我们的a^b运算就可以拆解成
1
a^b0*a^b1*2*...*a^(bn*2^n)

对于b来说,二进制位不是0就是1,那么对于bx为0的项我们的计算结果是1就不用考虑了,我们真正想要的其实是b的非0二进制位。那么假设除去了b的0的二进制位之后我们得到的式子是
1
a^(bx*2^x)*...*a(bn*2^n)

这里我们再应用我们一开始提到的公式,那么我们的a^b%c运算就可以转化为
1
(a^(bx*2^x)%c)*...*(a^(bn*2^n)%c)

这样的话,我们就很接近快速幂的本质了
1
(a^(bx*2^x)%c)*...*(a^(bn*2^n)%c)

我们会发现令
1
2
3
A1=(a^(bx*2^x)%c)
...
An=(a^(bn*2^n)%c)

这样的话,An始终是A(n-1)的平方倍(当然加进去了取模匀速那),依次递推。现在,我们基本的内容都已经了解到了,现在我们来考虑实现它:
1
2
3
4
5
6
7
8
9
10
11
12
int quick(int a,int b,int c)
{
int ans=1; //记录结果
a=a%c; //预处理,使得a处于c的数据范围之下
while(b!=0)
{
if(b&1) ans=(ans*a)%c; //如果b的二进制位不是0,那么我们的结果是要参与运算的
b>>=1; //二进制的移位操作,相当于每次除以2,用二进制看,就是我们不断的遍历b的二进制位
a=(a*a)%c; //不断的加倍
}
return ans;
}

现在,我们的快速幂已经讲完了。我们来大致的推演一下快速幂取模算法的时间复杂度。首先,我们会观察到,我们每次都是将b的规模缩小了2倍,那么很显然,原本的朴素的时间复杂度是O(n)。快速幂的时间复杂度就是O(logn)无限接近常熟的时间复杂度无疑逼朴素的时间复杂度优秀很多,在数据量越大的时候,者中优化效果越明显。

OJ例题

POJ1995题意:快速幂版题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include"iostream"
#include"cstdio"
#include"cstring"
#include"cstdlib"

using namespace std;

int ans=0;
int a,b;
int c;

int quick(int a,int b,int c)
{
int ans=1;
a=a%c;
while(b!=0)
{
if(b&1) ans=(ans*a)%c;
b>>=1;
a=(a*a)%c;
}
return ans;
}

int main()
{
int for_;
int t;
scanf("%d",&t);
while(t--)
{
ans=0;
scanf("%d%d",&c,&for_);
for(int i=1;i<=for_;i++)
{
scanf("%d%d",&a,&b);
ans=(ans+quick(a,b,c))%c;
}
printf("%d\n",ans);
}
return 0;
}

二叉树

  1. 所有非叶子结点至多拥有两个儿子(Left和Right);
  2. 所有结点存储一个关键字;
  3. 非叶子结点的左指针指向小于其关键字的子树,右指针指向大于其关键字的子树;

二叉树的搜索,从根结点开始,如果查询的关键字与结点的关键字相等,那么就命中;否则,如果查询关键字比结点关键字小,就进入左儿子;如果比结点关键字大,就进入右儿子;如果左儿子或右儿子的指针为空,则报告找不到相应的关键字;如果二叉树的所有非叶子结点的左右子树的结点数目均保持差不多(平衡),那么二叉树的搜索性能逼近二分查找;但它比连续内存空间的二分查找的优点是,改变二叉树结构(插入与删除结点)不需要移动大段的内存数据,甚至通常是常数开销;

但二叉树在经过多次插入与删除后,有可能导致不同的结构:

右边也是一个二叉树,但它的搜索性能已经是线性的了;同样的关键字集合有可能导致不同的树结构索引;所以,使用二叉树还要考虑尽可能让二叉树保持左图的结构,和避免右图的结构,也就是所谓的“平衡”问题;实际使用的二叉树都是在原二叉树的基础上加上平衡算法,即“平衡二叉树”;如何保持二叉树结点分布均匀的平衡算法是平衡二叉树的关键;平衡算法是一种在二叉树中插入和删除结点的策略。

1
2
3
4
5
6
7
class TreeNode{
int val;
//左孩子
TreeNode left;
//右孩子
TreeNode right;
}

二叉树的题目普遍可以用递归和迭代的方式来解

  1. 求二叉树的最大深度
1
2
3
4
5
6
7
8
int maxDeath(TreeNode node){
if(node==null){
return 0;
}
int left = maxDeath(node.left);
int right = maxDeath(node.right);
return Math.max(left,right) + 1;
}
  1. 求二叉树的最小深度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int getMinDepth(TreeNode root){
if(root == null){
return 0;
}
return getMin(root);
}
int getMin(TreeNode root){
if(root == null){
return Integer.MAX_VALUE;
}
if(root.left == null&&root.right == null){
return 1;
}
return Math.min(getMin(root.left),getMin(root.right)) + 1;
}
  1. 求二叉树中节点的个数
1
2
3
4
5
6
7
8
int numOfTreeNode(TreeNode root){
if(root == null){
return 0;
}
int left = numOfTreeNode(root.left);
int right = numOfTreeNode(root.right);
return left + right + 1;
}
  1. 求二叉树中叶子节点的个数
1
2
3
4
5
6
7
8
9
 int numsOfNoChildNode(TreeNode root){
if(root == null){
return 0;
}
if(root.left==null&&root.right==null){
return 1;
}
return numsOfNodeTreeNode(root.left)+numsOfNodeTreeNode(root.right);
}
  1. 求二叉树中第k层节点的个数
1
2
3
4
5
6
7
8
9
10
11
int numsOfkLevelTreeNode(TreeNode root,int k){
if(root == null||k<1){
return 0;
}
if(k==1){
return 1;
}
int numsLeft = numsOfkLevelTreeNode(root.left,k-1);
int numsRight = numsOfkLevelTreeNode(root.right,k-1);
return numsLeft + numsRight;
}
  1. 判断二叉树是否是平衡二叉树
1
2
3
4
5
6
7
8
9
10
11
12
13
14
boolean isBalanced(TreeNode node){
return maxDeath2(node)!=-1;
}
int maxDeath2(TreeNode node){
if(node == null){
return 0;
}
int left = maxDeath2(node.left);
int right = maxDeath2(node.right);
if(left==-1||right==-1||Math.abs(left-right)>1){
return -1;
}
return Math.max(left, right) + 1;
}
  1. 判断二叉树是否是完全二叉树
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
boolean isCompleteTreeNode(TreeNode root){
if(root == null){
return false;
}
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.add(root);
boolean result = true;
boolean hasNoChild = false;
while(!queue.isEmpty()){
TreeNode current = queue.remove();
if(hasNoChild){
if(current.left!=null||current.right!=null){
result = false;
break;
}
}else{
if(current.left!=null&&current.right!=null){
queue.add(current.left);
queue.add(current.right);
}else if(current.left!=null&&current.right==null){
queue.add(current.left);
hasNoChild = true;
}else if(current.left==null&&current.right!=null){
result = false;
break;
}else{
hasNoChild = true;
}
}
}
return result;
}
  1. 两个二叉树是否完全相同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
boolean isSameTreeNode(TreeNode t1,TreeNode t2){
if(t1==null&&t2==null){
return true;
}
else if(t1==null||t2==null){
return false;
}
if(t1.val != t2.val){
return false;
}
boolean left = isSameTreeNode(t1.left,t2.left);
boolean right = isSameTreeNode(t1.right,t2.right);
return left&&right;
}
  1. 两个二叉树是否互为镜像
1
2
3
4
5
6
7
8
9
10
11
12
 boolean isMirror(TreeNode t1,TreeNode t2){
if(t1==null&&t2==null){
return true;
}
if(t1==null||t2==null){
return false;
}
if(t1.val != t2.val){
return false;
}
return isMirror(t1.left,t2.right)&&isMirror(t1.right,t2.left);
}
  1. 翻转二叉树or镜像二叉树
1
2
3
4
5
6
7
8
9
10
  TreeNode mirrorTreeNode(TreeNode root){
if(root == null){
return null;
}
TreeNode left = mirrorTreeNode(root.left);
TreeNode right = mirrorTreeNode(root.right);
root.left = right;
root.right = left;
return root;
}
  1. 求两个二叉树的最低公共祖先节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
TreeNode getLastCommonParent(TreeNode root,TreeNode t1,TreeNode t2){
if(findNode(root.left,t1)){
if(findNode(root.right,t2)){
return root;
}else{
return getLastCommonParent(root.left,t1,t2);
}
}else{
if(findNode(root.left,t2)){
return root;
}else{
return getLastCommonParent(root.right,t1,t2)
}
}
}
// 查找节点node是否在当前 二叉树中
boolean findNode(TreeNode root,TreeNode node){
if(root == null || node == null){
return false;
}
if(root == node){
return true;
}
boolean found = findNode(root.left,node);
if(!found){
found = findNode(root.right,node);
}
return found;
}
  1. 二叉树的前序遍历

迭代解法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
  ArrayList<Integer> preOrder(TreeNode root){
Stack<TreeNode> stack = new Stack<TreeNode>();
ArrayList<Integer> list = new ArrayList<Integer>();
if(root == null){
return list;
}
stack.push(root);
while(!stack.empty()){
TreeNode node = stack.pop();
list.add(node.val);
if(node.right!=null){
stack.push(node.right);
}
if(node.left != null){
stack.push(node.left);
}
}
return list;
}
```C++

递归解法
```C++
ArrayList<Integer> preOrderReverse(TreeNode root){
ArrayList<Integer> result = new ArrayList<Integer>();
preOrder2(root,result);
return result;
}
void preOrder2(TreeNode root,ArrayList<Integer> result){
if(root == null){
return;
}
result.add(root.val);
preOrder2(root.left,result);
preOrder2(root.right,result);
}
  1. 二叉树的中序遍历
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ArrayList<Integer> inOrder(TreeNode root){
ArrayList<Integer> list = new ArrayList<<Integer>();
Stack<TreeNode> stack = new Stack<TreeNode>();
TreeNode current = root;
while(current != null|| !stack.empty()){
while(current != null){
stack.add(current);
current = current.left;
}
current = stack.peek();
stack.pop();
list.add(current.val);
current = current.right;
}
return list;
}
  1. 二叉树的后序遍历
1
2
3
4
5
6
7
8
9
10
ArrayList<Integer> postOrder(TreeNode root){
ArrayList<Integer> list = new ArrayList<Integer>();
if(root == null){
return list;
}
list.addAll(postOrder(root.left));
list.addAll(postOrder(root.right));
list.add(root.val);
return list;
}
  1. 前序遍历和后序遍历构造二叉树
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
TreeNode buildTreeNode(int[] preorder,int[] inorder){
if(preorder.length!=inorder.length){
return null;
}
return myBuildTree(inorder,0,inorder.length-1,preorder,0,preorder.length-1);
}
TreeNode myBuildTree(int[] inorder,int instart,int inend,int[] preorder,int prestart,int preend){
if(instart>inend){
return null;
}
TreeNode root = new TreeNode(preorder[prestart]);
int position = findPosition(inorder,instart,inend,preorder[start]);
root.left = myBuildTree(inorder,instart,position-1,preorder,prestart+1,prestart+position-instart);
root.right = myBuildTree(inorder,position+1,inend,preorder,position-inend+preend+1,preend);
return root;
}
int findPosition(int[] arr,int start,int end,int key){
int i;
for(i = start;i<=end;i++){
if(arr[i] == key){
return i;
}
}
return -1;
}
  1. 在二叉树中插入节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
TreeNode insertNode(TreeNode root,TreeNode node){
if(root == node){
return node;
}
TreeNode tmp = new TreeNode();
tmp = root;
TreeNode last = null;
while(tmp!=null){
last = tmp;
if(tmp.val>node.val){
tmp = tmp.left;
}else{
tmp = tmp.right;
}
}
if(last!=null){
if(last.val>node.val){
last.left = node;
}else{
last.right = node;
}
}
return root;
}
  1. 输入一个二叉树和一个整数,打印出二叉树中节点值的和等于输入整数所有的路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void findPath(TreeNode r,int i){
if(root == null){
return;
}
Stack<Integer> stack = new Stack<Integer>();
int currentSum = 0;
findPath(r, i, stack, currentSum);
}
void findPath(TreeNode r,int i,Stack<Integer> stack,int currentSum){
currentSum+=r.val;
stack.push(r.val);
if(r.left==null&&r.right==null){
if(currentSum==i){
for(int path:stack){
System.out.println(path);
}
}
}
if(r.left!=null){
findPath(r.left, i, stack, currentSum);
}
if(r.right!=null){
findPath(r.right, i, stack, currentSum);
}
stack.pop();
}
  1. 二叉树的搜索区间
    给定两个值 k1 和 k2(k1 < k2)和一个二叉查找树的根节点。找到树中所有值在 k1 到 k2 范围内的节点。即打印所有x (k1 <= x <= k2) 其中 x 是二叉查找树的中的节点值。返回所有升序的节点值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ArrayList<Integer> result;
ArrayList<Integer> searchRange(TreeNode root,int k1,int k2){
result = new ArrayList<Integer>();
searchHelper(root,k1,k2);
return result;
}
void searchHelper(TreeNode root,int k1,int k2){
if(root == null){
return;
}
if(root.val>k1){
searchHelper(root.left,k1,k2);
}
if(root.val>=k1&&root.val<=k2){
result.add(root.val);
}
if(root.val<k2){
searchHelper(root.right,k1,k2);
}
}
  1. 二叉树的层次遍历
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ArrayList<ArrayList<Integer>> levelOrder(TreeNode root){
ArrayList<ArrayList<Integer>> result = new ArrayList<ArrayList<Integer>>();
if(root == null){
return result;
}
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
while(!queue.isEmpty()){
int size = queue.size();
ArrayList<<Integer> level = new ArrayList<Integer>():
for(int i = 0;i < size ;i++){
TreeNode node = queue.poll();
level.add(node.val);
if(node.left != null){
queue.offer(node.left);
}
if(node.right != null){
queue.offer(node.right);
}
}
result.add(Level);
}
return result;
}
  1. 二叉树内两个节点的最长距离
    二叉树中两个节点的最长距离可能有三种情况:
  • 左子树的最大深度+右子树的最大深度为二叉树的最长距离
  • 左子树中的最长距离即为二叉树的最长距离
  • 右子树种的最长距离即为二叉树的最长距离

因此,递归求解即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 private static class Result{  
int maxDistance;
int maxDepth;
public Result() {
}
public Result(int maxDistance, int maxDepth) {
this.maxDistance = maxDistance;
this.maxDepth = maxDepth;
}
}
int getMaxDistance(TreeNode root){
return getMaxDistanceResult(root).maxDistance;
}
Result getMaxDistanceResult(TreeNode root){
if(root == null){
Result empty = new Result(0,-1);
return empty;
}
Result lmd = getMaxDistanceResult(root.left);
Result rmd = getMaxDistanceResult(root.right);
Result result = new Result();
result.maxDepth = Math.max(lmd.maxDepth,rmd.maxDepth) + 1;
result.maxDistance = Math.max(lmd.maxDepth + rmd.maxDepth,Math.max(lmd.maxDistance,rmd.maxDistance));
return result;
}
  1. 不同的二叉树
    给出 n,问由 1…n 为节点组成的不同的二叉查找树有多少种?
1
2
3
4
5
6
7
8
9
10
11
int numTrees(int n ){
int[] counts = new int[n+2];
counts[0] = 1;
counts[1] = 1;
for(int i = 2;i<=n;i++){
for(int j = 0;j<i;j++){
counts[i] += counts[j] * counts[i-j-1];
}
}
return counts[n];
}
  1. 判断二叉树是否是合法的二叉查找树(BST)
    一棵BST定义为:
  • 节点的左子树中的值要严格小于该节点的值。
  • 节点的右子树中的值要严格大于该节点的值。
  • 左右子树也必须是二叉查找树。

一个节点的树也是二叉查找树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int lastVal = Integer.MAX_VALUE;
public boolean firstNode = true;
public boolean isValidBST(TreeNode root) {
// write your code here
if(root==null){
return true;
}
if(!isValidBST(root.left)){
return false;
}
if(!firstNode&&lastVal >= root.val){
return false;
}
firstNode = false;
lastVal = root.val;
if (!isValidBST(root.right)) {
return false;
}
return true;
}

树转换为二叉树

  1. 加线。在所有兄弟结点之间加一条连线。
  2. 去线。树中的每个结点,只保留它与第一个孩子结点的连线,删除它与其它孩子结点之间的连线。
  3. 层次调整。以树的根节点为轴心,将整棵树顺时针旋转一定角度,使之结构层次分明。(注意第一个孩子是结点的左孩子,兄弟转换过来的孩子是结点的右孩子)

森林转换为二叉树

  1. 把每棵树转换为二叉树。
  2. 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。

二叉树转换为树

是树转换为二叉树的逆过程。

  1. 加线。若某结点X的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点…,都作为结点X的孩子。将结点X与这些右孩子结点用线连接起来。
  2. 去线。删除原二叉树中所有结点与其右孩子结点的连线。
  3. 层次调整。

二叉树转换为森林

假如一棵二叉树的根节点有右孩子,则这棵二叉树能够转换为森林,否则将转换为一棵树。

  1. 从根节点开始,若右孩子存在,则把与右孩子结点的连线删除。再查看分离后的二叉树,若其根节点的右孩子存在,则连线删除…。直到所有这些根节点与右孩子的连线都删除为止。
  2. 将每棵分离后的二叉树转换为树。

2-3 树

2-3 树的定义如下:

  1. 2-3 树要么为空要么具有以下性质:
  2. 对于 2- 节点,和普通的 BST 节点一样,有一个数据域和两个子节点指针,两个子节点要么为空,要么也是一个2-3树,当前节点的数据的值要大于左子树中所有节点的数据,要小于右子树中所有节点的数据。
  3. 对于 3- 节点,有两个数据域 a 和 b 和三个子节点指针,左子树中所有的节点数据要小于a,中子树中所有节点数据要大于 a 而小于 b ,右子树中所有节点数据要大于 b 。

例如图 2.1 所示的树为一棵 2-3 树:

2-3 树性质

性质:

  1. 对于每一个结点有 1 或者 2 个关键码。
  2. 当节点有一个关键码的时,节点有 2 个子树。
  3. 当节点有 2 个关键码时,节点有 3 个子树。
  4. 所有叶子点都在树的同一层。

2-3树查找

2-3 树的查找类似二叉搜索树的查找过程,根据键值的比较来决定查找的方向。

例如在图 2.1 所示的 2-3 树中查找键为H的节点:

例如在图 2.1 所示的 2-3 树中查找键为 B 的节点:

2-3树插入

在树的插入之前需要对带插入的节点进行一次查找操作,若树中已经有此节点则不予插入,若没有查找到此节点则记录未命中查找结束时访问的最后一个节点。

空树的插入最简单,创建一个节点即可,这里不予赘述。

对于非空树插入主要分为 4 种情况:

  1. 向 2- 节点中插入新节点
  2. 向一棵只含 3- 节点的树中插入新节点
  3. 向一个父节点为 2- 节点的 3- 节点中插入新节点
  4. 向一个父节点为 3- 节点的 3- 节点中插入新节点

向2-节点中插入新节点的操作步骤:如果未命中查找结束于一个 2-节点,直接将 2- 节点替换为一个 3- 节点,并将要插入的键保存在其中。

图解:

向一棵只含 3- 节点的树中插入新节点的操作步骤:先临时将新键存入唯一的 3- 节点中,使其成为一个 4- 节点,再将它转化为一颗由 3 个 2- 节点组成的 2-3 树,分解后树高会增加 1。

图解:

向一个父节点为 2- 节点的 3- 节点中插入新节点的操作步骤:先构造一个临时的 4- 节点并将其分解,分解时将中键移动到父节点中(中键移动后,其父节点中的位置由键的大小确定)

图解:

向一个父节点为3-节点的3-节点中插入新节点的操作步骤:插入节点后一直向上分解构造的临时4-节点并将中键移动到更高层双亲节点,直到遇到一个-2节点并将其替换为一个不需要继续分解的3-节点,或是到达树根(3-节点)。

图解:

分解根节点
操作步骤:如果从插入节点到根节点的路径上全是3-节点(包含根节点在内),根节点将最终被替换为一个临时的4-节点,将临时的4-节点分解为3个2-节点,分解后树高会增加1。

图解:

2-3树删除

删除之前,先要对2-3树进行一次命中的查找,查找成功才可以进行删除操作。删除节点大概分为3种情形

  1. 删除非叶子节点。
  2. 删除不为2-节点的叶子节点。
  3. 删除为2-节点的叶子节点。

删除非叶子节点

操作步骤:使用中序遍历下的直接后继节点key来覆盖当前待删除节点key,再删除用来覆盖的后继节点key。

图解:

删除不为2-节点的叶子节点操作步骤:删除不为2-节点的叶子节点,直接删除节点即可。**

图解:

删除为2-节点的叶子节点
删除为2-节点的叶子节点的步骤相对复杂,删除节点后需要做出相应判断,并根据判断结果调整树结构。主要分为四种情形:

删除节点为2-节点,父节点为2-节点,兄弟节点为3-节点的操作步骤:当前待删除节点的父节点是2-节点、兄弟节点是3-节点,将父节点移动到当前待删除节点位置,再将兄弟节点中最接近当前位置的key移动到父节点中。

图解:

删除节点为2-节点,父节点为2-节点,兄弟节点为2-节点的操作步骤:当前待删除节点的父节点是2-节点、兄弟节点也是2-节点,先通过移动兄弟节点的中序遍历直接后驱到兄弟节点,以使兄弟节点变为3-节点;再进行6.3.1的操作。

图解:

删除节点为2-节点,父节点为3-节点的操作步骤:当前待删除节点的父节点是3-节点,拆分父节点使其成为2-节点,再将再将父节点中最接近的一个拆分key与中孩子合并,将合并后的节点作为当前节点。

图解:

2-3树为满二叉树,删除叶子节点的操作步骤:若2-3树是一颗满二叉树,将2-3树层树减少,并将当前删除节点的兄弟节点合并到父节点中,同时将父节点的所有兄弟节点合并到父节点的父节点中,如果生成了4-节点,再分解4-节点。

图解:

2-3-4树

2-3-4树是对2-3树的概念扩展,包括了4节点的使用。一个4节点中包含小中大三个元素和四个孩子(要么有四个孩子要么没有,不存在其他情况),如果某个4节点有孩子的话,左子树包含小于最小元素的元素;第二子树包含大于最小元素,小于第二元素的元素;第三子树包含大于第二元素,小于最大元素的元素;右子树包含大于最大元素的元素。

总结

先找插入结点,若结点有空(即2-结点),则直接插入。如结点没空(即3-结点),则插入使其临时容纳这个元素,然后分裂此结点,把中间元素移到其父结点中。对父结点亦如此处理。(中键一直往上移,直到找到空位,在此过程中没有空位就先搞个临时的,再分裂。)

2-3树插入算法的根本在于这些变换都是局部的:除了相关的结点和链接之外不必修改或者检查树的其他部分。每次变换中,变更的链接数量不会超过一个很小的常数。所有局部变换都不会影响整棵树的有序性和平衡性。

同时,通过上面树的深度增加的例子,可以看出2-3树和标准二叉树不同,标准的二叉树的的深度是由上到下的增加的,而2-3树的深度生长是由下至上的。

B-树

定义:B-树是一类树,包括B-树、B+树、B*树等,是一棵自平衡的搜索树,它类似普通的平衡二叉树,不同的一点是B-树允许每个节点有更多的子节点。

一个 m 阶的B树满足以下条件:

  • 每个结点至多拥有m棵子树;
  • 根结点至少拥有两颗子树(存在子树的情况下);
  • 除了根结点以外,其余每个分支结点至少拥有 m/2 棵子树;
  • 所有的叶结点都在同一层上;
  • 有 k 棵子树的分支结点则存在 k-1 个关键码,关键码按照递增次序进行排列;
  • 关键字数量需要满足ceil(m/2)-1 <= n <= m-1;

B树上大部分的操作所需要的磁盘存取次数和B树的高度是成正比的,在B树中可以检查多个子结点,由于在一棵树中检查任意一个结点都需要一次磁盘访问,所以B树避免了大量的磁盘访问。

B-树是专门为外部存储器设计的,如磁盘,它对于读取和写入大块数据有良好的性能,所以一般被用在文件系统及数据库中。

定义只需要知道B-树允许每个节点有更多的子节点即可(多叉树)。子节点数量一般在上千,具体数量依赖外部存储器的特性。

先来看看为什么会出现B-树这类数据结构。

传统用来搜索的平衡二叉树有很多,如 AVL 树,红黑树等。这些树在一般情况下查询性能非常好,但当数据非常大的时候它们就无能为力了。原因当数据量非常大时,内存不够用,大部分数据只能存放在磁盘上,只有需要的数据才加载到内存中。一般而言内存访问的时间约为 50 ns,而磁盘在 10 ms 左右。速度相差了近 5 个数量级,磁盘读取时间远远超过了数据在内存中比较的时间。这说明程序大部分时间会阻塞在磁盘 IO 上。那么我们如何提高程序性能?减少磁盘 IO 次数,像 AVL 树,红黑树这类平衡二叉树从设计上无法“迎合”磁盘。

平衡二叉树是通过旋转来保持平衡的,而旋转是对整棵树的操作,若部分加载到内存中则无法完成旋转操作。其次平衡二叉树的高度相对较大为 log n(底数为2),这样逻辑上很近的节点实际可能非常远,无法很好的利用磁盘预读(局部性原理),所以这类平衡二叉树在数据库和文件系统上的选择就被 pass 了。

空间局部性原理:如果一个存储器的某个位置被访问,那么将它附近的位置也会被访问。

我们从“迎合”磁盘的角度来看看B-树的设计。

索引的效率依赖与磁盘 IO 的次数,快速索引需要有效的减少磁盘 IO 次数,如何快速索引呢?索引的原理其实是不断的缩小查找范围,就如我们平时用字典查单词一样,先找首字母缩小范围,再第二个字母等等。平衡二叉树是每次将范围分割为两个区间。为了更快,B-树每次将范围分割为多个区间,区间越多,定位数据越快越精确。那么如果节点为区间范围,每个节点就较大了。所以新建节点时,直接申请页大小的空间(磁盘存储单位是按 block 分的,一般为 512 Byte。磁盘 IO 一次读取若干个 block,我们称为一页,具体大小和操作系统有关,一般为 4 k,8 k或 16 k),计算机内存分配是按页对齐的,这样就实现了一个节点只需要一次 IO。

多叉的好处非常明显,有效的降低了B-树的高度,为底数很大的 log n,底数大小与节点的子节点数目有关,一般一棵B-树的高度在 3 层左右。层数低,每个节点区确定的范围更精确,范围缩小的速度越快(比二叉树深层次的搜索肯定快很多)。上面说了一个节点需要进行一次 IO,那么总 IO 的次数就缩减为了 log n 次。B-树的每个节点是 n 个有序的序列(a1,a2,a3…an),并将该节点的子节点分割成 n+1 个区间来进行索引(X1< a1, a2 < X2 < a3, … , an+1 < Xn < anXn+1 > an)。

点评:B树的每个节点,都是存多个值的,不像二叉树那样,一个节点就一个值,B树把每个节点都给了一点的范围区间,区间更多的情况下,搜索也就更快了,比如:有1-100个数,二叉树一次只能分两个范围,0-50和51-100,而B树,分成4个范围 1-25, 25-50,51-75,76-100一次就能筛选走四分之三的数据。所以作为多叉树的B树是更快的。

插入

新结点一般插在第h层,通过搜索找到对应的结点进行插入,那么根据即将插入的结点的数量又分为下面几种情况。

如果该结点的关键字个数没有到达m-1个,那么直接插入即可;

如果该结点的关键字个数已经到达了m-1个,那么根据B树的性质显然无法满足,需要将其进行分裂。分裂的规则是该结点分成两半,将中间的关键字进行提升,加入到父亲结点中,但是这又可能存在父亲结点也满员的情况,则不得不向上进行回溯,甚至是要对根结点进行分裂,那么整棵树都加了一层。

其过程如下:

删除

同样的,我们需要先通过搜索找到相应的值,存在则进行删除,需要考虑删除以后的情况,

  • 如果该结点拥有关键字数量仍然满足B树性质,则不做任何处理;
  • 如果该结点在删除关键字以后不满足B树的性质(关键字没有到达ceil(m/2)-1的数量),则需要向兄弟结点借关键字,这有分为兄弟结点的关键字数量是否足够的情况。
  • 如果兄弟结点的关键字足够借给该结点,则过程为将父亲结点的关键字下移,兄弟结点的关键字上移;
  • 如果兄弟结点的关键字在借出去以后也无法满足情况,即之前兄弟结点的关键字的数量为ceil(m/2)-1,借的一方的关键字数量为ceil(m/2)-2的情况,那么我们可以将该结点合并到兄弟结点中,合并之后的子结点数量少了一个,则需要将父亲结点的关键字下放,如果父亲结点不满足性质,则向上回溯;
  • 其余情况参照BST中的删除。

其过程如下:

B-树是一种多路搜索树(并不是二叉的):

  1. 定义任意非叶子结点最多只有M个儿子;且M>2;
  2. 根结点的儿子数为[2, M];
  3. 除根结点以外的非叶子结点的儿子数为[M/2, M];
  4. 每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个关键字)
  5. 非叶子结点的关键字个数=指向儿子的指针个数-1;
  6. 非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
  7. 非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树;
  8. 所有叶子结点位于同一层;

如:(M=3)

来模拟下查找文件29的过程:

  1. 根据根结点指针找到文件目录的根磁盘块1,将其中的信息导入内存。【磁盘IO操作1次】
  2. 此时内存中有两个文件名17,35和三个存储其他磁盘页面地址的数据。根据算法我们发现17<29<35,因此我们找到指针p2。
  3. 根据p2指针,我们定位到磁盘块3,并将其中的信息导入内存。【磁盘IO操作2次】
  4. 此时内存中有两个文件名26,30和三个存储其他磁盘页面地址的数据。根据算法我们发现26<29<30,因此我们找到指针p2。
  5. 根据p2指针,我们定位到磁盘块8,并将其中的信息导入内存。【磁盘IO操作3次】
  6. 此时内存中有两个文件名28,29。根据算法我们查找到文件29,并定位了该文件内存的磁盘地址。

生成从空树开始,逐个插入关键字。但是由于B-树节点关键字必须大于等于[ceil(m/2)-1],所以每次插入一个关键字不是在树中添加一个叶子结点,而是首先在最底层的某个非终端节点中添加一个“关键字”,该结点的关键字不超过m-1,则插入完成;否则要产生结点的“分裂”,将一半数量的关键字元素分裂到新的其相邻右结点中,中间关键字元素上移到父结点中。

1、咱们通过一个实例来逐步讲解下。插入以下字符字母到一棵空的B 树中(非根结点关键字数小了(小于2个)就合并,大了(超过4个)就分裂):C N G A H E K Q M F W L T Z D P R X Y S,首先,结点空间足够,4个字母插入相同的结点中,如下图:

2、当咱们试着插入H时,结点发现空间不够,以致将其分裂成2个结点,移动中间元素G上移到新的根结点中,在实现过程中,咱们把A和C留在当前结点中,而H和N放置新的其右邻居结点中。如下图:

3、当咱们插入E,K,Q时,不需要任何分裂操作

4、插入M需要一次分裂,注意M恰好是中间关键字元素,以致向上移到父节点中

5、插入F,W,L,T不需要任何分裂操作

6、插入Z时,最右的叶子结点空间满了,需要进行分裂操作,中间元素T上移到父节点中,注意通过上移中间元素,树最终还是保持平衡,分裂结果的结点存在2个关键字元素。

7、插入D时,导致最左边的叶子结点被分裂,D恰好也是中间元素,上移到父节点中,然后字母P,R,X,Y陆续插入不需要任何分裂操作(别忘了,树中至多5个孩子)。

8、最后,当插入S时,含有N,P,Q,R的结点需要分裂,把中间元素Q上移到父节点中,但是情况来了,父节点中空间已经满了,所以也要进行分裂,将父节点中的中间元素M上移到新形成的根结点中,注意以前在父节点中的第三个指针在修改后包括D和G节点中。这样具体插入操作的完成。

删除操作

首先查找B树中需删除的元素,如果该元素在B树中存在,则将该元素在其结点中进行删除,如果删除该元素后,首先判断该元素是否有左右孩子结点,如果有,则上移孩子结点中的某相近元素到父节点中,然后是移动之后的情况;如果没有,直接删除后,移动之后的情况。

删除元素,移动相应元素之后,如果某结点中元素数目(即关键字数)小于ceil(m/2)-1,则需要看其某相邻兄弟结点是否丰满(结点中元素个数大于ceil(m/2)-1)(还记得第一节中关于B树的第5个特性中的c点么?:c)除根结点之外的结点(包括叶子结点)的关键字的个数n必须满足:(ceil(m / 2)-1) <= n <= m-1。m表示最多含有m个孩子,n表示关键字数。在本小节中举的一颗B树的示例中,关键字数n满足:2<=n<=4),如果丰满,则向父节点借一个元素来满足条件;如果其相邻兄弟都刚脱贫,即借了之后其结点数目小于ceil(m/2)-1,则该结点与其相邻的某一兄弟结点进行“合并”成一个结点,以此来满足条件。那咱们通过下面实例来详细了解吧。

以上述插入操作构造的一棵5阶B树(树中最多含有m(m=5)个孩子,因此关键字数最小为ceil(m / 2)-1=2。还是这句话,关键字数小了(小于2个)就合并,大了(超过4个)就分裂)为例,依次删除H,T,R,E。

1、首先删除元素H,当然首先查找H,H在一个叶子结点中,且该叶子结点元素数目3大于最小元素数目ceil(m/2)-1=2,则操作很简单,咱们只需要移动K至原来H的位置,移动L至K的位置(也就是结点中删除元素后面的元素向前移动)

2、下一步,删除T,因为T没有在叶子结点中,而是在中间结点中找到,咱们发现他的继承者W(字母升序的下个元素),将W上移到T的位置,然后将原包含W的孩子结点中的W进行删除,这里恰好删除W后,该孩子结点中元素个数大于2,无需进行合并操作。

3、下一步删除R,R在叶子结点中,但是该结点中元素数目为2,删除导致只有1个元素,已经小于最小元素数目ceil(5/2)-1=2,而由前面我们已经知道:如果其某个相邻兄弟结点中比较丰满(元素个数大于ceil(5/2)-1=2),则可以向父结点借一个元素,然后将最丰满的相邻兄弟结点中上移最后或最前一个元素到父节点中(有没有看到红黑树中左旋操作的影子?),在这个实例中,右相邻兄弟结点中比较丰满(3个元素大于2),所以先向父节点借一个元素W下移到该叶子结点中,代替原来S的位置,S前移;然后X在相邻右兄弟结点中上移到父结点中,最后在相邻右兄弟结点中删除X,后面元素前移。

4、最后一步删除E,删除后会导致很多问题,因为E所在的结点数目刚好达标,刚好满足最小元素个数(ceil(5/2)-1=2),而相邻的兄弟结点也是同样的情况,删除一个元素都不能满足条件,所以需要该节点与某相邻兄弟结点进行合并操作;首先移动父结点中的元素(该元素在两个需要合并的两个结点元素之间)下移到其子结点中,然后将这两个结点进行合并成一个结点。所以在该实例中,咱们首先将父节点中的元素D下移到已经删除E而只有F的结点中,然后将含有D和F的结点和含有A,C的相邻兄弟结点进行合并成一个结点。

5、也许你认为这样删除操作已经结束了,其实不然,在看看上图,对于这种特殊情况,你立即会发现父节点只包含一个元素G,没达标(因为非根节点包括叶子结点的关键字数n必须满足于2=<n<=4,而此处的n=1),这是不能够接受的。如果这个问题结点的相邻兄弟比较丰满,则可以向父结点借一个元素。假设这时右兄弟结点(含有Q,X)有一个以上的元素(Q右边还有元素),然后咱们将M下移到元素很少的子结点中,将Q上移到M的位置,这时,Q的左子树将变成M的右子树,也就是含有N,P结点被依附在M的右指针上。所以在这个实例中,咱们没有办法去借一个元素,只能与兄弟结点进行合并成一个结点,而根结点中的唯一元素M下移到子结点,这样,树的高度减少一层。

为了进一步详细讨论删除的情况,再举另外一个实例:这里是一棵不同的5序B树,那咱们试着删除C

于是将删除元素C的右子结点中的D元素上移到C的位置,但是出现上移元素后,只有一个元素的结点的情况。

又因为含有E的结点,其相邻兄弟结点才刚脱贫(最少元素个数为2),不可能向父节点借元素,所以只能进行合并操作,于是这里将含有A,B的左兄弟结点和含有E的结点进行合并成一个结点。

这样又出现只含有一个元素F结点的情况,这时,其相邻的兄弟结点是丰满的(元素个数为3>最小元素个数2),这样就可以想父结点借元素了,把父结点中的J下移到该结点中,相应的如果结点中J后有元素则前移,然后相邻兄弟结点中的第一个元素(或者最后一个元素)上移到父节点中,后面的元素(或者前面的元素)前移(或者后移);注意含有K,L的结点以前依附在M的左边,现在变为依附在J的右边。这样每个结点都满足B树结构性质。

从以上操作可看出:除根结点之外的结点(包括叶子结点)的关键字的个数n满足:(ceil(m / 2)-1) <= n <= m-1,即2<=n<=4。这也佐证了咱们之前的观点。删除操作完。

B-树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点;

B-树的特性:

  1. 关键字集合分布在整颗树中;
  2. 任何一个关键字出现且只出现在一个结点中;
  3. 搜索有可能在非叶子结点结束;
  4. 其搜索性能等价于在关键字全集内做一次二分查找;
  5. 自动层次控制;

由于限制了除根结点以外的非叶子结点,至少含有M/2个儿子,确保了结点的至少利用率,其最底搜索性能为:

其中,M为设定的非叶子结点最多子树个数,N为关键字总数;所以B-树的性能总是等价于二分查找(与M值无关),也就没有B树平衡的问题;由于M/2的限制,在插入结点时,如果结点已满,需要将结点分裂为两个各占M/2的结点;删除结点时,需将两个不足M/2的兄弟结点合并;

B+树

B+树是B-树的变体,也是一种多路搜索树:

  1. 其定义基本与B-树同,除了:
  2. 非叶子结点的子树指针与关键字个数相同;
  3. 非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间);
  4. 为所有叶子结点增加一个链指针;
  5. 所有关键字都在叶子结点出现;

B+的搜索与B-树也基本相同,区别是B+树只有达到叶子结点才命中(B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找。B+树的主要优点:非终端结点仅仅起高层索引作用,而B树非终端结点的关键字除作子树分界外,本身还是实际记录的有效关键字(含记录指针),因此相同的结点空间,B+树可以设计的阶树比B树大,相同的索引,B+树的索引层数比B树少,因此检索速度比B树快。此外,B+树叶子结点包含完整的索引信息,可以较方便地表示文件的稀疏索引。最后,B+树的检索、插入和删除都在叶子结点进行,比B树相对简单。

B+的特性:

  1. 所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;
  2. 不可能在非叶子结点命中;
  3. 非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;
  4. 更适合文件索引系统;

B树和B+树的区别是由于B+树和B具有这不同的存储结构所造成的区别,以一个m阶树为例。

  • 关键字的数量不同;B+树中分支结点有m个关键字,其叶子结点也有m个,其关键字只是起到了一个索引的作用,但是B树虽然也有m个子结点,但是其只拥有m-1个关键字。
  • 存储的位置不同;B+树中的数据都存储在叶子结点上,也就是其所有叶子结点的数据组合起来就是完整的数据,但是B树的数据存储在每一个结点中,并不仅仅存储在叶子结点上。
  • 分支结点的构造不同;B+树的分支结点仅仅存储着关键字信息和儿子的指针(这里的指针指的是磁盘块的偏移量),也就是说内部结点仅仅包含着索引信息。
  • 查询不同;B树在找到具体的数值以后,则结束,而B+树则需要通过索引找到叶子结点中的数据才结束,也就是说B+树的搜索过程中走了一条从根结点到叶子结点的路径。

B*树

是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针;

B*树定义了非叶子结点关键字个数至少为(2/3)*M,即块的最低使用率为2/3(代替B+树的1/2);

B+树的分裂:当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针;

B*树的分裂:当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针;

所以,B*树分配新结点的概率比B+树要低,空间使用率更高;

实现

根据B树的特点,我们首先可以写出B树的整体的结构。

B树结构

B树的结构我们定义需要参考规则,我们首先是需要给出保存键值的一个数组,这个数组的大小取决与我们定义的M,然后我们根据规则,可以得到一个保存M+1个子的一个数组,然后当然为了方便访问,parent指针,然后要有一个记录每个节点中键值个数的一个size。

所以定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename K,int M>
struct BTreeNode
{
K _keys[M]; //用来保存键值。
BTreeNode<K, M>* _sub[M + 1]; //用来保存子。
BTreeNode<K, M>* _parent;
size_t _size;
BTreeNode()
:_parent(NULL)
, _size(0)
{
int i = 0;
for ( i = 0; i < M; i++)
{
_keys[i] = K();
_sub[i] = K();
}
_sub[i] = K();
}
};

B树的查找

对于AVL,BST,红黑树,B树这些高级的数据结构而言,查找算法是非常重要的。我们首先确定返回值,对于这种关于key和key-value的数据结构,参考map和set,我们让它返回一个pair的一个结构体。

pair结构体的定义在std中是

1
2
3
4
5
6
template<typename K,typename V>
struct pair
{
K key;
V value;
}

我们只需要让这个里面的value变为bool值,value返回以后说明的是存不存就可以了。

接下来的思路就是从根节点进行和这个节点当中的每一个key比较,如果=那么就返回找到了,如果小于,那么就到这个节点左面的子节点中找,如果大了,就继续向后面的键值进行查找。如果相等那么就返回。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
pair <Node*, int> Find(const K &key)
{
Node* cur = _root;
Node* parent = NULL;
while (cur)
{
size_t i = 0;
while (i < cur->_size)
{
//如果小于当前,向后
if (cur->_keys[i] < key)
{
i++;
}
//如果大于,
else if (cur->_keys[i]>key)
{
cur = cur->_sub[i];
parent = cur;
break;
}
//相等,返回这个节点
else
{
return pair<Node *, int>(NULL, -1);
}

}
if (key > cur->_sub[i + 1])
{
cur = cur->_sub[i];
}

//为了防止出现我返回空指针操作,如果是空指针,那么就返回父亲
if (cur != NULL && i == cur->_size)
{
parent = cur;
cur = cur->_sub[i];
}

}
return pair<Node *, int>(parent, 1);
}

B树的插入

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
bool Insert(const K &key)
{
//首先来考虑空树的情况
if (_root == NULL)
{
//给这个节点中添加key,并且让size++。
_root = new Node;
_root->_keys[0] = key;
_root->_size++;
return true;
}
//使用通用的key-value结构体来保存找到的key所在的节点。

pair<Node*,int > ret=Find(key);

//在这里来看这个节点是否存在,存在就直接return false。
if (ret.second == -1)
{
return false;
}

Node* cur = ret.first;
K newKey = key;
Node *sub = NULL;
//此时表示考虑插入。
while (1)
{
//向cur里面进行插入,如果没满插入,满了就进行分裂。
InsetKey(cur, newKey, sub);

//小于M,这样就可以直接插入
if (cur->_size < M)
{
return true;
}

//如果==M,那么就应该进行分裂
//首先找到中间的节点
size_t mid = cur->_size / 2;
//创建一个节点,用来保存中间节点右边所有的节点和子节点。
Node * tmp = new Node;

size_t j = 0;
//进行移动sub以及所有的子接点。
for (size_t i = mid + 1; i < cur->_size; i++)
{
tmp->_keys[j] = cur->_keys[i];
cur->_keys[i] = K();
cur->_size--;
tmp->_size++;
j++;
}

//移动子串
for (j = 0; j < tmp->_size + 1; j++)
{
tmp->_sub[j] = cur->_sub[mid + 1 + j];
if (tmp->_sub[j])
{
tmp->_sub[j]->_parent = tmp;
}
cur->_sub[mid + 1 + j] = NULL;
}

//进行其他的移动
//分裂的条件就是要么分裂根,要么就是分裂子节点,要么就是所在节点的节点数小于M。

//考虑根分裂,分裂的时候创建节点,然后把中间节点上拉,记得要更改最后的parent
if (cur->_parent == NULL)
{
_root = new Node();
_root->_keys[0] = cur->_keys[mid];
cur->_keys[mid] = K();
cur->_size--;
_root->_size++;

_root->_sub[0] = cur;
cur->_parent = _root;

_root->_sub[1] = tmp;
tmp->_parent = _root;
return true;
}
//分裂如果不是根节点,那么就把mid节点插入到上一层节点中,然后看上一层节点是否要分裂。注意修改cur和sub

else
{
newKey = cur->_keys[mid];
cur->_keys[mid] = K();
cur->_size--;
cur = cur->_parent;

sub = tmp;
sub->_parent = cur;

}
}

}
void InsetKey(Node* cur, const K &key, Node* sub)
{
int i = cur->_size - 1;
while (i>=0)
{
//进行插入
if (key > cur->_keys[i])
{
break;
}
//进行移动
else
{
cur->_keys[i + 1] = cur->_keys[i];
cur->_sub[i + 2] = cur->_sub[i + 1];
}
i--;
}
//进行插入
cur->_keys[i + 1] = key;
//插入子
cur->_sub[i + 2] = sub;

//如果没满,只需要对size++;
if (cur->_size < M)
{
cur->_size++;
}
}

深入浅出分析LSM树

LSM树数据结构定义

LSM树并没有一种固定死的实现方式,更多的是一种将:

“磁盘顺序写” + “多个树(状数据结构)” + “冷热(新老)数据分级” + “定期归并” + “非原地更新”这几种特性统一在一起的思想。

为了方便后续的讲解分析,我们尝试先对LSM树做一个定义。

LSM树的定义:

  • LSM树是一个横跨内存和磁盘的,包含多颗”子树”的一个森林。
  • LSM树分为Level 0,Level 1,Level 2 … Level n 多颗子树,其中只有Level 0在内存中,其余Level 1-n在磁盘中。
  • 内存中的Level 0子树一般采用排序树(红黑树/AVL树)、跳表或者TreeMap等这类有序的数据结构,方便后续顺序写磁盘。
  • 磁盘中的Level 1-n子树,本质是数据排好序后顺序写到磁盘上的文件,只是叫做树而已。
  • 每一层的子树都有一个阈值大小,达到阈值后会进行合并,合并结果写入下一层。
  • 只有内存中数据允许原地更新,磁盘上数据的变更只允许追加写,不做原地更新。

以上6条定义组成了LSM树,如图1所示。

  • 图1中分成了左侧绿色的内存部分和右侧蓝色的磁盘部分(定义1)。
  • 图1左侧绿色的内存部分只包含Level 0树,右侧蓝色的磁盘部分则包含Level 1-n等多棵”树”(定义2)
  • 图1左侧绿色的内存部分中Level 0是一颗二叉排序树(定义3)。注意这里的有序性,该性质决定了LSM树优异的读写性能。
  • 图1右侧蓝色的磁盘部分所包含的Level 1到Level n多颗树,虽然叫做“树”,但本质是按数据key排好序后,顺序写在磁盘上的一个个文件(定义4) ,注意这里再次出现了有序性。
  • 内存中的Level 0树在达到阈值后,会在内存中遍历排好序的Level 0树并顺序写入磁盘的Level 1。同样的,在磁盘中的Level n(n>0)达到阈值时,则会将Level n层的多个文件进行归并,写入Level n+1层。(定义5)
  • 除了内存中的Level 0层做原地更新外,对已写入磁盘上的数据,都采用Append形式的磁盘顺序写,即更新和删除操作并不去修改老数据,只是简单的追加新数据。图1中右侧蓝色的磁盘部分,Level 1和Level 2均包含key为2的数据,同时图1左侧绿色内存中的Level 0树也包含key为2的数据节点。(定义6)

下面我们遵循LSM树的6条定义,通过动图对LSM树的增、删、改、查和归并进行详细分析。

插入操作

LSM树的插入较简单,数据无脑往内存中的Level 0排序树丢即可,并不关心该数据是否已经在内存或磁盘中存在。(已经存在该数据的话,则场景转换成更新操作,详见第四部分)

图2展示了,新数据直接插入Level 0树的过程。

如上图2所示,我们依次插入了key=9、1、6的数据,这三个数据均按照key的大小,插入内存里的Level 0排序树中。该操作复杂度为树高log(n),n是Level 0树的数据量,可见代价很低,能实现极高的写吞吐量。

删除操作

LSM树的删除操作并不是直接删除数据,而是通过一种叫“墓碑标记”的特殊数据来标识数据的删除。

删除操作分为:待删除数据在内存中、待删除数据在磁盘中 和 该数据根本不存在 三种情况。

待删除数据在内存中:

如图3所示,展示了待删除数据在内存中的删除过程。我们不能简单地将Level 0树中的黄色节点2删除,而是应该采用墓碑标记将其覆盖(思考题:为什么不能直接删除而是要用墓碑标记覆盖呢)

待删除数据在磁盘中

如图4所示,展示了待删除数据在磁盘上时的删除过程。我们并不去修改磁盘上的数据(理都不理它),而是直接向内存中的Level 0树中插入墓碑标记即可。

待删除数据根本不存在:

这种情况等价于在内存的Level 0树中新增一条墓碑标记,场景转换为情况3.2的内存中插入墓碑标记操作。

综合看待上述三种情况,发现不论数据有没有、在哪里,删除操作都是等价于向Level 0树中写入墓碑标记。该操作复杂度为树高log(n),代价很低。

修改操作

LSM树的修改操作和删除操作很像,也是分为三种情况:待修改数据在内存中、在磁盘中和 该数据根本不存在。

待修改数据在内存中:

如图5所示,展示了待修改数据在内存中的操作过程。新的蓝色的key=7的数据,直接定位到内存中Level 0树上黄色的老的key=7的位置,将其覆盖即可。

待修改数据在磁盘中:

如图6所示,展示了待修改数据在磁盘中的操作过程。LSM树并不会去磁盘中的Level 1树上原地更新老的key=7的数据,而是直接将新的蓝色的节点7插入内存中的Level 0树中。

该数据根本不存在:

此场景等价于情况b,直接向内存中的Level 0树插入新的数据即可。

综上4.1、4.2、4.3三种情况可以看出,修改操作都是对内存中Level 0进行覆盖/新增操作。该操作复杂度为树高log(n),代价很低。

我们会发现,LSM树的增加、删除、修改(这三个都属于写操作)都是在内存中倒腾,完全没涉及到磁盘操作,所以速度飞快,写吞吐量高的离谱。。。

查询操作

LSM树的查询操作会按顺序查找Level 0、Level 1、Level 2 … Level n 每一颗树,一旦匹配便返回目标数据,不再继续查询。该策略保证了查到的一定是目标key最新版本的数据(有点MVCC的感觉)。

我们来分场景分析:依然分为 待查询数据在内存中 和 待查询数据在磁盘中 两种情况。

待查询数据在内存中:

如图7所示,展示了待查询数据在内存中时的查询过程。

沿着内存中已排好序的Level 0树递归向下比较查询,返回目标节点即可。我们注意到磁盘上的Level 1树中同样包括一个key=6的较老的数据。但LSM树查询的时候会按照Level 0、1、2 … n的顺序查询,一旦查到第一个就返回,因此磁盘上老的key=6的数据没人理它,更不会作为结果被返回。

待查询数据在磁盘中:

如图8所示,展示了待查询数据在磁盘上时的查询过程。

先查询内存中的Level 0树,没查到便查询磁盘中的Level 1树,还是没查到,于是查询磁盘中的Level 2树,匹配后返回key=6的数据。

综合上述两种情况,我们发现,LSM树的查询操作相对来说代价比较高,需要从Level 0到Level n一直顺序查下去。极端情况是LSM树中不存在该数据,则需要把整个库从Level 0到Level n给扫了一遍,然后返回查无此人(可以通过 布隆过滤器 + 建立稀疏索引 来优化查询操作)。代价大于以B/B+树为基本数据结构的传统RDB存储引擎。

合并操作

合并操作是LSM树的核心(毕竟LSM树的名字就叫: 日志结构合并树,直接点名了合并这一操作)

之所以在增、删、改、查这四个基本操作之外还需要合并操作:一是因为内存不是无限大,Level 0树达到阈值时,需要将数据从内存刷到磁盘中,这是合并操作的第一个场景;二是需要对磁盘上达到阈值的顺序文件进行归并,并将归并结果写入下一层,归并过程中会清理重复的数据和被删除的数据(墓碑标记)。我们分别对上述两个场景进行分析:

内存数据写入磁盘的场景:

如图9所示,展示了内存中Level 0树在达到阈值后,归并写入磁盘Level 1树的场景。

对内存中的Level 0树进行中序遍历,将数据顺序写入磁盘的Level 1层即可,我们可以看到因为Level 0树是已经排好序的,所以写入的Level 1中的新块也是有序的(有序性保证了查询和归并操作的高效)。此时磁盘的Level 1层有两个Block块。

磁盘中多个块的归并:

如图10所示,该图展示了磁盘中Level 1层达到阈值时,对其包含的两个Block块进行归并,并将归并结果写入Level 2层的过程。

我们注意到key=5和key=7的数据同时存在于较老的Block 1和较新的Block 2中。而归并的过程是保留较新的数据,于是我们看到结果中,key=5和7的数据都是红色的(来自于较新的Block2)。

综上我们可以看到,不论是场景6.1还是场景6.2,由于原始数据都是有序的,因此归并的过程只需要对数据集进行一次扫描即可,复杂度为O(n)。

优缺点分析

以上便是对LSM树的增、删、改、查和归并五种核心操作的详细分析。

可以看到LSM树将增、删、改这三种操作都转化为内存insert + 磁盘顺序写(当Level 0满的时候),通过这种方式得到了无与伦比的写吞吐量。

LSM树的查询能力则相对被弱化,相比于B+树的最多3~4次磁盘IO,LSM树则要从Level 0一路查询Level n,极端情况下等于做了全表扫描。(即便做了稀疏索引,也是lg(N0)+lg(N1)+…+lg(Nn)的复杂度,大于B+树的lg(N0+N1+…+Nn)的时间复杂度)。

同时,LSM树只append追加不原地修改的特性引入了归并操作,归并操作涉及到大量的磁盘IO,比较消耗性能,需要合理设置触发该操作的参数。

综上我们可以给出LSM树的优缺点:

优:增、删、改操作飞快,写吞吐量极大。

缺:读操作性能相对被弱化;不擅长区间范围的读操作; 归并操作较耗费资源。

总结

以上是对LSM树基本操作以及优缺点的分析,我们可以据此得出LSM树的设计原则:

  • 先内存再磁盘
  • 内存原地更新
  • 磁盘追加更新
  • 归并保留新值

如果说B/B+树的读写性能基本平衡的话,LSM树的设计原则通过舍弃部分读性能,换取了无与伦比的写性能。该数据结构适合用于写吞吐量远远大于读吞吐量的场景,得到了NoSQL届的喜爱和好评。

平衡二叉树详解

二叉搜索树(Binary Sort Tree)

二叉搜索树,又称之为二叉排序树(二叉查找树),它或许是一棵空树,或许是具有以下性质的二叉树:

  • 若他的左子树不为空,则左子树上所有节点的值都小于根节点的值
  • 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
  • 它的左右子树也分别是二叉搜索树

二叉搜索树的这种特性,使得我们在此二叉树上查找某个值就很方便了,从根节点开始,若要寻找的值小于根节点的值,则在左子树上去找,反之则去右子树查找,知道找到与值相同的节点。插入节点也是一样的道理,从根节点出发,所要插入的值,若小于根节点则去左子树寻找该节点所对应的位置,反之去右子树寻找,直到找到该节点合适的位置。

二叉平衡搜索树(AVL)

前面提到了二叉搜索树,我们知道,二叉搜索树的特性便于我们进行查找插入删除等一系列操作,其时间复杂度为O(logn),但是,如果遇见最差的情况,比如以下这棵树:

这棵树,说是树,其实它已经退化成链表了,但从概念上来看,它仍是一棵二叉搜索树,只要我们按照逐次增大,如1、2、3、4、5、6的顺序构造一棵二叉搜索树,则形如上图。那么插入的时间复杂度就变成了O(n),导致这种糟糕的情况原因是因为这棵树极其不平衡,右树的重量远大于左树,因此我们提出了叫平衡二叉搜索树的结构,又称之为AVL树,是因为平衡二叉搜索树的发明者为Adel’son-Vel’skii 和Landis二人。

平衡二叉搜索树,它能保持二叉树的高度平衡,尽量降低二叉树的高度,减少树的平均查找长度。

AVL树的性质:

  • 左子树与右子树高度之差的绝对值不超过1
  • 树的每个左子树和右子树都是AVL树
  • 每一个节点都有一个平衡因子(balance factor),任一节点的平衡因子是-1、0、1(每一个节点的平衡因子 = 右子树高度 - 左子树高度)

做到了这点,这棵树看起来就比较平衡了,那么如何生成一棵AVL树呢?算法相对来说复杂,随着新节点的加入,树自动调整自身结构,达到新的平衡状态,这就是我们想要的AVL树。我们先要分析,为什么树会失衡?是由于插入了一个新的元素。

  • 当子树的根结点的平衡因子为+1时,它是左倾斜的(left-heavy)。
  • 当子树的根结点的平衡因子为 -1时,它是右倾斜的(right-heavy)。
  • 一颗子树的根结点的平衡因子就代表该子树的平衡性。
  • 保持所有子树几乎都处于平衡状态,AVL树在总体上就能够基本保持平衡。

AVL树的基本查找、插入结点的操作和二叉树的操作一样。但是,当向AVL树中插入一个结点后,还有一些额外的工作要做。首先,必须计算因插入操作对平衡因子带来的改变。其次,如果任何平衡因子变成了+/-2,就必须从这个结点开始往下重新平衡这颗树,这个重新平衡的过程就称为旋转。

在AVL树中,插入一个节点是什么样的过程呢?总结如下:

  1. AVL树首先是二叉搜索树。我们要根据二叉搜索树的插入节点方式进行插入
  2. AVL树有判断该树是否平衡的平衡因子,我们要根据平衡因子来对树进行选择调整

具体步骤:

  1. 判断该树是不是NULL,若为NULL,则直接插入
    2· 若不为NULL,找到需要插入节点的位置(用pParent标记双亲,方便插入节点)pCur
  2. 插入节点pCur
  3. 更新pParent的平衡因子。然后判断该树是否要调整
    1. 若更新后的pParent平衡因子为0的话,pParent在插入新节点之前只有左孩子或者只有右孩子,此时树的高度不变,该树仍然为AVL
    2. 若更新后的pParent平衡因子为1或者-1的话,pParent在插入节点前是叶子节点,此时的高度可能发生改变,我们要从pParent节点开始,向上判断调整其祖先节点
    3. 若平衡因子不满足上面的两种情况,说明该树已经不平衡,需要调整。具体情况见下面,局部调整完后,上面的树已经满足AVL。

插入节点代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
template <class K, class V>
bool AVLTree<K, V>::AVLInsert(K key, V val)
{
//1.根节点为空,直接插入
if (_root == NULL)
{
_root = new Node(key, val);
return true;
}
//2.根节点不为空
else
{
Node* cur = _root;
Node* parent =NULL;
//a)找到要插入节点的位置
while (cur)
{
parent = cur;
if (cur->_key > key)
cur = cur->_left;
else if (cur->_key < key)
cur = cur->_right;
else
return false; //不允许出现重复元素的节点
}
//b)插入新节点
cur = new Node(key, val);
if (parent->_key > key)
{
parent->_left = cur;
cur->_parent = parent;
}
else
{
parent->_right = cur;
cur->_parent = parent;
}

//c)插入完成后,调整平衡因子
while (parent)
{
if (cur == parent->_left)//插入节点在左子树父节点bf--,反之++
parent->_bf--;
else
parent->_bf++;

//1)插入新节点后,parent->bf==0;说明高度没变,平衡,返回
if (parent->_bf == 0)
break;
//2)插入节点后parent->_bf==-1||parent->_bf==1;说明子树高度改变,则继续向上调整
else if (parent->_bf == -1 || parent->_bf == 1)
{
cur = parent;
parent = parent->_parent;
}
//3)插入节点后parent->_bf==-2||parent->_bf==2;说明已经不平衡,需要旋转
else
{
if (parent->_bf == 2)
{
if (cur->_bf == 1)
RotateL(parent);
else// (cur->_bf == -1)
RotateRL(parent);
}
else//parent->_bf == -2
{
if (cur->_bf == -1)
RotateR(parent);
else// (cur->_bf == 1)
RotateLR(parent);
}
break;
}
}//end while (parent)
return true;
}
}

当树不平衡时,我们需要做出旋转调整,有四种调整方法。以下是节点调平的四种情况。

AVL树的自平衡操作——旋转

AVL树的旋转总体来说分为四种情况:

  • 左单旋
  • 右单旋
  • 左右双旋
  • 右左双旋

接下来,我们通过图解来认识这四种节点调平方式

左单旋(逆时针旋转)

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
template <class K, class V>
void AVLTree<K, V>::RotateL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
Node* pParent = parent->_parent;

parent->_right = subRL;
if (subRL)
subRL->_parent = parent;

subR->_left = parent;
parent->_parent = subR;

if (parent == _root)
{
_root = subR;
_root->_parent = NULL;
}
else
{
if (pParent->_left = parent)
pParent->_left = subR;
else
pParent->_right = subR;
subR->_parent = pParent;
}
parent->_bf = subR->_bf = 0;
}

右单旋(顺时针旋转)

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template <class K, class V>
void AVLTree<K, V>::RotateR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
Node* ppNode = parent->_parent;

parent->_left = subLR;
if (subLR)
subLR->_parent = parent;

subL->_right = parent;
parent->_parent = subL;

if (_root == parent)
{
_root = subL;
subL->_parent = NULL;
}
else
{
if (ppNode->_right == parent)
{
ppNode->_right = subL;
}
else
{
ppNode->_left = subL;
}

subL->_parent = ppNode;
}

subL->_bf = parent->_bf = 0;
}

左右双旋

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <class K, class V>
void AVLTree<K, V>::RotateLR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
int bf = subLR->_bf;

RotateL(parent->_left);
RotateR(parent);

if (bf == 0)
{
subLR->_bf = subL->_bf = parent->_bf = 0;
}
else if (bf == 1)
{
parent->_bf = 0;
subL->_bf = -1;
subLR->_bf = 0;
}
else if (bf == -1)
{
parent->_bf = 1;
subL->_bf = 0;
subLR->_bf = 0;
}
else
{
assert(false);
}
}

右左双旋

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <class K, class V>
void AVLTree<K, V>::RotateRL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
int bf = subRL->_bf;

RotateR(parent->_right);
RotateL(parent);

if (bf == 0)
{
subRL->_bf = subR->_bf = parent->_bf = 0;
}
else if (bf == 1)
{
subR->_bf = 0;
parent->_bf = -1;
subRL->_bf = 0;
}
else if (bf == -1)
{
parent->_bf = 0;
subR->_bf = 1;
subRL->_bf = 0;
}
else
{
assert(false);
}
}

平衡因子更新

我们知道AVL树的每一个节点都有一个平衡因子,那么在AVL树插入节点时,其自平衡操作保证了AVL树始终保持平衡状态,但是在每一次插入节点时,都可能会导致节点平衡因子的改变,因此,当插入节点时,我们应当注意平衡因子的更新,这直接关系到之后判断插入节点后的数是否仍为AVL树。

平衡因子更新原则:——平衡因子与节点本身无关,只与其左右子树相关

  • 新增节点bf恒为1
  • 右子树结点增加,父亲bf ++

  • 左子树结点增加,父亲bf —

  • 若插入节点,更新平衡因子之后
    • 父亲节点bf==0:高度没变,结束更新,平衡,满足条件,返回

- 父亲bf==1 (或者bf== -1),子树高度改变,继续往上更新

- 父亲bf==2 (或者bf== -2),子树不再是平衡树,旋转,变成平衡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
#pragma once

//AVLTree树节点定义
template <class K,class V>
struct AVLTreeNode
{
K _key;
V _val;

AVLTreeNode<K,V>* _left;
AVLTreeNode<K, V>* _right;
AVLTreeNode<K, V>* _parent;

int _bf; //平衡因子

AVLTreeNode(const K& key, const V& val)
:_key(key)
, _val(val)
, _left(NULL)
, _right(NULL)
, _parent(NULL)
, _bf(0)
{}

};

//AVLTree类的定义
template <class K,class V>
class AVLTree
{
typedef AVLTreeNode<K, V> Node;
public:
AVLTree() //构造函数
:_root(NULL)
{}

bool AVLInsert(K key,V val); //插入节点

void RotateL(Node* parent); // 左单旋
void RotateR(Node* parent); // 右单旋
void RotateLR(Node* parent); // 左右双旋
void RotateRL(Node* parent); // 右左双旋

bool _IsBalance(Node* root, int& height) //判断是否平衡
{
if (root == NULL)
{
height = 0;
return true;
}

int leftHeight = 0;
int rightHeight = 0;
if (_IsBalance(root->_left, leftHeight)
&& _IsBalance(root->_right, rightHeight))
{
if (rightHeight - leftHeight != root->_bf)
{
cout << "平衡因子异常" << root->_key << endl;
return false;
}

height = leftHeight > rightHeight ? leftHeight + 1 : rightHeight + 1;
return abs(leftHeight - rightHeight) < 2;
}
else
{
return false;
}
}

bool IsBalance()
{
int height = 0;
return _IsBalance(_root, height);
}

void _InOrder(Node* root)
{
if (root == NULL)
{
return;
}

_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}

void InOrder()
{
_InOrder(_root);
cout << endl;
}

private:
Node* _root;
};

template <class K,class V>
bool AVLTree<K, V>::AVLInsert(K key, V val)
{
//1.根节点为空,直接插入
if (_root == NULL)
{
_root = new Node(key, val);
return true;
}
//2.根节点不为空
else
{
Node* cur = _root;
Node* parent =NULL;
//a)找到要插入节点的位置
while (cur)
{
parent = cur;
if (cur->_key > key)
cur = cur->_left;
else if (cur->_key < key)
cur = cur->_right;
else
return false; //不允许出现重复元素的节点
}
//b)插入新节点
cur = new Node(key, val);
if (parent->_key>key)
{
parent->_left = cur;
cur->_parent = parent;
}

else
{
parent->_right = cur;
cur->_parent = parent;
}

//c)插入完成后,调整平衡因子
while (parent)
{
if (cur == parent->_left)//插入节点在左子树父节点bf--,反之++
parent->_bf--;
else
parent->_bf++;

//1)插入新节点后,parent->bf==0;说明高度没变,平衡,返回
if (parent->_bf == 0)
break;
//2)插入节点后parent->_bf==-1||parent->_bf==1;说明子树高度改变,则继续向上调整
else if (parent->_bf == -1 || parent->_bf == 1)
{
cur = parent;
parent = parent->_parent;
}
//3)插入节点后parent->_bf==-2||parent->_bf==2;说明已经不平衡,需要旋转
else
{
if (parent->_bf == 2)
{
if (cur->_bf == 1)
RotateL(parent);
else// (cur->_bf == -1)
RotateRL(parent);
}
else//parent->_bf == -2
{
if (cur->_bf == -1)
RotateR(parent);
else// (cur->_bf == 1)
RotateLR(parent);
}
break;
}

}//end while (parent)
return true;
}
}

template <class K, class V>
void AVLTree<K, V>::RotateL(Node* parent)
{
Node*subR = parent->_right;
Node*subRL = subR->_left;
Node*pParent = parent->_parent;

parent->_right = subRL;
if (subRL)
subRL->_parent = parent;

subR->_left = parent;
parent->_parent = subR;

if (parent == _root)
{
_root = subR;
_root->_parent = NULL;
}

else
{
if (pParent->_left = parent)
pParent->_left = subR;
else
pParent->_right = subR;

subR->_parent = pParent;
}
parent->_bf = subR->_bf = 0;
}

template <class K, class V>
void AVLTree<K, V>::RotateR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
Node* ppNode = parent->_parent;

parent->_left = subLR;
if (subLR)
subLR->_parent = parent;

subL->_right = parent;
parent->_parent = subL;

if (_root == parent)
{
_root = subL;
subL->_parent = NULL;
}
else
{
if (ppNode->_right == parent)
{
ppNode->_right = subL;
}
else
{
ppNode->_left = subL;
}

subL->_parent = ppNode;
}

subL->_bf = parent->_bf = 0;
}

template <class K, class V>
void AVLTree<K, V>::RotateLR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
int bf = subLR->_bf;

RotateL(parent->_left);
RotateR(parent);

if (bf == 0)
{
subLR->_bf = subL->_bf = parent->_bf = 0;
}
else if (bf == 1)
{
parent->_bf = 0;
subL->_bf = -1;
subLR->_bf = 0;
}
else if (bf == -1)
{
parent->_bf = 1;
subL->_bf = 0;
subLR->_bf = 0;
}
else
{
assert(false);
}
}

template <class K, class V>
void AVLTree<K, V>::RotateRL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
int bf = subRL->_bf;

RotateR(parent->_right);
RotateL(parent);

if (bf == 0)
{
subRL->_bf = subR->_bf = parent->_bf = 0;
}
else if (bf == 1)
{
subR->_bf = 0;
parent->_bf = -1;
subRL->_bf = 0;
}
else if (bf == -1)
{
parent->_bf = 0;
subR->_bf = 1;
subRL->_bf = 0;
}
else
{
assert(false);
}
}


void TestAVLtree() //测试代码
{
int a[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14 };
//{16, 3, 7, 11, 9, 26, 18, 14, 15};
AVLTree<int, int> t;
for (size_t i = 0; i < sizeof(a) / sizeof(int); ++i)
{
t.AVLInsert(a[i], i);
cout << a[i] << ":" << t.IsBalance() << endl;
}

t.InOrder();
cout << t.IsBalance() << endl;
}

更清晰的一个图

旋转操作用来重新平衡树的某个部分。通过重新安排结点 ,使结点之间的关系始终保持左子结点小于父结点,父结点小于右子结点。使得该树仍然是一颗二叉搜索树。旋转过后,旋转子树中的所有结点的平衡因子都为+1、-1或0。

AVL树的旋转类型有4种, 分别是LL(left-left)旋转、LR(left-right)旋转、RR(right-right)旋转和RL(right-left)旋转。

为方便理解在何时执行哪一种旋转,设x代表刚插入AVL树中的结点,设A为离x最近且平衡因子更改为2的绝对值的祖先。

LL旋转

如下图所示,当x位于A的左子树的左子树上时,执行LL旋转。

设left为A的左子树,要执行LL旋转,将A的左指针指向left的右子结点,left的右指针指向A,将原来指向A的指针指向left。

旋转过后,将A和left的平衡因子都改为0。所有其他结点的平衡因子没有发生变化。

LR旋转

当x位于A的左子树的右子树上时,执行LR旋转。

设left是A的左子结点,并设A的子孙结点grandchild为left的右子结点。

要执行LR旋转,将left的右子结点指向grandchild的左子结点,grandchild的左子结点指向left,A的左子结点指向grandchild的右子结点,再将grandchild的右子结点指向A,最后将原来指向A的指针指向grandchild。

执行LR旋转之后,调整结点的平衡因子取决于旋转前grandchild结点的原平衡因子值。

  • 如果grandchild结点的原始平衡因子为+1,就将A的平衡因子设为-1,将left的平衡因子设为0。
  • 如果grandchild结点的原始平衡因子为0,就将A和left的平衡因子都设置为0。
  • 如果grandchild结点的原始平衡因子为-1,就将A的平衡因子设置为0,将left的平衡因子设置为+1。

在所有的情况下,grandchild的新平衡因子都是0。所有其他结点的平衡因子都没有改变。

RR旋转

当x位于A的右子树的右子树上时,执行RR旋转。

RR旋转与LL旋转是对称的关系。

设A的右子结点为Right。要执行RR旋转,将A的右指针指向right的左子结点,right的左指针指向A,原来指向A的指针修改为指向right。

完成旋转以后,将A和left的平衡因子都修改为0。所有其他结点的平衡因子都没有改变。

RL旋转

当x位于A的右子树的左子树上时,执行RL旋转。

RL旋转与LR旋转是对称的关系。

设A的右子结点为right,right的左子结点为grandchild。要执行RL旋转,将right结点的左子结点指向grandchild的右子结点,将grandchild的右子结点指向right,将A的右子结点指向grandchild的左子结点,将grandchild的左子结点指向A,最后将原来指向A的指针指向grandchild。

执行RL旋转以后,调整结点的平衡因子取决于旋转前grandchild结点的原平衡因子。这里也有三种情况需要考虑:

  • 如果grandchild的原始平衡因子值为+1,将A的平衡因子更新为0,right的更新为-1;
  • 如果grandchild的原始平衡因子值为 0,将A和right的平衡因子都更新为0;
  • 如果grandchild的原始平衡因子值为-1,将A的平衡因子更新为+1,right的更新为0;

在所有情况中,都将grandchild的新平衡因子设置为0。所有其他结点的平衡因子不发生改变。

AVL树的删除操作

同插入操作一样,删除结点时也有可能破坏平衡性,这就要求我们删除的时候要进行平衡性调整。

首先在整个二叉树中搜索要删除的结点,如果没搜索到直接返回不作处理,否则执行以下操作:

  • 要删除的节点是当前根节点T。
    • 如果左右子树都非空。在高度较大的子树中实施删除操作。分两种情况:
      • 左子树高度大于右子树高度,将左子树中最大的那个元素赋给当前根节点,然后删除左子树中元素值最大的那个节点。
      • 左子树高度小于右子树高度,将右子树中最小的那个元素赋给当前根节点,然后删除右子树中元素值最小的那个节点。
    • 如果左右子树中有一个为空,那么直接用那个非空子树或者是NULL替换当前根节点即可。
  • 要删除的节点元素值小于当前根节点T值,在左子树中进行删除。
    • 递归调用,在左子树中实施删除。
    • 这个是需要判断当前根节点是否仍然满足平衡条件,
    • 如果满足平衡条件,只需要更新当前根节点T的高度信息。
    • 否则,需要进行旋转调整:
    • 如果T的左子节点的左子树的高度大于T的左子节点的右子树的高度,进行相应的单旋转。否则进行双旋转。
  • 要删除的节点元素值大于当前根节点T值,在右子树中进行删除。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
下面给出详细代码实现:

AvlTree.h

#include <iostream>
#include <algorithm>
using namespace std;
#pragma once
//平衡二叉树结点
template <typename T>
struct AvlNode
{
T data;
int height; //结点所在高度
AvlNode<T> *left;
AvlNode<T> *right;
AvlNode<T>(const T theData) : data(theData), left(NULL), right(NULL), height(0){}
};
//AvlTree
template <typename T>
class AvlTree
{
public:
AvlTree<T>(){}
~AvlTree<T>(){}
AvlNode<T> *root;
//插入结点
void Insert(AvlNode<T> *&t, T x);
//删除结点
bool Delete(AvlNode<T> *&t, T x);
//查找是否存在给定值的结点
bool Contains(AvlNode<T> *t, const T x) const;
//中序遍历
void InorderTraversal(AvlNode<T> *t);
//前序遍历
void PreorderTraversal(AvlNode<T> *t);
//最小值结点
AvlNode<T> *FindMin(AvlNode<T> *t) const;
//最大值结点
AvlNode<T> *FindMax(AvlNode<T> *t) const;
private:
//求树的高度
int GetHeight(AvlNode<T> *t);
//单旋转 左
AvlNode<T> *LL(AvlNode<T> *t);
//单旋转 右
AvlNode<T> *RR(AvlNode<T> *t);
//双旋转 右左
AvlNode<T> *LR(AvlNode<T> *t);
//双旋转 左右
AvlNode<T> *RL(AvlNode<T> *t);
};
template <typename T>
AvlNode<T> * AvlTree<T>::FindMax(AvlNode<T> *t) const
{
if (t == NULL)
return NULL;
if (t->right == NULL)
return t;
return FindMax(t->right);
}
template <typename T>
AvlNode<T> * AvlTree<T>::FindMin(AvlNode<T> *t) const
{
if (t == NULL)
return NULL;
if (t->left == NULL)
return t;
return FindMin(t->left);
}

template <typename T>
int AvlTree<T>::GetHeight(AvlNode<T> *t)
{
if (t == NULL)
return -1;
else
return t->height;
}

//单旋转
//左左插入导致的不平衡
template <typename T>
AvlNode<T> * AvlTree<T>::LL(AvlNode<T> *t)
{
AvlNode<T> *q = t->left;
t->left = q->right;
q->right = t;
t = q;
t->height = max(GetHeight(t->left), GetHeight(t->right)) + 1;
q->height = max(GetHeight(q->left), GetHeight(q->right)) + 1;
return q;
}
//单旋转
//右右插入导致的不平衡
template <typename T>
AvlNode<T> * AvlTree<T>::RR(AvlNode<T> *t)
{
AvlNode<T> *q = t->right;
t->right = q->left;
q->left = t;
t = q;
t->height = max(GetHeight(t->left), GetHeight(t->right)) + 1;
q->height = max(GetHeight(q->left), GetHeight(q->right)) + 1;
return q;
}
//双旋转
//插入点位于t的左儿子的右子树
template <typename T>
AvlNode<T> * AvlTree<T>::LR(AvlNode<T> *t)
{
//双旋转可以通过两次单旋转实现
//对t的左结点进行RR旋转,再对根节点进行LL旋转
RR(t->left);
return LL(t);
}
//双旋转
//插入点位于t的右儿子的左子树
template <typename T>
AvlNode<T> * AvlTree<T>::RL(AvlNode<T> *t)
{
LL(t->right);
return RR(t);
}

template <typename T>
void AvlTree<T>::Insert(AvlNode<T> *&t, T x)
{
if (t == NULL)
t = new AvlNode<T>(x);
else if (x < t->data)
{
Insert(t->left, x);
//判断平衡情况
if (GetHeight(t->left) - GetHeight(t->right) > 1)
{
//分两种情况 左左或左右
if (x < t->left->data)//左左
t = LL(t);
else //左右
t = LR(t);
}
}
else if (x > t->data)
{
Insert(t->right, x);
if (GetHeight(t->right) - GetHeight(t->left) > 1)
{
if (x > t->right->data)
t = RR(t);
else
t = RL(t);
}
}
else
;//数据重复
t->height = max(GetHeight(t->left), GetHeight(t->right)) + 1;
}
template <typename T>
bool AvlTree<T>::Delete(AvlNode<T> *&t, T x)
{
//t为空 未找到要删除的结点
if (t == NULL)
return false;
//找到了要删除的结点
else if (t->data == x)
{
//左右子树都非空
if (t->left != NULL && t->right != NULL)
{//在高度更大的那个子树上进行删除操作
//左子树高度大,删除左子树中值最大的结点,将其赋给根结点
if (GetHeight(t->left) > GetHeight(t->right))
{
t->data = FindMax(t->left)->data;
Delete(t->left, t->data);
}
else//右子树高度更大,删除右子树中值最小的结点,将其赋给根结点
{
t->data = FindMin(t->right)->data;
Delete(t->right, t->data);
}
}
else
{//左右子树有一个不为空,直接用需要删除的结点的子结点替换即可
AvlNode<T> *old = t;
t = t->left ? t->left: t->right;//t赋值为不空的子结点
delete old;
}
}
else if (x < t->data)//要删除的结点在左子树上
{
//递归删除左子树上的结点
Delete(t->left, x);
//判断是否仍然满足平衡条件
if (GetHeight(t->right) - GetHeight(t->left) > 1)
{
if (GetHeight(t->right->left) > GetHeight(t->right->right))
{
//RL双旋转
t = RL(t);
}
else
{//RR单旋转
t = RR(t);
}
}
else//满足平衡条件 调整高度信息
{
t->height = max(GetHeight(t->left), GetHeight(t->right)) + 1;
}
}
else//要删除的结点在右子树上
{
//递归删除右子树结点
Delete(t->right, x);
//判断平衡情况
if (GetHeight(t->left) - GetHeight(t->right) > 1)
{
if (GetHeight(t->left->right) > GetHeight(t->left->left))
{
//LR双旋转
t = LR(t);
}
else
{
//LL单旋转
t = LL(t);
}
}
else//满足平衡性 调整高度
{
t->height = max(GetHeight(t->left), GetHeight(t->right)) + 1;
}
}
return true;
}
//查找结点
template <typename T>
bool AvlTree<T>::Contains(AvlNode<T> *t, const T x) const
{
if (t == NULL)
return false;
if (x < t->data)
return Contains(t->left, x);
else if (x > t->data)
return Contains(t->right, x);
else
return true;
}
//中序遍历
template <typename T>
void AvlTree<T>::InorderTraversal(AvlNode<T> *t)
{
if (t)
{
InorderTraversal(t->left);
cout << t->data << ' ';
InorderTraversal(t->right);
}
}
//前序遍历
template <typename T>
void AvlTree<T>::PreorderTraversal(AvlNode<T> *t)
{
if (t)
{
cout << t->data << ' ';
PreorderTraversal(t->left);
PreorderTraversal(t->right);
}
}

红黑树详解

转载请标明出处,原文地址:http://blog.csdn.net/hackbuteer1/article/details/7740956

红黑树概述

红黑树和我们以前学过的AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。不过自从红黑树出来后,AVL树就被放到了博物馆里,据说是红黑树有更好的效率,更高的统计性能。这一点在我们了解了红黑树的实现原理后,就会有更加深切的体会。

红黑树和AVL树的区别在于它使用颜色来标识结点的高度,它所追求的是局部平衡而不是AVL树中的非常严格的平衡。学过数据结构的人应该都已经领教过AVL树的复杂,但AVL树的复杂比起红黑树来说简直是小巫见大巫,红黑树才是真正的变态级数据结构。由于STL中的关联式容器默认的底层实现都是红黑树,因此红黑树对于后续学习STL源码还是很重要的,有必要掌握红黑树的实现原理和源码实现。红黑树是AVL树的变种,红黑树通过一些着色法则确保没有一条路径会比其它路径长出两倍,因而达到接近平衡的目的。所谓红黑树,不仅是一个二叉搜索树,而且必须满足以下规则:

  1. 每个节点不是红色就是黑色。
  2. 根节点为黑色。
  3. 如果节点为红色,其子节点必须为黑色。
  4. 任意一个节点到到NULL(树尾端)的任何路径,所含之黑色节点数必须相同。

上面的这些约束保证了这个树大致上是平衡的,这也决定了红黑树的插入、删除、查询等操作是比较快速的。 根据规则4,新增节点必须为红色;根据规则3,新增节点之父节点必须为黑色。当新增节点根据二叉搜索树的规则到达其插入点时,却未能符合上述条件时,就必须调整颜色并旋转树形,如下图:

假设我们为上图分别插入节点3、8、35、75,根据二叉搜索树的规则,插入这四个节点后,我们会发现它们都破坏了红黑树的规则,因此我们必须调整树形,也就是旋转树形并改变节点的颜色。

红黑树上结点的插入

在讨论红黑树的插入操作之前必须要明白,任何一个即将插入的新结点的初始颜色都为红色。这一点很容易理解,因为插入黑点会增加某条路径上黑结点的数目,从而导致整棵树黑高度的不平衡。但如果新结点的父结点为红色时(如下图所示),将会违反红黑树的性质:一条路径上不能出现相邻的两个红色结点。这时就需要通过一系列操作来使红黑树保持平衡。

为了清楚地表示插入操作以下在结点中使用“新”字表示一个新插入的结点;使用“父”字表示新插入点的父结点;使用“叔”字表示“父”结点的兄弟结点;使用“祖”字表示“父”结点的父结点。插入操作分为以下几种情况:

黑父

如下图所示,如果新节点的父结点为黑色结点,那么插入一个红点将不会影响红黑树的平衡,此时插入操作完成。红黑树比AVL树优秀的地方之一在于黑父的情况比较常见,从而使红黑树需要旋转的几率相对AVL树来说会少一些。

红父

如果新节点的父结点为红色,这时就需要进行一系列操作以保证整棵树红黑性质。如下图所示,由于父结点为红色,此时可以判定,祖父结点必定为黑色。这时需要根据叔父结点的颜色来决定做什么样的操作。青色结点表示颜色未知。由于有可能需要根结点到新点的路径上进行多次旋转操作,而每次进行不平衡判断的起始点(我们可将其视为新点)都不一样。所以我们在此使用一个蓝色箭头指向这个起始点,并称之为判定点。

红叔

当叔父结点为红色时,如下图所示,无需进行旋转操作,只要将父和叔结点变为黑色,将祖父结点变为红色即可。但由于祖父结点的父结点有可能为红色,从而违反红黑树性质。此时必须将祖父结点作为新的判定点继续向上(迭代)进行平衡操作。

需要注意的是,无论“父节点”在“叔节点”的左边还是右边,无论“新节点”是“父节点”的左孩子还是右孩子,它们的操作都是完全一样的(其实这种情况包括4种,只需调整颜色,不需要旋转树形)。

黑叔

当叔父结点为黑色时,需要进行旋转,以下图示了所有的旋转可能:

Case 1:

Case 2:

Case 3:

Case 4:

可以观察到,当旋转完成后,新的旋转根全部为黑色,此时不需要再向上回溯进行平衡操作,插入操作完成。需要注意,上面四张图的“叔”、“1”、“2”、“3”结点有可能为黑哨兵结点。

其实红黑树的插入操作不是很难,甚至比AVL树的插入操作还更简单些。红黑树的插入操作源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
// 元素插入操作  insert_unique()
// 插入新值:节点键值不允许重复,若重复则插入无效
// 注意,返回值是个pair,第一个元素是个红黑树迭代器,指向新增节点
// 第二个元素表示插入成功与否
template<class Key, class Value, class KeyOfValue, class Compare, class Alloc>
pair<typename rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::iterator, bool>
rb_tree<Key, Value, KeyOfValue, Compare, Alloc>::insert_unique(const Value &v){
rb_tree_node* y = header; // 根节点root的父节点
rb_tree_node* x = root(); // 从根节点开始
bool comp = true;
while(x != 0) {
y = x;
comp = key_compare(KeyOfValue()(v), key(x)); // v键值小于目前节点之键值?
x = comp ? left(x) : right(x); // 遇“大”则往左,遇“小于或等于”则往右
}
// 离开while循环之后,y所指即插入点之父节点(此时的它必为叶节点)
iterator j = iterator(y); // 令迭代器j指向插入点之父节点y
if(comp) // 如果离开while循环时comp为真(表示遇“大”,将插入于左侧)
{
if(j == begin()) // 如果插入点之父节点为最左节点
return pair<iterator, bool>(_insert(x, y, z), true);
else // 否则(插入点之父节点不为最左节点)
--j; // 调整j,回头准备测试
}
if(key_compare(key(j.node), KeyOfValue()(v) )) // 新键值不与既有节点之键值重复,于是以下执行安插操作
return pair<iterator, bool>(_insert(x, y, z), true);
// 以上,x为新值插入点,y为插入点之父节点,v为新值
// 进行至此,表示新值一定与树中键值重复,那么就不应该插入新值
return pair<iterator, bool>(j, false);
}

// 真正地插入执行程序 _insert()
template<class Key, class Value, class KeyOfValue, class Compare, class Alloc>
typename<Key, Value, KeyOfValue, Compare, Alloc>::_insert(base_ptr x_, base_ptr y_, const Value &v)
{ // 参数x_ 为新值插入点,参数y_为插入点之父节点,参数v为新值
link_type x = (link_type) x_;
link_type y = (link_type) y_;
link_type z; // key_compare 是键值大小比较准则。应该会是个function object

if(y == header || x != 0 || key_compare(KeyOfValue()(v), key(y) )) {
z = create_node(v); // 产生一个新节点
left(y) = z; // 这使得当y即为header时,leftmost() = z
if(y == header) {
root() = z;
rightmost() = z;
}
else if(y == leftmost()) // 如果y为最左节点
leftmost() = z; // 维护leftmost(),使它永远指向最左节点
}
else {
z = create_node(v); // 产生一个新节点
right(y) = z; // 令新节点成为插入点之父节点y的右子节点
if(y == rightmost())
rightmost() = z; // 维护rightmost(),使它永远指向最右节点
}
parent(z) = y; // 设定新节点的父节点
left(z) = 0; // 设定新节点的左子节点
right(z) = 0; // 设定新节点的右子节点
// 新节点的颜色将在_rb_tree_rebalance()设定(并调整)
_rb_tree_rebalance(z, header->parent); // 参数一为新增节点,参数二为根节点root
++node_count; // 节点数累加
return iterator(z); // 返回一个迭代器,指向新增节点
}

// 全局函数
// 重新令树形平衡(改变颜色及旋转树形)
// 参数一为新增节点,参数二为根节点root
inline void _rb_tree_rebalance(_rb_tree_node_base* x, _rb_tree_node_base*& root) {
x->color = _rb_tree_red; //新节点必为红
while(x != root && x->parent->color == _rb_tree_red) // 父节点为红
{
if(x->parent == x->parent->parent->left) // 父节点为祖父节点之左子节点
{
_rb_tree_node_base* y = x->parent->parent->right; // 令y为伯父节点
if(y && y->color == _rb_tree_red) // 伯父节点存在,且为红
{
x->parent->color = _rb_tree_black; // 更改父节点为黑色
y->color = _rb_tree_black; // 更改伯父节点为黑色
x->parent->parent->color = _rb_tree_red; // 更改祖父节点为红色
x = x->parent->parent;
}
else // 无伯父节点,或伯父节点为黑色
{
if(x == x->parent->right) // 如果新节点为父节点之右子节点
{
x = x->parent;
_rb_tree_rotate_left(x, root); // 第一个参数为左旋点
}
x->parent->color = _rb_tree_black; // 改变颜色
x->parent->parent->color = _rb_tree_red;
_rb_tree_rotate_right(x->parent->parent, root); // 第一个参数为右旋点
}
}
else // 父节点为祖父节点之右子节点
{
_rb_tree_node_base* y = x->parent->parent->left; // 令y为伯父节点
if(y && y->color == _rb_tree_red) // 有伯父节点,且为红
{
x->parent->color = _rb_tree_black; // 更改父节点为黑色
y->color = _rb_tree_black; // 更改伯父节点为黑色
x->parent->parent->color = _rb_tree_red; // 更改祖父节点为红色
x = x->parent->parent; // 准备继续往上层检查
}
else // 无伯父节点,或伯父节点为黑色
{
if(x == x->parent->left) // 如果新节点为父节点之左子节点
{
x = x->parent;
_rb_tree_rotate_right(x, root); // 第一个参数为右旋点
}
x->parent->color = _rb_tree_black; // 改变颜色
x->parent->parent->color = _rb_tree_red;
_rb_tree_rotate_left(x->parent->parent, root); // 第一个参数为左旋点
}
}
}//while
root->color = _rb_tree_black; // 根节点永远为黑色
}

// 左旋函数
inline void _rb_tree_rotate_left(_rb_tree_node_base* x, _rb_tree_node_base*& root)
{
// x 为旋转点
_rb_tree_node_base* y = x->right; // 令y为旋转点的右子节点
x->right = y->left;
if(y->left != 0)
y->left->parent = x; // 别忘了回马枪设定父节点
y->parent = x->parent;

// 令y完全顶替x的地位(必须将x对其父节点的关系完全接收过来)
if(x == root) // x为根节点
root = y;
else if(x == x->parent->left) // x为其父节点的左子节点
x->parent->left = y;
else // x为其父节点的右子节点
x->parent->right = y;

y->left = x;
x->parent = y;
}

// 右旋函数
inline void _rb_tree_rotate_right(_rb_tree_node_base* x, _rb_tree_node_base*& root)
{
// x 为旋转点
_rb_tree_node_base* y = x->left; // 令y为旋转点的左子节点
x->left = y->right;
if(y->right != 0)
y->right->parent = x; // 别忘了回马枪设定父节点
y->parent = x->parent; // 令y完全顶替x的地位(必须将x对其父节点的关系完全接收过来)
if(x == root)
root = y;
else if(x == x->parent->right) // x为其父节点的右子节点
x->parent->right = y;
else // x为其父节点的左子节点
x->parent->left = y;

y->right = x;
x->parent = y;
}

算法导论书上给出的红黑树的性质如下,跟STL源码剖析书上面的4条性质大同小异。

  1. 每个结点或是红色的,或是黑色的
  2. 根节点是黑色的
  3. 每个叶结点(NIL)是黑色的
  4. 如果一个节点是红色的,则它的两个儿子都是黑色的。
  5. 对于每个结点,从该结点到其子孙结点的所有路径上包含相同数目的黑色结点。

从红黑树上删除一个节点,可以先用普通二叉搜索树的方法,将节点从红黑树上删除掉,然后再将被破坏的红黑性质进行恢复。我们回忆一下普通二叉树的节点删除方法:Z指向需要删除的节点,Y指向实质结构上被删除的结点,如果Z节点只有一个子节点或没有子节点,那么Y就是指向Z指向的节点。如果Z节点有两个子节点,那么Y指向Z节点的后继节点(其实前趋也是一样的),而Z的后继节点绝对不可能有左子树。因此,仅从结构来看,二叉树上实质被删除的节点最多只可能有一个子树。

现在我们来看红黑性质的恢复过程:如果Y指向的节点是个红色节点,那么直接删除掉Y以后,红黑性质不会被破坏。操作结束。如果Y指向的节点是个黑色节点,那么就有几条红黑性质可能受到破坏了。首先是包含Y节点的所有路径,黑高度都减少了一(第5条被破坏)。其次,如果Y的有红色子节点,Y又有红色的父节点,那么Y被删除后,就出现了两个相邻的红色节点(第4条被破坏)。最后,如果Y指向的是根节点,而Y的子节点又是红色的,那么Y被删除后,根节点就变成红色的了(第2条被破坏)。其中,第5条被破坏是让我们比较难受的。因为这影响到了全局。这样动作就太大太复杂了。而且在这个条件下,进行其它红黑性质的恢复也很困难。

所以我们首先解决这个问题:如果不改变含Y路径的黑高度,那么树的其它部分的黑高度就必须做出相应的变化来适应它。所以,我们想办法恢复原来含Y节点的路径的黑高度。做法就是:无条件的把Y节点的黑色,推到它的子节点X上去。(X可能是NIL节点)。这样,X就可能具有双重黑色,或同时具有红黑两色,也就是第1条性质被破坏了。但第1条性质是比较容易恢复的:

  1. 如果X是同时具有红黑两色,那么好办,直接把X涂成黑色,就行了。而且这样把所有问题都解决了。因为将X变为黑色,2、4两条如果有问题的话也会得到恢复,算法结束。
  2. 如果X是双黑色,那么我们希望把这种情况向上推一直推到根节点(调整树结构和颜色,X的指向新的双黑色节点,X不断向上移动),让根节点具双黑色,这时,直接把X的一层黑色去掉就行了(因为根节点被包含在所有的路径上,所以这样做所有路径同时黑高减少一,不会破坏红黑特征)。

下面就具体地分析如何恢复1、2、4三个可能被破坏的红黑特性:我们知道,如果X指向的节点是有红黑两色,或是X是根节点时,只需要简单的对X进行一些改变就行了。要对除X节点外的其它节点进行操作时,必定是这样的情况:X节点是双层黑色,且X有父节点P。由知可知,X必然有兄弟节点W,而且这个W节点必定有两个子节点。(因为这是原树满足红黑条件要求而自然具备的。X为双黑色,那么P的另一个子节点以下一定要有至少两层的节点,否则黑色高度不可能和X路径一致)。所以我们就分析这些节点之间如何变形,把问题限制在比较小的范围内解决。另一个前提是:X在一开始,肯定是树底的叶节点或是NIL节点,所以在递归向上的过程中,每一步都保证下一步进行时,至少X的子树是满足红黑特性的。因此子树的情况就可以认为是已经正确的了,这样,分析就只限制在X节点,X的父节点P和X的兄弟节点W,以及W的两个子节点中。

下面仅仅考虑X原本是黑色的情况即可。在这种情况下,X此时应该具有双重黑色,算法的过程就是将这多出的一重黑色向上移动,直到遇到红节点或者根节点。接着往下分析,会遇到4种情况,实际上是8种,因为其中4种是相互对称的,这可以通过判断X是其父节点的右孩子还是左孩子来区分。下面我们以X是其父节点的左孩子的情况来分析这4种情况,实际上接下来的调整过程,就是要想方设法将经过X的所有路径上的黑色节点个数增加1。

具体分为以下四种情况:(下面针对x是左儿子的情况讨论,右儿子对称)

Case1:X的兄弟W是红色(想办法将其变为黑色)。由于W是红色的,因此其儿子节点和父节点必为黑色,只要将W和其父节点的颜色对换,在对父节点进行一次左旋转,便将W的左子节点放到了X的兄弟节点上,X的兄弟节点变成了黑色,且红黑性质不变。但还不算完,只是暂时将情况1转变成了下面的情况2或3或4。

Case2:X的兄弟节点W是黑色的,而且W的两个子节点都是黑色的。此时可以将X的一重黑色和W的黑色同时去掉,而转加给他们的父节点上,这是X就指向它的父节点了,因此此时父节点具有双重颜色了。这一重黑色节点上移。

如果父节点原来是红色的,现在又加一层黑色,那么X现在指向的这个节点就是红黑两色的,直接把X(也就是父节点)着为黑色。问题就已经完整解决了。如果父节点现在是双层黑色,那就以父节点为新的X进行向上的下一次的递归。

Case3:X的兄弟节点W是黑色的,而且W的左子节点是红色的,右子节点是黑色的。此时通过交换W和其左子节点的颜色并进行一次向右旋转就可转换成下面的第四种情况。注意,原来L是红色的,所以L的子节点一定是黑色的,所以旋转中L节点的一个子树挂到之后着为红色的W节点上不会破坏红黑性质。变形后黑色高度不变。

Case4:X的兄弟节点W是黑色的,而且W的右子节点是红色的。这种情况下,做一次左旋,W就处于根的位置,将W保持为原来的根的位置的颜色,同时将W的两个新的儿子节点的颜色变为黑色,去掉X的一重黑色。这样整个问题也就得到了解决。递归结束。(在代码上,为了标识递归结束,我们把X指向根节点)

因此,只要按上面四种情况一直递归处理下去,X最终总会指向根结点或一个红色结点,这时我们就可以结束递归并把问题解决了。

以上就是红黑树的节点删除全过程。

总结:如果我们通过上面的情况画出所有的分支图,我们可以得出如下结论

  • 插入操作:解决的是 红-红 问题
  • 删除操作:解决的是 黑-黑 问题

即你可以从分支图中看出,需要往上遍历的情况为红红(插入),或者为黑黑黑(删除)的情况,如果你认真分析并总结所有的情况后,并坚持下来,红黑树也就没有想象中的那么恐怖了,并且很美妙;

详细的红黑树删除节点的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
#include<iostream>
using namespace std;

// 定义节点颜色
enum COLOR {
BLACK = 0,
RED
};

// 红黑树节点
typedef struct RB_Tree_Node {
int key;
struct RB_Tree_Node *left;
struct RB_Tree_Node *right;
struct RB_Tree_Node *parent;
unsigned char RB_COLOR;
} RB_Node;

// 红黑树,包含一个指向根节点的指针
typedef struct RBTree {
RB_Node* root;
} *RB_Tree;

// 红黑树的NIL节点
static RB_Tree_Node NIL = {0, 0, 0, 0, BLACK};

#define PNIL (&NIL) // NIL节点地址

void Init_RBTree(RB_Tree pTree) // 初始化一棵红黑树
{
pTree->root = PNIL;
}

// 查找最小键值节点
RB_Node* RBTREE_MIN(RB_Node* pRoot)
{
while (PNIL != pRoot->left)
{
pRoot = pRoot->left;
}
return pRoot;
}


// 查找指定节点的后继节点
RB_Node* RBTREE_SUCCESSOR(RB_Node* pRoot)
{
if (PNIL != pRoot->right)
{
return RBTREE_MIN(pRoot->right);
}

// 节点没有右子树的时候,进入下面的while循环
RB_Node* pParent = pRoot->parent;
while((PNIL != pParent) && (pRoot == pParent->right))
{
pRoot = pParent;
pParent = pRoot->parent;
}
return pParent;
}

// 红黑树的节点删除
RB_Node* Delete(RB_Tree pTree , RB_Node* pDel)
{
RB_Node* rel_delete_point;
if(pDel->left == PNIL || pDel->right == PNIL)
rel_delete_point = pDel;
else
rel_delete_point = RBTREE_SUCCESSOR(pDel);

// 查找后继节点
RB_Node* delete_point_child;
if(rel_delete_point->right != PNIL)
{
delete_point_child = rel_delete_point->right;
}
else if(rel_delete_point->left != PNIL)
{
delete_point_child = rel_delete_point->left;
}
else
{
delete_point_child = PNIL;
}
delete_point_child->parent = rel_delete_point->parent;
if(rel_delete_point->parent == PNIL) // 删除的节点是根节点
{
pTree->root = delete_point_child;
}
else if(rel_delete_point == rel_delete_point->parent->right)
{
rel_delete_point->parent->right = delete_point_child;
}
else
{
rel_delete_point->parent->left = delete_point_child;
}

if(pDel != rel_delete_point)
{
pDel->key = rel_delete_point->key;
}
if(rel_delete_point->RB_COLOR == BLACK)
{
DeleteFixUp(pTree , delete_point_child);
}
return rel_delete_point;
}
/*算法导论上的描述如下:
RB-DELETE-FIXUP(T, x)
1 while x ≠ root[T] and color[x] = BLACK
2 do if x = left[p[x]]
3 then w ← right[p[x]]
4 if color[w] = RED
5 then color[w] ← BLACK Case 1
6 color[p[x]] ← RED Case 1
7 LEFT-ROTATE(T, p[x]) Case 1
8 w ← right[p[x]] Case 1
9 if color[left[w]] = BLACK and color[right[w]] = BLACK
10 then color[w] ← RED Case 2
11 x p[x] Case 2
12 else if color[right[w]] = BLACK
13 then color[left[w]] ← BLACK Case 3
14 color[w] ← RED Case 3
15 RIGHT-ROTATE(T, w) Case 3
16 w ← right[p[x]] Case 3
17 color[w] ← color[p[x]] Case 4
18 color[p[x]] ← BLACK Case 4
19 color[right[w]] ← BLACK Case 4
20 LEFT-ROTATE(T, p[x]) Case 4
21 x ← root[T] Case 4
22 else (same as then clause with "right" and "left" exchanged)
23 color[x] ← BLACK */

//接下来的工作,很简单,即把上述伪代码改写成c++代码即可
void DeleteFixUp(RB_Tree pTree , RB_Node* node)
{
while(node != pTree->root && node->RB_COLOR == BLACK)
{
if(node == node->parent->left)
{
RB_Node* brother = node->parent->right;
if(brother->RB_COLOR==RED) //情况1:x的兄弟w是红色的。
{
brother->RB_COLOR = BLACK;
node->parent->RB_COLOR = RED;
RotateLeft(node->parent);
}
else //情况2:x的兄弟w是黑色的,
{
if(brother->left->RB_COLOR == BLACK && brother->right->RB_COLOR == BLACK) //w的两个孩子都是黑色的
{
brother->RB_COLOR = RED;
node = node->parent;
}
else
{
if(brother->right->RB_COLOR == BLACK) //情况3:x的兄弟w是黑色的,w的右孩子是黑色(w的左孩子是红色)
{
brother->RB_COLOR = RED;
brother->left->RB_COLOR = BLACK;
RotateRight(brother);
brother = node->parent->right; //情况3转换为情况4
} //情况4:x的兄弟w是黑色的,且w的右孩子时红色的
brother->RB_COLOR = node->parent->RB_COLOR;
node->parent->RB_COLOR = BLACK;
brother->right->RB_COLOR = BLACK;
RotateLeft(node->parent);
node = pTree->root;
}//else
}//else
}
else //同上,原理一致,只是遇到左旋改为右旋,遇到右旋改为左旋即可。其它代码不变。
{
RB_Node* brother = node->parent->left;
if(brother->RB_COLOR == RED)
{
brother->RB_COLOR = BLACK;
node->parent->RB_COLOR = RED;
RotateRight(node->parent);
}
else
{
if(brother->left->RB_COLOR==BLACK && brother->right->RB_COLOR == BLACK)
{
brother->RB_COLOR = RED;
node = node->parent;
}
else
{
if(brother->left->RB_COLOR==BLACK)
{
brother->RB_COLOR = RED;
brother->right->RB_COLOR = BLACK;
RotateLeft(brother);
brother = node->parent->left; //情况3转换为情况4
}
brother->RB_COLOR = node->parent->RB_COLOR;
node->parent->RB_COLOR = BLACK;
brother->left->RB_COLOR = BLACK;
RotateRight(node->parent);
node = pTree->root;
}
}
}
}//while
node->RB_COLOR = BLACK; //如果X节点原来为红色,那么直接改为黑色
}

斜堆之图文解析和C语言的实现

概要

本章介绍斜堆。和以往一样,本文会先对斜堆的理论知识进行简单介绍,然后给出C语言的实现。后续再分别给出C++和Java版本的实现;实现的语言虽不同,但是原理如出一辙,选择其中之一进行了解即可。若文章有错误或不足的地方,请不吝指出!

目录

  1. 斜堆的介绍
  2. 斜堆的基本操作
  3. 斜堆的C实现(完整源码)
  4. 斜堆的C测试程序

转载请注明出处:http://www.cnblogs.com/skywang12345/p/3638493.html

斜堆的介绍

斜堆(Skew heap)也叫自适应堆(self-adjusting heap),它是左倾堆的一个变种。和左倾堆一样,它通常也用于实现优先队列。它的合并操作的时间复杂度也是O(lg n)。

相比于左倾堆,斜堆的节点没有”零距离”这个属性。除此之外,它们斜堆的合并操作也不同。斜堆的合并操作算法如下:
(01) 如果一个空斜堆与一个非空斜堆合并,返回非空斜堆。
(02) 如果两个斜堆都非空,那么比较两个根节点,取较小堆的根节点为新的根节点。将”较小堆的根节点的右孩子”和”较大堆”进行合并。
(03) 合并后,交换新堆根节点的左孩子和右孩子。

第(03)步是斜堆和左倾堆的合并操作差别的关键所在,如果是左倾堆,则合并后要比较左右孩子的零距离大小,若右孩子的零距离 > 左孩子的零距离,则交换左右孩子;最后,在设置根的零距离。

头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#ifndef _SKEW_HEAP_H_
#define _SKEW_HEAP_H_

typedef int Type;

typedef struct _SkewNode{
Type key; // 关键字(键值)
struct _SkewNode *left; // 左孩子
struct _SkewNode *right; // 右孩子
}SkewNode, *SkewHeap;

// 前序遍历"斜堆"
void preorder_skewheap(SkewHeap heap);
// 中序遍历"斜堆"
void inorder_skewheap(SkewHeap heap);
// 后序遍历"斜堆"
void postorder_skewheap(SkewHeap heap);

// 获取最小值(保存到pval中),成功返回0,失败返回-1。
int skewheap_minimum(SkewHeap heap, int *pval);
// 合并"斜堆x"和"斜堆y",并返回合并后的新树
SkewNode* merge_skewheap(SkewHeap x, SkewHeap y);
// 将结点插入到斜堆中,并返回根节点
SkewNode* insert_skewheap(SkewHeap heap, Type key);
// 删除结点(key为节点的值),并返回根节点
SkewNode* delete_skewheap(SkewHeap heap);

// 销毁斜堆
void destroy_skewheap(SkewHeap heap);

// 打印斜堆
void print_skewheap(SkewHeap heap);

#endif

SkewNode是斜堆对应的节点类。

合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 
* 合并"斜堆x"和"斜堆y"
*
* 返回值:
* 合并得到的树的根节点
*/
SkewNode* merge_skewheap(SkewHeap x, SkewHeap y)
{
if(x == NULL)
return y;
if(y == NULL)
return x;

// 合并x和y时,将x作为合并后的树的根;
// 这里的操作是保证: x的key < y的key
if(x->key > y->key)
swap_skewheap_node(x, y);

// 将x的右孩子和y合并,
// 合并后直接交换x的左右孩子,而不需要像左倾堆一样考虑它们的npl。
SkewNode *tmp = merge_skewheap(x->right, y);
x->right = x->left;
x->left = tmp;

return x;
}

merge_skewheap(x, y)的作用是合并x和y这两个斜堆,并返回得到的新堆。merge_skewheap(x, y)是递归实现的。

添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 
* 新建结点(key),并将其插入到斜堆中
*
* 参数说明:
* heap 斜堆的根结点
* key 插入结点的键值
* 返回值:
* 根节点
*/
SkewNode* insert_skewheap(SkewHeap heap, Type key)
{
SkewNode *node; // 新建结点

// 如果新建结点失败,则返回。
if ((node = (SkewNode *)malloc(sizeof(SkewNode))) == NULL)
return heap;
node->key = key;
node->left = node->right = NULL;

return merge_skewheap(heap, node);
}

insert_skewheap(heap, key)的作用是新建键值为key的结点,并将其插入到斜堆中,并返回堆的根节点。

删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 
* 取出根节点
*
* 返回值:
* 取出根节点后的新树的根节点
*/
SkewNode* delete_skewheap(SkewHeap heap)
{
SkewNode *l = heap->left;
SkewNode *r = heap->right;

// 删除根节点
free(heap);

return merge_skewheap(l, r); // 返回左右子树合并后的新树
}

delete_skewheap(heap)的作用是删除斜堆的最小节点,并返回删除节点后的斜堆根节点。

斜堆的C实现(完整源码)

斜堆的头文件(skewheap.h)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#ifndef _SKEW_HEAP_H_
#define _SKEW_HEAP_H_

typedef int Type;

typedef struct _SkewNode{
Type key; // 关键字(键值)
struct _SkewNode *left; // 左孩子
struct _SkewNode *right; // 右孩子
}SkewNode, *SkewHeap;

// 前序遍历"斜堆"
void preorder_skewheap(SkewHeap heap);
// 中序遍历"斜堆"
void inorder_skewheap(SkewHeap heap);
// 后序遍历"斜堆"
void postorder_skewheap(SkewHeap heap);

// 获取最小值(保存到pval中),成功返回0,失败返回-1。
int skewheap_minimum(SkewHeap heap, int *pval);
// 合并"斜堆x"和"斜堆y",并返回合并后的新树
SkewNode* merge_skewheap(SkewHeap x, SkewHeap y);
// 将结点插入到斜堆中,并返回根节点
SkewNode* insert_skewheap(SkewHeap heap, Type key);
// 删除结点(key为节点的值),并返回根节点
SkewNode* delete_skewheap(SkewHeap heap);

// 销毁斜堆
void destroy_skewheap(SkewHeap heap);

// 打印斜堆
void print_skewheap(SkewHeap heap);

#endif

斜堆的实现文件(skewheap.c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
/**
* C语言实现的斜堆
*
* @author skywang
* @date 2014/03/31
*/

#include <stdio.h>
#include <stdlib.h>
#include "skewheap.h"

/*
* 前序遍历"斜堆"
*/
void preorder_skewheap(SkewHeap heap)
{
if(heap != NULL)
{
printf("%d ", heap->key);
preorder_skewheap(heap->left);
preorder_skewheap(heap->right);
}
}

/*
* 中序遍历"斜堆"
*/
void inorder_skewheap(SkewHeap heap)
{
if(heap != NULL)
{
inorder_skewheap(heap->left);
printf("%d ", heap->key);
inorder_skewheap(heap->right);
}
}

/*
* 后序遍历"斜堆"
*/
void postorder_skewheap(SkewHeap heap)
{
if(heap != NULL)
{
postorder_skewheap(heap->left);
postorder_skewheap(heap->right);
printf("%d ", heap->key);
}
}

/*
* 交换两个节点的内容
*/
static void swap_skewheap_node(SkewNode *x, SkewNode *y)
{
SkewNode tmp = *x;
*x = *y;
*y = tmp;
}

/*
* 获取最小值
*
* 返回值:
* 成功返回0,失败返回-1
*/
int skewheap_minimum(SkewHeap heap, int *pval)
{
if (heap == NULL)
return -1;

*pval = heap->key;

return 0;
}

/*
* 合并"斜堆x"和"斜堆y"
*
* 返回值:
* 合并得到的树的根节点
*/
SkewNode* merge_skewheap(SkewHeap x, SkewHeap y)
{
if(x == NULL)
return y;
if(y == NULL)
return x;

// 合并x和y时,将x作为合并后的树的根;
// 这里的操作是保证: x的key < y的key
if(x->key > y->key)
swap_skewheap_node(x, y);

// 将x的右孩子和y合并,
// 合并后直接交换x的左右孩子,而不需要像左倾堆一样考虑它们的npl。
SkewNode *tmp = merge_skewheap(x->right, y);
x->right = x->left;
x->left = tmp;

return x;
}

/*
* 新建结点(key),并将其插入到斜堆中
*
* 参数说明:
* heap 斜堆的根结点
* key 插入结点的键值
* 返回值:
* 根节点
*/
SkewNode* insert_skewheap(SkewHeap heap, Type key)
{
SkewNode *node; // 新建结点

// 如果新建结点失败,则返回。
if ((node = (SkewNode *)malloc(sizeof(SkewNode))) == NULL)
return heap;
node->key = key;
node->left = node->right = NULL;

return merge_skewheap(heap, node);
}

/*
* 取出根节点
*
* 返回值:
* 取出根节点后的新树的根节点
*/
SkewNode* delete_skewheap(SkewHeap heap)
{
SkewNode *l = heap->left;
SkewNode *r = heap->right;

// 删除根节点
free(heap);

return merge_skewheap(l, r); // 返回左右子树合并后的新树
}

/*
* 销毁斜堆
*/
void destroy_skewheap(SkewHeap heap)
{
if (heap==NULL)
return ;

if (heap->left != NULL)
destroy_skewheap(heap->left);
if (heap->right != NULL)
destroy_skewheap(heap->right);

free(heap);
}

/*
* 打印"斜堆"
*
* heap -- 斜堆的节点
* key -- 节点的键值
* direction -- 0,表示该节点是根节点;
* -1,表示该节点是它的父结点的左孩子;
* 1,表示该节点是它的父结点的右孩子。
*/
static void skewheap_print(SkewHeap heap, Type key, int direction)
{
if(heap != NULL)
{
if(direction==0) // heap是根节点
printf("%2d is root\n", heap->key);
else // heap是分支节点
printf("%2d is %2d's %6s child\n", heap->key, key, direction==1?"right" : "left");

skewheap_print(heap->left, heap->key, -1);
skewheap_print(heap->right,heap->key, 1);
}
}

void print_skewheap(SkewHeap heap)
{
if (heap != NULL)
skewheap_print(heap, heap->key, 0);
}

斜堆的测试程序(skewheap_test.c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* C语言实现的斜堆
*
* @author skywang
* @date 2014/03/31
*/

#include <stdio.h>
#include "skewheap.h"

#define LENGTH(a) ( (sizeof(a)) / (sizeof(a[0])) )

void main()
{
int i;
int a[]= {10,40,24,30,36,20,12,16};
int b[]= {17,13,11,15,19,21,23};
int alen=LENGTH(a);
int blen=LENGTH(b);
SkewHeap ha,hb;

ha=hb=NULL;

printf("== 斜堆(ha)中依次添加: ");
for(i=0; i<alen; i++)
{
printf("%d ", a[i]);
ha = insert_skewheap(ha, a[i]);
}
printf("\n== 斜堆(ha)的详细信息: \n");
print_skewheap(ha);


printf("\n== 斜堆(hb)中依次添加: ");
for(i=0; i<blen; i++)
{
printf("%d ", b[i]);
hb = insert_skewheap(hb, b[i]);
}
printf("\n== 斜堆(hb)的详细信息: \n");
print_skewheap(hb);

// 将"斜堆hb"合并到"斜堆ha"中。
ha = merge_skewheap(ha, hb);
printf("\n== 合并ha和hb后的详细信息: \n");
print_skewheap(ha);


// 销毁斜堆
destroy_skewheap(ha);
}

左倾堆之图文解析和C语言的实现

概要

本章介绍左倾堆,它和二叉堆一样,都是堆结构中的一员。和以往一样,本文会先对左倾堆的理论知识进行简单介绍,然后给出C语言的实现。后续再分别给出C++和Java版本的实现;实现的语言虽不同,但是原理如出一辙,选择其中之一进行了解即可。若文章有错误或不足的地方,请不吝指出!

目录

  1. 左倾堆的介绍
  2. 左倾堆的图文解析
  3. 左倾堆的C实现(完整源码)
  4. 左倾堆的C测试程序

转载请注明出处:http://www.cnblogs.com/skywang12345/p/3638327.html

左倾堆的介绍

左倾堆(leftist tree 或 leftist heap),又被成为左偏树、左偏堆,最左堆等。
它和二叉堆一样,都是优先队列实现方式。当优先队列中涉及到”对两个优先队列进行合并”的问题时,二叉堆的效率就无法令人满意了,而本文介绍的左倾堆,则可以很好地解决这类问题。

左倾堆的定义

左倾堆是一棵二叉树,它的节点除了和二叉树的节点一样具有左右子树指针外,还有两个属性:键值和零距离。
(01) 键值的作用是来比较节点的大小,从而对节点进行排序。
(02) 零距离(英文名NPL,即Null Path Length)则是从一个节点到一个”最近的不满节点”的路径长度。不满节点是指该该节点的左右孩子至少有有一个为NULL。叶节点的NPL为0,NULL节点的NPL为-1。

上图是一颗左倾堆,它满足左倾堆的基本性质:
[性质1] 节点的键值小于或等于它的左右子节点的键值。
[性质2] 节点的左孩子的NPL >= 右孩子的NPL。
[性质3] 节点的NPL = 它的右孩子的NPL + 1。

左倾堆,顾名思义,是有点向左倾斜的意思了。它在统计问题、最值问题、模拟问题和贪心问题等问题中有着广泛的应用。此外,斜堆是比左倾堆更为一般的数据结构。当然,今天讨论的是左倾堆,关于斜堆,以后再撰文来表。
前面说过,它能和好的解决”两个优先队列合并”的问题。实际上,左倾堆的合并操作的平摊时间复杂度为O(lg n),而完全二叉堆为O(n)。合并就是左倾树的重点,插入和删除操作都是以合并操作为基础的。插入操作,可以看作两颗左倾树合并;删除操作(移除优先队列中队首元素),则是移除根节点之后再合并剩余的两个左倾树。闲话说到这里,下面开始介绍左倾树的基本方法。

左倾堆的图文解析

合并操作是左倾堆的重点。合并两个左倾堆的基本思想如下:

  1. 如果一个空左倾堆与一个非空左倾堆合并,返回非空左倾堆。
  2. 如果两个左倾堆都非空,那么比较两个根节点,取较小堆的根节点为新的根节点。将”较小堆的根节点的右孩子”和”较大堆”进行合并。
  3. 如果新堆的右孩子的NPL > 左孩子的NPL,则交换左右孩子。
  4. 设置新堆的根节点的NPL = 右子堆NPL + 1

下面通过图文演示合并以下两个堆的过程。

提示:这两个堆的合并过程和测试程序相对应!

第1步:将”较小堆(根为10)的右孩子”和”较大堆(根为11)”进行合并。
合并的结果,相当于将”较大堆”设置”较小堆”的右孩子,如下图所示:

第2步:将上一步得到的”根11的右子树”和”根为12的树”进行合并,得到的结果如下:

第3步:将上一步得到的”根12的右子树”和”根为13的树”进行合并,得到的结果如下:

第4步:将上一步得到的”根13的右子树”和”根为16的树”进行合并,得到的结果如下:

第5步:将上一步得到的”根16的右子树”和”根为23的树”进行合并,得到的结果如下:

至此,已经成功的将两棵树合并成为一棵树了。接下来,对新生成的树进行调节。
第6步:上一步得到的”树16的右孩子的NPL > 左孩子的NPL”,因此交换左右孩子。得到的结果如下:

第7步:上一步得到的”树12的右孩子的NPL > 左孩子的NPL”,因此交换左右孩子。得到的结果如下:

第8步:上一步得到的”树10的右孩子的NPL > 左孩子的NPL”,因此交换左右孩子。得到的结果如下:

至此,合并完毕。上面就是合并得到的左倾堆!

下面看看左倾堆的基本操作的代码

  1. 头文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    #ifndef _LEFTIST_TREE_H_
    #define _LEFTIST_TREE_H_

    typedef int Type;

    typedef struct _LeftistNode{
    Type key; // 关键字(键值)
    int npl; // 零路经长度(Null Path Length)
    struct _LeftistNode *left; // 左孩子
    struct _LeftistNode *right; // 右孩子
    }LeftistNode, *LeftistHeap;

    // 前序遍历"左倾堆"
    void preorder_leftist(LeftistHeap heap);
    // 中序遍历"左倾堆"
    void inorder_leftist(LeftistHeap heap);
    // 后序遍历"左倾堆"
    void postorder_leftist(LeftistHeap heap);

    // 获取最小值(保存到pval中),成功返回0,失败返回-1。
    int leftist_minimum(LeftistHeap heap, int *pval);
    // 合并"左倾堆x"和"左倾堆y",并返回合并后的新树
    LeftistNode* merge_leftist(LeftistHeap x, LeftistHeap y);
    // 将结点插入到左倾堆中,并返回根节点
    LeftistNode* insert_leftist(LeftistHeap heap, Type key);
    // 删除结点(key为节点的值),并返回根节点
    LeftistNode* delete_leftist(LeftistHeap heap);

    // 销毁左倾堆
    void destroy_leftist(LeftistHeap heap);

    // 打印左倾堆
    void print_leftist(LeftistHeap heap);

    #endif
    LeftistNode是左倾堆对应的节点类。
  1. 合并
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    /* 
    * 合并"左倾堆x"和"左倾堆y"
    *
    * 返回值:
    * 合并得到的树的根节点
    */
    LeftistNode* merge_leftist(LeftistHeap x, LeftistHeap y)
    {
    if(x == NULL)
    return y;
    if(y == NULL)
    return x;

    // 合并x和y时,将x作为合并后的树的根;
    // 这里的操作是保证: x的key < y的key
    if(x->key > y->key)
    swap_leftist_node(x, y);

    // 将x的右孩子和y合并,"合并后的树的根"是x的右孩子。
    x->right = merge_leftist(x->right, y);

    // 如果"x的左孩子为空" 或者 "x的左孩子的npl<右孩子的npl"
    // 则,交换x和y
    if(x->left == NULL || x->left->npl < x->right->npl)
    {
    LeftistNode *tmp = x->left;
    x->left = x->right;
    x->right = tmp;
    }
    // 设置合并后的新树(x)的npl
    if (x->right == NULL || x->left == NULL)
    x->npl = 0;
    else
    x->npl = (x->left->npl > x->right->npl) ? (x->right->npl + 1) : (x->left->npl + 1);

    return x;
    }

merge_leftist(x, y)的作用是合并x和y这两个左倾堆,并返回得到的新堆。merge_leftist(x, y)是递归实现的。

  1. 添加

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /* 
    * 新建结点(key),并将其插入到左倾堆中
    *
    * 参数说明:
    * heap 左倾堆的根结点
    * key 插入结点的键值
    * 返回值:
    * 根节点
    */
    LeftistNode* insert_leftist(LeftistHeap heap, Type key)
    {
    LeftistNode *node; // 新建结点

    // 如果新建结点失败,则返回。
    if ((node = (LeftistNode *)malloc(sizeof(LeftistNode))) == NULL)
    return heap;
    node->key = key;
    node->npl = 0;
    node->left = node->right = NULL;

    return merge_leftist(heap, node);
    }

    insert_leftist(heap, key)的作用是新建键值为key的结点,并将其插入到左倾堆中,并返回堆的根节点。

  2. 删除

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /* 
    * 取出根节点
    *
    * 返回值:
    * 取出根节点后的新树的根节点
    */
    LeftistNode* delete_leftist(LeftistHeap heap)
    {
    if (heap == NULL)
    return NULL;

    LeftistNode *l = heap->left;
    LeftistNode *r = heap->right;

    // 删除根节点
    free(heap);

    return merge_leftist(l, r); // 返回左右子树合并后的新树
    }

    delete_leftist(heap)的作用是删除左倾堆的最小节点,并返回删除节点后的左倾堆根节点。

左倾堆的头文件(leftist.h)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#ifndef _LEFTIST_TREE_H_
#define _LEFTIST_TREE_H_

typedef int Type;

typedef struct _LeftistNode{
Type key; // 关键字(键值)
int npl; // 零路经长度(Null Path Length)
struct _LeftistNode *left; // 左孩子
struct _LeftistNode *right; // 右孩子
}LeftistNode, *LeftistHeap;

// 前序遍历"左倾堆"
void preorder_leftist(LeftistHeap heap);
// 中序遍历"左倾堆"
void inorder_leftist(LeftistHeap heap);
// 后序遍历"左倾堆"
void postorder_leftist(LeftistHeap heap);

// 获取最小值(保存到pval中),成功返回0,失败返回-1。
int leftist_minimum(LeftistHeap heap, int *pval);
// 合并"左倾堆x"和"左倾堆y",并返回合并后的新树
LeftistNode* merge_leftist(LeftistHeap x, LeftistHeap y);
// 将结点插入到左倾堆中,并返回根节点
LeftistNode* insert_leftist(LeftistHeap heap, Type key);
// 删除结点(key为节点的值),并返回根节点
LeftistNode* delete_leftist(LeftistHeap heap);

// 销毁左倾堆
void destroy_leftist(LeftistHeap heap);

// 打印左倾堆
void print_leftist(LeftistHeap heap);

#endif

左倾堆的实现文件(leftist.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
* C语言实现的左倾堆
*
* @author skywang
* @date 2014/03/31
*/

#include <stdio.h>
#include <stdlib.h>
#include "leftist.h"

/*
* 前序遍历"左倾堆"
*/
void preorder_leftist(LeftistHeap heap)
{
if(heap != NULL)
{
printf("%d ", heap->key);
preorder_leftist(heap->left);
preorder_leftist(heap->right);
}
}

/*
* 中序遍历"左倾堆"
*/
void inorder_leftist(LeftistHeap heap)
{
if(heap != NULL)
{
inorder_leftist(heap->left);
printf("%d ", heap->key);
inorder_leftist(heap->right);
}
}

/*
* 后序遍历"左倾堆"
*/
void postorder_leftist(LeftistHeap heap)
{
if(heap != NULL)
{
postorder_leftist(heap->left);
postorder_leftist(heap->right);
printf("%d ", heap->key);
}
}

/*
* 交换两个节点的内容
*/
static void swap_leftist_node(LeftistNode *x, LeftistNode *y)
{
LeftistNode tmp = *x;
*x = *y;
*y = tmp;
}

/*
* 获取最小值
*
* 返回值:
* 成功返回0,失败返回-1
*/
int leftist_minimum(LeftistHeap heap, int *pval)
{
if (heap == NULL)
return -1;

*pval = heap->key;

return 0;
}

/*
* 合并"左倾堆x"和"左倾堆y"
*
* 返回值:
* 合并得到的树的根节点
*/
LeftistNode* merge_leftist(LeftistHeap x, LeftistHeap y)
{
if(x == NULL)
return y;
if(y == NULL)
return x;

// 合并x和y时,将x作为合并后的树的根;
// 这里的操作是保证: x的key < y的key
if(x->key > y->key)
swap_leftist_node(x, y);

// 将x的右孩子和y合并,"合并后的树的根"是x的右孩子。
x->right = merge_leftist(x->right, y);

// 如果"x的左孩子为空" 或者 "x的左孩子的npl<右孩子的npl"
// 则,交换x和y
if(x->left == NULL || x->left->npl < x->right->npl)
{
LeftistNode *tmp = x->left;
x->left = x->right;
x->right = tmp;
}
// 设置合并后的新树(x)的npl
if (x->right == NULL || x->left == NULL)
x->npl = 0;
else
x->npl = (x->left->npl > x->right->npl) ? (x->right->npl + 1) : (x->left->npl + 1);

return x;
}

/*
* 新建结点(key),并将其插入到左倾堆中
*
* 参数说明:
* heap 左倾堆的根结点
* key 插入结点的键值
* 返回值:
* 根节点
*/
LeftistNode* insert_leftist(LeftistHeap heap, Type key)
{
LeftistNode *node; // 新建结点

// 如果新建结点失败,则返回。
if ((node = (LeftistNode *)malloc(sizeof(LeftistNode))) == NULL)
return heap;
node->key = key;
node->npl = 0;
node->left = node->right = NULL;

return merge_leftist(heap, node);
}

/*
* 取出根节点
*
* 返回值:
* 取出根节点后的新树的根节点
*/
LeftistNode* delete_leftist(LeftistHeap heap)
{
if (heap == NULL)
return NULL;

LeftistNode *l = heap->left;
LeftistNode *r = heap->right;

// 删除根节点
free(heap);

return merge_leftist(l, r); // 返回左右子树合并后的新树
}

/*
* 销毁左倾堆
*/
void destroy_leftist(LeftistHeap heap)
{
if (heap==NULL)
return ;

if (heap->left != NULL)
destroy_leftist(heap->left);
if (heap->right != NULL)
destroy_leftist(heap->right);

free(heap);
}

/*
* 打印"左倾堆"
*
* heap -- 左倾堆的节点
* key -- 节点的键值
* direction -- 0,表示该节点是根节点;
* -1,表示该节点是它的父结点的左孩子;
* 1,表示该节点是它的父结点的右孩子。
*/
static void leftist_print(LeftistHeap heap, Type key, int direction)
{
if(heap != NULL)
{
if(direction==0) // heap是根节点
printf("%2d(%d) is root\n", heap->key, heap->npl);
else // heap是分支节点
printf("%2d(%d) is %2d's %6s child\n", heap->key, heap->npl, key, direction==1?"right" : left");

leftist_print(heap->left, heap->key, -1);
leftist_print(heap->right,heap->key, 1);
}
}

void print_leftist(LeftistHeap heap)
{
if (heap != NULL)
leftist_print(heap, heap->key, 0);
}

左倾堆的测试程序(leftist_test.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* C语言实现的左倾堆
*
* @author skywang
* @date 2014/03/31
*/

#include <stdio.h>
#include "leftist.h"

#define LENGTH(a) ( (sizeof(a)) / (sizeof(a[0])) )

void main()
{
int i;
int a[]= {10,40,24,30,36,20,12,16};
int b[]= {17,13,11,15,19,21,23};
int alen=LENGTH(a);
int blen=LENGTH(b);
LeftistHeap ha,hb;

ha=hb=NULL;

printf("== 左倾堆(ha)中依次添加: ");
for(i=0; i<alen; i++)
{
printf("%d ", a[i]);
ha = insert_leftist(ha, a[i]);
}
printf("\n== 左倾堆(ha)的详细信息: \n");
print_leftist(ha);


printf("\n== 左倾堆(hb)中依次添加: ");
for(i=0; i<blen; i++)
{
printf("%d ", b[i]);
hb = insert_leftist(hb, b[i]);
}
printf("\n== 左倾堆(hb)的详细信息: \n");
print_leftist(hb);

// 将"左倾堆hb"合并到"左倾堆ha"中。
ha = merge_leftist(ha, hb);
printf("\n== 合并ha和hb后的详细信息: \n");
print_leftist(ha);


// 销毁左倾堆
destroy_leftist(ha);
}

左倾堆的C测试程序

左倾堆的测试程序已经包含在它的实现文件(leftist_test.c)中了,这里仅给出它的运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
== 左倾堆(ha)中依次添加: 10 40 24 30 36 20 12 16 
== 左倾堆(ha)的详细信息:
10(2) is root
24(1) is 10's left child
30(0) is 24's left child
36(0) is 24's right child
12(1) is 10's right child
20(0) is 12's left child
40(0) is 20's left child
16(0) is 12's right child

== 左倾堆(hb)中依次添加: 17 13 11 15 19 21 23
== 左倾堆(hb)的详细信息:
11(2) is root
15(1) is 11's left child
19(0) is 15's left child
21(0) is 15's right child
13(1) is 11's right child
17(0) is 13's left child
23(0) is 13's right child

== 合并ha和hb后的详细信息:
10(2) is root
11(2) is 10's left child
15(1) is 11's left child
19(0) is 15's left child
21(0) is 15's right child
12(1) is 11's right child
13(1) is 12's left child
17(0) is 13's left child
16(0) is 13's right child
23(0) is 16's left child
20(0) is 12's right child
40(0) is 20's left child
24(1) is 10's right child
30(0) is 24's left child
36(0) is 24's right child

跳跃表原理

Skip List是在有序链表的基础上进行了扩展,解决了有序链表结构查找特定值困难的问题,查找特定值的时间复杂度为O(logn),他是一种可以代替平衡树的数据结构。下面是skipList的一个介绍,转载来的,源地址:http://kenby.iteye.com/blog/1187303,

什么选择跳表

目前经常使用的平衡数据结构有:B树,红黑树,AVL树,Splay Tree, Treep等。想象一下,给你一张草稿纸,一只笔,一个编辑器,你能立即实现一颗红黑树,或者AVL树出来吗? 很难吧,这需要时间,要考虑很多细节,要参考一堆算法与数据结构之类的树,还要参考网上的代码,相当麻烦。

用跳表吧,跳表是一种随机化的数据结构,目前开源软件 Redis 和 LevelDB 都有用到它,它的效率和红黑树以及 AVL 树不相上下,但跳表的原理相当简单,只要你能熟练操作链表,就能轻松实现一个 SkipList。

有序表的搜索

考虑一个有序表:

从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉搜索树,我们把一些节点提取出来,作为索引。得到如下结构:

这里我们把 < 14, 34, 50, 72 > 提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。 我们还可以再从一级索引提取一些元素出来,作为二级索引,变成如下结构:

这里元素不多,体现不出优势,如果元素足够多,这种索引结构就能体现出优势来了。

跳表

下面的结构是就是跳表:-1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。

跳表具有如下性质:

  1. 由很多层结构组成
  2. 每一层都是一个有序的链表
  3. 最底层(Level 1)的链表包含所有元素
  4. 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
  5. 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。

跳表的搜索

例子:查找元素 117

  1. 比较 21, 比 21 大,往后面找
  2. 比较 37, 比 37大,比链表最大值小,从 37 的下面一层开始找
  3. 比较 71, 比 71 大,比链表最大值小,从 71 的下面一层开始找
  4. 比较 85, 比 85 大,从后面找
  5. 比较 117, 等于 117, 找到了节点。

具体的搜索算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 如果存在 x, 返回 x 所在的节点, 
* 否则返回 x 的后继节点 */
find(x)
{
p = top;
while (1) {
while (p->next->key < x)
p = p->next;
if (p->down == NULL)
return p->next;
p = p->down;
}
}

跳表的插入

先确定该元素要占据的层数 K(采用丢硬币的方式,这完全是随机的),然后在 Level 1 … Level K 各个层的链表都插入元素。
例子:插入 119, K = 2

如果 K 大于链表的层数,则要添加新的层。例子:插入 119, K = 4

丢硬币决定 K

插入元素的时候,元素所占有的层数完全是随机的,通过一下随机算法产生:

1
2
3
4
5
6
7
8
9
int random_level()  
{
K = 1;

while (random(0,1))
K++;

return K;
}

相当与做一次丢硬币的实验,如果遇到正面,继续丢,遇到反面,则停止,用实验中丢硬币的次数 K 作为元素占有的层数。显然随机变量 K 满足参数为 p = 1/2 的几何分布,K 的期望值 E[K] = 1/p = 2. 就是说,各个元素的层数,期望值是 2 层。

跳表的高度。

n 个元素的跳表,每个元素插入的时候都要做一次实验,用来决定元素占据的层数 K,跳表的高度等于这 n 次实验中产生的最大 K,

跳表的空间复杂度分析

根据上面的分析,每个元素的期望高度为 2, 一个大小为 n 的跳表,其节点数目的期望值是 2n。

跳表的删除

在各个层中找到包含 x 的节点,使用标准的 delete from list 方法删除该节点。
例子:删除 71

字典树

字典树又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来节约存储空间,最大限度地减少无谓的字符串比较,查询效率比哈希表高。

字典树与字典很相似,当你要查一个单词是不是在字典树中,首先看单词的第一个字母是不是在字典的第一层,如果不在,说明字典树里没有该单词,如果在就在该字母的孩子节点里找是不是有单词的第二个字母,没有说明没有该单词,有的话用同样的方法继续查找.字典树不仅可以用来储存字母,也可以储存数字等其它数据。

Trie的数据结构定义:

1
2
3
4
5
6
7
8
#define MAX 26
typedef struct Trie
{
Trie *next[MAX];
int v; //根据需要变化
};

Trie *root;

next是表示每层有多少种类的数,如果只是小写字母,则26即可,若改为大小写字母,则是52,若再加上数字,则是62了,这里根据题意来确定。
v可以表示一个字典树到此有多少相同前缀的数目,这里根据需要应当学会自由变化。

Trie的查找(最主要的操作):

  1. 每次从根结点开始一次搜索;
  2. 取得要查找关键词的第一个字母,并根据该字母选择对应的子树并转到该子树继续进行检索;
  3. 在相应的子树上,取得要查找关键词的第二个字母,并进一步选择对应的子树进行检索。   
  4. 迭代过程……   
  5. 在某个结点处,关键词的所有字母已被取出,则读取附在该结点上的信息,即完成查找。

这里给出生成字典树和查找的模版:
生成字典树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void createTrie(char *str)
{
int len = strlen(str);
Trie *p = root, *q;
for(int i=0; i<len; ++i)
{
int id = str[i]-'0';
if(p->next[id] == NULL)
{
q = (Trie *)malloc(sizeof(Trie));
q->v = 1; //初始v==1
for(int j=0; j<MAX; ++j)
q->next[j] = NULL;
p->next[id] = q;
p = p->next[id];
}
else
{
p->next[id]->v++;
p = p->next[id];
}
}
p->v = -1; //若为结尾,则将v改成-1表示
}

接下来是查找的过程了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int findTrie(char *str)
{
int len = strlen(str);
Trie *p = root;
for(int i=0; i<len; ++i)
{
int id = str[i]-'0';
p = p->next[id];
if(p == NULL) //若为空集,表示不存以此为前缀的串
return 0;
if(p->v == -1) //字符集中已有串是此串的前缀
return -1;
}
return -1; //此串是字符集中某串的前缀
}

对于上述动态字典树,有时会超内存,这是就要记得释放空间了:

1
2
3
4
5
6
7
8
9
10
11
12
13
int dealTrie(Trie* T)
{
int i;
if(T==NULL)
return 0;
for(i=0;i<MAX;i++)
{
if(T->next[i]!=NULL)
deal(T->next[i]);
}
free(T);
return 0;
}

Trie的删除操作就稍微复杂一些,主要分为以下3种情况:

如果待删除的单词是另一个单词的前缀,只需要把该单词的最后一个节点的 isWord 的改成false

比如Trie中存在 panda 和 pan 这两个单词,删除 pan ,只需要把字符 n 对应的节点的 isWord 改成 false 即可

如果单词的所有字母的都没有多个分支(也就是说该单词所有的字符对应的Node都只有一个子节点),则删除整个单词

如果单词的除了最后一个字母,其他的字母有多个分支

位图(BitMap)索引

案例

  有张表名为table的表,由三列组成,分别是姓名、性别和婚姻状况,其中性别只有男和女两项,婚姻状况由已婚、未婚、离婚这三项,该表共有100w个记录。现在有这样的查询: select * from table where Gender=‘男’ and Marital=“未婚”。

姓名(Name) 性别(Gender) 婚姻状况(Marital)
张三 已婚
李四 已婚
王五 未婚
赵六 离婚
孙七 未婚
  1. 不使用索引

不使用索引时,数据库只能一行行扫描所有记录,然后判断该记录是否满足查询条件。

  1. B树索引

对于性别,可取值的范围只有’男’,’女’,并且男和女可能各站该表的50%的数据,这时添加B树索引还是需要取出一半的数据, 因此完全没有必要。相反,如果某个字段的取值范围很广,几乎没有重复,比如身份证号,此时使用B树索引较为合适。事实上,当取出的行数据占用表中大部分的数据时,即使添加了B树索引,数据库如oracle、mysql也不会使用B树索引,很有可能还是一行行全部扫描。

位图索引出马

如果用户查询的列的基数非常的小, 即只有的几个固定值,如性别、婚姻状况、行政区等等。要为这些基数值比较小的列建索引,就需要建立位图索引。

对于性别这个列,位图索引形成两个向量,男向量为10100…,向量的每一位表示该行是否是男,如果是则位1,否为0,同理,女向量位01011。

RowId 1 2 3 4 5
1 0 1 0 0
0 1 0 1 1

对于婚姻状况这一列,位图索引生成三个向量,已婚为11000…,未婚为00100…,离婚为00010…。

RowId 1 2 3 4 5
已婚 1 1 0 0 0
未婚 0 0 1 0 1
离婚 0 0 0 1 0

当我们使用查询语句“select * from table where Gender=‘男’ and Marital=“未婚”;”的时候 首先取出男向量10100…,然后取出未婚向量00100…,将两个向量做and操作,这时生成新向量00100…,可以发现第三位为1,表示该表的第三行数据就是我们需要查询的结果。

RowId 1 2 3 4 5
1 0 1 0 0
未婚 0 0 1 0 1
结果 0 0 1 0 0

位图索引的适用条件

上面讲了,位图索引适合只有几个固定值的列,如性别、婚姻状况、行政区等等,而身份证号这种类型不适合用位图索引。

此外,位图索引适合静态数据,而不适合索引频繁更新的列。举个例子,有这样一个字段busy,记录各个机器的繁忙与否,当机器忙碌时,busy为1,当机器不忙碌时,busy为0。

这个时候有人会说使用位图索引,因为busy只有两个值。好,我们使用位图索引索引busy字段!假设用户A使用update更新某个机器的busy值,比如update table set table.busy=1 where rowid=100;,但还没有commit,而用户B也使用update更新另一个机器的busy值,update table set table.busy=1 where rowid=12; 这个时候用户B怎么也更新不了,需要等待用户A commit。

原因:用户A更新了某个机器的busy值为1,会导致所有busy为1的机器的位图向量发生改变,因此数据库会将busy=1的所有行锁定,只有commit之后才解锁。

源地址:http://www.cnblogs.com/LBSer

Boyer-Moore 字符串匹配算法

在 1977 年,Robert S. Boyer (Stanford Research Institute) 和 J Strother Moore (Xerox Palo Alto Research Center) 共同发表了文章《A Fast String Searching Algorithm》,介绍了一种新的快速字符串匹配算法。这种算法在逻辑上相对于现有的算法有了显著的改进,它对要搜索的字符串进行倒序的字符比较,并且当字符比较不匹配时无需对整个模式串再进行搜索。

Boyer-Moore 算法的主要特点有:

  • 对模式字符的比较顺序时从右向左;
  • 预处理需要 O(m + σ) 的时间和空间复杂度;
  • 匹配阶段需要 O(m × n) 的时间复杂度;
  • 匹配阶段在最坏情况下需要 3n 次字符比较;
  • 最优复杂度 O(n/m);

在 Naive 算法中,对文本 T 和模式 P 字符串均未做预处理。而在 KMP 算法中则对模式 P 字符串进行了预处理操作,以预先计算模式串中各位置的最长相同前后缀长度的数组。Boyer–Moore 算法同样也是对模式 P 字符串进行预处理。

我们知道,在 Naive 算法中,如果发现模式 P 中的字符与文本 T 中的字符不匹配时,需要将文本 T 的比较位置向后滑动一位,模式 P 的比较位置归 0 并从头开始比较。而 KMP 算法则是根据预处理的结果进行判断以使模式 P 的比较位置可以向后滑动多个位置。Boyer–Moore 算法的预处理过程也是为了达到相同效果。

Boyer–Moore 算法在对模式 P 字符串进行预处理时,将采用两种不同的启发式方法。这两种启发式的预处理方法称为:

  • 坏字符(Bad Character Heuristic):当文本 T 中的某个字符跟模式 P 的某个字符不匹配时,我们称文本 T 中的这个失配字符为坏字符。
  • 好后缀(Good Suffix Heuristic):当文本 T 中的某个字符跟模式 P 的某个字符不匹配时,我们称文本 T 中的已经匹配的字符串为好后缀。

Boyer–Moore 算法在预处理时,将为两种不同的启发法结果创建不同的数组,分别称为 Bad-Character-Shift(or The Occurrence Shift)和 Good-Suffix-Shift(or Matching Shift)。当进行字符匹配时,如果发现模式 P 中的字符与文本 T 中的字符不匹配时,将比较两种不同启发法所建议的移动位移长度,选择最大的一个值来对模式 P 的比较位置进行滑动。

此外,Naive 算法和 KMP 算法对模式 P 的比较方向是从前向后比较,而 Boyer–Moore 算法的设计则是从后向前比较,即从尾部向头部方向进行比较。

下面,我们将以 J Strother Moore 提供的例子作为示例。

1
2
Text T : HERE IS A SIMPLE EXAMPLE
Pattern P : EXAMPLE

首先将文本 T 与模式 P 头部对齐,并从尾部开始进行比较。
1
2
HERE IS A SIMPLE EXAMPLE
EXAMPLE

这样如果尾部的字符不匹配,则前面的字符也就无需比较了,直接跳过。我们看到,”S” 与 “E” 不匹配,我们称文本 T 中的失配字符 “S” 为坏字符(Bad Character)。由于字符 “S” 在模式 “EXAMPLE” 中不存在,则可将搜索位置滑动到 “S” 的后面。
1
2
HERE IS A SIMPLE EXAMPLE
EXAMPLE

仍然从尾部开始比较,发现 “P” 与 “E” 不匹配,所以 “P” 是坏字符。但此时,”P” 包含在模式 “EXAMPLE” 之中。所以,将模式后移两位,使两个 “P” 对齐。
1
2
HERE IS A SIMPLE EXAMPLE
EXAMPLE

由此总结坏字符启发法的规则是:
1
模式后移位数 = 坏字符在模式中失配的位置 - 坏字符在模式中最后一次出现的位置

坏字符启发法规则中的特殊情况:

  • 如果坏字符不存在于模式中,则最后一次出现的位置为 -1。
  • 如果坏字符在模式中的位置位于失配位置的右侧,则此启发法不提供任何建议。

以上面示例中坏字符 “P” 为例,它的失配位置为 “E” 的位置 6 (位置从 0 开始),在模式中最后一次出现的位置是 4,则模式后移位数为 6 - 4 = 2 位。模式移动的结果就是使模式中最后出现的 “P” 与文本中的坏字符 “P” 进行对齐。

实际上,前面的坏字符 “S” 出现时,其失配位置为 6,最后一次出现位置为 -1,所以模式后移位数为 6 - (-1) = 7 位。也就是将模式整体移过坏字符。

我们继续上面的过程,仍然从尾部开始比较。仍然从尾部开始比较,发现 “E” 与 “E” 匹配,则继续倒序比较。发现 “L” 与 “L” 匹配,则继续倒序比较。发现 “P” 与 “P” 匹配,则继续倒序比较。发现 “M” 与 “M” 匹配,则继续倒序比较。发现 “I” 与 “A” 不匹配,则 “I” 是坏字符。对于前面已经匹配的字符串 “MPLE”、”PLE”、”LE”、”E”,我们称它们为好后缀(Good Suffix)。

1
2
HERE IS A SIMPLE EXAMPLE
EXAMPLE

而好后缀启发法的规则是:

1
模式后移位数 = 好后缀在模式中的当前位置 - 好后缀在模式中最右出现且前缀字符不同的位置

好后缀在模式中的当前位置以其最后一个字符为准。如果好后缀不存在于模式中,则最右出现的位置为 -1。这样,我们先来找出好后缀在模式中上一次出现的位置。

  • “MPLE” : 未出现,最右出现的位置为 -1;
  • “PLE” : 未出现在头部,最右出现的位置为 -1;
  • “LE” : 未出现在头部,最右出现的位置为 -1;
  • “E” : 出现在头部,补充虚拟字符 ‘MPL’E,前缀字符为空,最右出现的位置为 0;

由于只有 “E” 在模式中出现,其当前位置为 6,上一次出现的位置为 0,则依据好后缀启发法规则,模式后移位数为 6 - 0 = 6 位。而如果依据坏字符启发法规则,模式后移位数为 2 - (-1) = 3 位。

Boyer–Moore 算法的特点就在于此,选择上述两种启发法规则计算结果中最大的一个值来对模式 P 的比较位置进行滑动。这里将选择好后缀启发法的计算结果,即将模式向后移 6 位。

1
2
HERE IS A SIMPLE EXAMPLE
EXAMPLE

此时,仍然从尾部开始比较。发现 “P” 与 “E” 不匹配,则 “P” 是坏字符,则模式后移位数为 6 - 4 = 2 位。

1
2
HERE IS A SIMPLE EXAMPLE
EXAMPLE

发现 “E” 与 “E” 匹配,则继续倒序比较,直到发现全部匹配,则匹配到的第一个完整的模式 P 被发现。

继续下去则是依据好后缀启示法规则计算好后缀 “E” 的后移位置为 6 - 0 = 6 位,然后继续倒序比较时发现已超出文本 T 的范围,搜索结束。

从上面的示例描述可以看出,Boyer–Moore 算法的精妙之处在于,其通过两种启示规则来计算后移位数,且其计算过程只与模式 P 有关,而与文本 T 无关。因此,在对模式 P 进行预处理时,可预先生成 “坏字符规则之向后位移表” 和 “好后缀规则之向后位移表”,在具体匹配时仅需查表比较两者中最大的位移即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
namespace StringMatching
{
class Program
{
static void Main(string[] args)
{
char[] text1 = "BBC ABCDAB ABCDABCDABDE".ToCharArray();
char[] pattern1 = "ABCDABD".ToCharArray();

int firstShift1;
bool isMatched1 = BoyerMooreStringMatcher.TryMatch(
text1, pattern1, out firstShift1);
Contract.Assert(isMatched1);
Contract.Assert(firstShift1 == 15);

char[] text2 = "ABABDAAAACAAAABCABAB".ToCharArray();
char[] pattern2 = "AAACAAAA".ToCharArray();

int firstShift2;
bool isMatched2 = BoyerMooreStringMatcher.TryMatch(
text2, pattern2, out firstShift2);
Contract.Assert(isMatched2);
Contract.Assert(firstShift2 == 6);

char[] text3 = "ABAAACAAAAAACAAAABCABAAAACAAAAFDLAAACAAAAAACAAAA"
.ToCharArray();
char[] pattern3 = "AAACAAAA".ToCharArray();

int[] shiftIndexes3 = BoyerMooreStringMatcher.MatchAll(text3, pattern3);
Contract.Assert(shiftIndexes3.Length == 5);
Contract.Assert(string.Join(",", shiftIndexes3) == "2,9,22,33,40");

char[] text4 = "GCATCGCAGAGAGTATACAGTACG".ToCharArray();
char[] pattern4 = "GCAGAGAG".ToCharArray();

int firstShift4;
bool isMatched4 = BoyerMooreStringMatcher.TryMatch(
text4, pattern4, out firstShift4);
Contract.Assert(isMatched4);
Contract.Assert(firstShift4 == 5);

char[] text5 = "HERE IS A SIMPLE EXAMPLE AND EXAMPLE OF BM.".ToCharArray();
char[] pattern5 = "EXAMPLE".ToCharArray();

int firstShift5;
bool isMatched5 = BoyerMooreStringMatcher.TryMatch(
text5, pattern5, out firstShift5);
Contract.Assert(isMatched5);
Contract.Assert(firstShift5 == 17);
int[] shiftIndexes5 = BoyerMooreStringMatcher.MatchAll(text5, pattern5);
Contract.Assert(shiftIndexes5.Length == 2);
Contract.Assert(string.Join(",", shiftIndexes5) == "17,29");

Console.WriteLine("Well done!");
Console.ReadKey();
}
}

public class BoyerMooreStringMatcher
{
private static int AlphabetSize = 256;

private static int Max(int a, int b) { return (a > b) ? a : b; }

static int[] PreprocessToBuildBadCharactorHeuristic(char[] pattern)
{
int m = pattern.Length;
int[] badCharactorShifts = new int[AlphabetSize];

for (int i = 0; i < AlphabetSize; i++)
{
//badCharactorShifts[i] = -1;
badCharactorShifts[i] = m;
}

// fill the actual value of last occurrence of a character
for (int i = 0; i < m; i++)
{
//badCharactorShifts[(int)pattern[i]] = i;
badCharactorShifts[(int)pattern[i]] = m - 1 - i;
}

return badCharactorShifts;
}

static int[] PreprocessToBuildGoodSuffixHeuristic(char[] pattern)
{
int m = pattern.Length;
int[] goodSuffixShifts = new int[m];
int[] suffixLengthArray = GetSuffixLengthArray(pattern);

for (int i = 0; i < m; ++i)
{
goodSuffixShifts[i] = m;
}

int j = 0;
for (int i = m - 1; i >= -1; --i)
{
if (i == -1 || suffixLengthArray[i] == i + 1)
{
for (; j < m - 1 - i; ++j)
{
if (goodSuffixShifts[j] == m)
{
goodSuffixShifts[j] = m - 1 - i;
}
}
}
}

for (int i = 0; i < m - 1; ++i)
{
goodSuffixShifts[m - 1 - suffixLengthArray[i]] = m - 1 - i;
}

return goodSuffixShifts;
}

static int[] GetSuffixLengthArray(char[] pattern)
{
int m = pattern.Length;
int[] suffixLengthArray = new int[m];

int f = 0, g = 0, i = 0;

suffixLengthArray[m - 1] = m;

g = m - 1;
for (i = m - 2; i >= 0; --i)
{
if (i > g && suffixLengthArray[i + m - 1 - f] < i - g)
{
suffixLengthArray[i] = suffixLengthArray[i + m - 1 - f];
}
else
{
if (i < g)
{
g = i;
}
f = i;

// find different preceded character suffix
while (g >= 0 && pattern[g] == pattern[g + m - 1 - f])
{
--g;
}
suffixLengthArray[i] = f - g;
}
}

return suffixLengthArray;
}

public static bool TryMatch(char[] text, char[] pattern, out int firstShift)
{
firstShift = -1;
int n = text.Length;
int m = pattern.Length;
int s = 0; // s is shift of the pattern with respect to text
int j = 0;

// fill the bad character and good suffix array by preprocessing
int[] badCharShifts = PreprocessToBuildBadCharactorHeuristic(pattern);
int[] goodSuffixShifts = PreprocessToBuildGoodSuffixHeuristic(pattern);

while (s <= (n - m))
{
// starts matching from the last character of the pattern
j = m - 1;

// keep reducing index j of pattern while characters of
// pattern and text are matching at this shift s
while (j >= 0 && pattern[j] == text[s + j])
{
j--;
}

// if the pattern is present at current shift, then index j
// will become -1 after the above loop
if (j < 0)
{
firstShift = s;
return true;
}
else
{
// shift the pattern so that the bad character in text
// aligns with the last occurrence of it in pattern. the
// max function is used to make sure that we get a positive
// shift. We may get a negative shift if the last occurrence
// of bad character in pattern is on the right side of the
// current character.
//s += Max(1, j - badCharShifts[(int)text[s + j]]);
// now, compare bad char shift and good suffix shift to find best
s += Max(goodSuffixShifts[j], badCharShifts[(int)text[s + j]] - (m - 1) + j);
}
}

return false;
}

public static int[] MatchAll(char[] text, char[] pattern)
{
int n = text.Length;
int m = pattern.Length;
int s = 0; // s is shift of the pattern with respect to text
int j = 0;
int[] shiftIndexes = new int[n - m + 1];
int c = 0;

// fill the bad character and good suffix array by preprocessing
int[] badCharShifts = PreprocessToBuildBadCharactorHeuristic(pattern);
int[] goodSuffixShifts = PreprocessToBuildGoodSuffixHeuristic(pattern);

while (s <= (n - m))
{
// starts matching from the last character of the pattern
j = m - 1;

// keep reducing index j of pattern while characters of
// pattern and text are matching at this shift s
while (j >= 0 && pattern[j] == text[s + j])
{
j--;
}

// if the pattern is present at current shift, then index j
// will become -1 after the above loop
if (j < 0)
{
shiftIndexes[c] = s;
c++;

// shift the pattern so that the next character in text
// aligns with the last occurrence of it in pattern.
// the condition s+m < n is necessary for the case when
// pattern occurs at the end of text
//s += (s + m < n) ? m - badCharShifts[(int)text[s + m]] : 1;
s += goodSuffixShifts[0];
}
else
{
// shift the pattern so that the bad character in text
// aligns with the last occurrence of it in pattern. the
// max function is used to make sure that we get a positive
// shift. We may get a negative shift if the last occurrence
// of bad character in pattern is on the right side of the
// current character.
//s += Max(1, j - badCharShifts[(int)text[s + j]]);
// now, compare bad char shift and good suffix shift to find best
s += Max(goodSuffixShifts[j], badCharShifts[(int)text[s + j]] - (m - 1) + j);
}
}

int[] shifts = new int[c];
for (int y = 0; y < c; y++)
{
shifts[y] = shiftIndexes[y];
}

return shifts;
}
}
}

多阶hash表

原文:https://blog.csdn.net/wm_1991/article/details/52218718

多阶hash表实际上是一个锯齿数组,看起来是这个样子的:
■■■■■■■■■■■■■■■
■■■■■■■■■■■■■
■■■■■■■■■■
■■■■■■
■■■

每一行是一阶,上面的元素个数多,下面的元素个数依次减少。
每一行的元素个数都是素数的。

数组的每个节点用于存储数据的内容,其中,节点的前四个字节用于存储int类型的key或者是hash_code

创建多阶HASH的时候,用户通过参数来指定有多少阶,每一阶最多多少个元素。
那么,下面的每一阶究竟应该选择多少个元素呢?从代码注释上看来,是采用了素数集中原理的算法来查找的。
例如,假设每阶最多1000个元素,一共10阶,则算法选择十个比1000小的最大素数,从大到小排列,以此作为各阶的元素个数。通过素数集中的算法得到的10个素数分别是:997 991 983 977 971 967 953 947 941 937。
可见,虽然是锯齿数组,各层之间的差别并不是很多。

查找过程:

  1. 先将key在第一阶内取模,看是否是这个元素,如果这个位置为空,直接返回不存在;如果是这个KEY,则返回这个位置。
  2. 如果这个位置有元素,但是又不是这个key,则说明hash冲突,再到第二阶去找。
  3. 循环往复。

好处:

  1. hash冲突的处理非常简单;
  2. 有多个桶,使得空间利用率很高,你并不需要一个很大的桶来减少冲突。
  3. 可以考虑动态增长空间,不断加入新的一阶,且对原来的数据没影响。

使用共享内存的多级哈希表的一种实现
在一个服务程序运行的时候,它往往要把数据写入共享内存以便在进城需要重新启动的时候可以直接从共享内存中读取数据,另一方面,在服务进程因某种原因挂掉的时候,共享内存中的数据仍然存在,这样就可以减少带来的损失。关于共享内存的内容请google之,在这里,实现了一种在共享内存中存取数据的hash表,它采用了多级存储求模取余的方法,具体内容请看以下代码:
http://lmlf001.blog.sohu.com/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237

//hash_shm.h
#ifndef _STORMLI_HASH_SHM_H_
#define _STORMLI_HASH_SHM_H_

#include<iostream>
#include<cstdlib>
#include<cmath>
#include<sys/shm.h>
using namespace std;

template<typename valueType,unsigned long maxLine,int lines>
class hash_shm
{
public:
int find(unsigned long _key);
//if _key in the table,return 0,and set lastFound the position,otherwise return -1
int remove(unsigned long _key);
//if _key not in the table,return-1,else remove the node,set the node key 0 and return 0

//insert node into the table,if the _key exists,return 1,if insert success,return 0;and if fail return -1
int insert(unsigned long _key,const valueType &_value);
void clear();
//remove all the data

public:
//some statistic function
double getFullRate()const;
//the rate of the space used

public:
//constructor,with the share memory start position and the space size,if the space is not enough,the program will exit
hash_shm(void *startShm,unsigned long shmSize=sizeof(hash_node)*maxLine*lines);

//constructor,with the share memory key,it will get share memory,if fail,exit
hash_shm(key_t shm_key);
~hash_shm(){}
//destroy the class
private:
void *mem;
//the start position of the share memory
// the mem+memSize space used to storage the runtime data:currentSize
unsigned long memSize;
//the size of the share memory
unsigned long modTable[lines];
//modtable,the largest primes
unsigned long maxSize;
//the size of the table
unsigned long *currentSize;
//current size of the table ,the pointer of the shm mem+memSize
void *lastFound;
//write by the find function,record the last find place

struct hash_node{ //the node of the hash table
unsigned long key; //when key==0,the node is empty
valueType value; //name-value pair
};
private:
bool getShm(key_t shm_key);
//get share memory,used by the constructor
void getMode();
//get the largest primes blow maxLine,use by the constructor
void *getPos(unsigned int _row,unsigned long _col);
//get the positon with the (row,col)
};

template<typename vT,unsigned long maxLine,int lines>
hash_shm<vT,maxLine,lines>::hash_shm(void *startShm,unsigned long shmSize)
{
if(startShm!=NULL){
cerr<<"Argument error\n Please check the shm address\n";
exit(-1);
}
getMode();
maxSize=0;
int i;
for(i=0;i<lines;i++) //count the maxSize
maxSize+=modTable[i];
if(shmSize<sizeof(hash_node)*(maxSize+1)){
//check the share memory size
cerr<<"Not enough share memory space\n";
exit(-1);
}
memSize=shmSize;
if(*(currentSize=(unsigned long *)((long)mem+memSize))<0)
*currentSize=0;;
}

template<typename vT,unsigned long maxLine,int lines>
hash_shm<vT,maxLine,lines>::hash_shm(key_t shm_key)
{ //constructor with get share memory
getMode();
maxSize=0;
for(int i=0;i<lines;i++)
maxSize+=modTable[i];
memSize=sizeof(hash_node)*maxSize;
if(!getShm(shm_key)){
exit(-1);
}
// memset(mem,0,memSize);
if(*(currentSize=(unsigned long *)((long)mem+memSize))<0)
*currentSize=0;
}


template<typename vT,unsigned long maxLine,int lines>
int hash_shm<vT,maxLine,lines>::find(unsigned long _key)
{
unsigned long hash;
hash_node *pH=NULL;
for(int i=0;i<lines;i++)
{
hash=(_key+maxLine)%modTable[i];
//calculate the col position
pH=(hash_node *)getPos(i,hash);
// if(pH==NULL)return -2; //almost not need
if(pH->key==_key){
lastFound=pH;
return 0;
}
}
return -1;
}

template<typename vT,unsigned long maxLine,int lines>
int hash_shm<vT,maxLine,lines>::remove(unsigned long _key)
{
if(find(_key)==-1)return -1; //not found
hash_node *pH=(hash_node *)lastFound;
pH->key=0; //only set the key 0
(*currentSize)--;
return 0;
}

template<typename vT,unsigned long maxLine,int lines>
int hash_shm<vT,maxLine,lines>::insert(unsigned long _key,const vT &_value)
{
if(find(_key)==0)return 1; //if the key exists
unsigned long hash;
hash_node *pH=NULL;
for(int i=0;i<lines;i++){
hash=(_key+maxLine)%modTable[i];
pH=(hash_node *)getPos(i,hash);
if(pH->key==0){ //find the insert position,insert the value
pH->key=_key;
pH->value=_value;
(*currentSize)++;
return 0;
}
}
return -1; //all the appropriate position filled
}


template<typename vT,unsigned long maxLine,int lines>
void hash_shm<vT,maxLine,lines>::clear()
{
memset(mem,0,memSize);
*currentSize=0;
}


template<typename vT,unsigned long maxLine,int lines>
bool hash_shm<vT,maxLine,lines>::getShm(key_t shm_key)
{
int shm_id=shmget(shm_key,memSize,0666);
if(shm_id==-1) //check if the shm exists
{
shm_id=shmget(shm_key,memSize,0666|IPC_CREAT);//create the shm
if(shm_id==-1){
cerr<<"Share memory get failed\n";
return false;
}
}
mem=shmat(shm_id,NULL,0); //mount the shm
if(int(mem)==-1){
cerr<<"shmat system call failed\n";
return false;
}
return true;
}

template<typename vT,unsigned long maxLine,int lines>
void hash_shm<vT,maxLine,lines>::getMode()
{ //采用 6n+1 6n-1 素数集中原理
if(maxLine<5){exit(-1);}

unsigned long t,m,n,p;
int i,j,a,b,k;
int z=0;

for(t=maxLine/6;t>=0,z<lines;t--)
{
i=1;j=1; k=t%10;
m=6*t; /**i,j的值 是是否进行验证的标志也是对应的6t-1和6t+1的素性标志**/
if(((k-4)==0)||((k-9)==0)||((m+1)%3==0))j=0;/*此处是简单验证6*t-1,6*t+1 是不是素数,借以提高素数纯度**/
if(((k-6)==0)||((m-1)%3==0))i=0; /***先通过初步判断去除末尾是5,及被3整除的数***/
for(p=1;p*6<=sqrt(m+1)+2;p++ )
{
n=p*6; /**将6*p-1和6*p+1看作伪素数来试除*****/
k=p%10;
a=1;b=1; /**同样此处a,b的值也是用来判断除数是否为素数提高除数的素数纯度**/
if(((k-4)==0)||((k-9)==0))a=0;
if(((k-6)==0))b=0;
if(i){ /*如果i非零就对m-1即所谓6*t-1进行验证,当然还要看除数n+1,n-1,素性纯度*/
if(a){if((m-1)%(n+1)==0)i=0;} /***一旦被整除就说明不是素数故素性为零即将i 赋值为零***/
if(b){if((m-1)%(n-1)==0)i=0;}
}
if(j){ /**如果j非零就对m+1即所谓6*t+1进行验证,当然还要看除数n+1,n-1,素性纯度*/
if(a){if((m+1)%(n+1)==0)j=0;} /***一旦被整除就说明不是素数故素性为零即将j 赋值为零***/
if(b){if((m+1)%(n-1)==0)j=0;}
}
if((i+j)==0)break; /**如果已经知道6*t-1,6*t+1都不是素数了那就结束试除循环***/
}
if(j){modTable[z++]=m+1;if(z>= lines)return;}
if(i){modTable[z++]=m-1;if(z>= lines)return;}
}
}

template<typename vT,unsigned long maxLine,int lines>
void *hash_shm<vT,maxLine,lines>::getPos(unsigned int _row,unsigned long _col)
{
unsigned long pos=0UL;
for(int i=0;i<_row;i++) //calculate the positon from the start
pos+=modTable[i];
pos+=_col;
if(pos>=maxSize)return NULL;
return (void *)((long)mem+pos*sizeof(hash_node));
}

template<typename vT,unsigned long maxLine,int lines>
double hash_shm<vT,maxLine,lines>::getFullRate()const
{
return double(*currentSize)/maxSize;
}

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

//test.cpp

#include"hash_shm.h"
#include<cstdlib>
using namespace std;
int main()
{
hash_shm<int,1000,100> ht(key_t(999));
double rate=0.0;
// ht.clear();
for(int i=0;i<100;i++){
srand(time(NULL)+i);
while(true){
if(ht.insert(rand(),0)==-1)break;
}
cout<<ht.getFullRate()<<endl;
rate+=ht.getFullRate();
ht.clear();
}
cout<<"\n\n\n";
cout<<rate/100<<endl;
}

这段代码作测试的时候发现了一些问题,用gprof查看函数时间的时候发现,getPos函数占用了大部分的执行时间,始主要的性能瓶颈,后来又新设立了一个数组,用来记录每行开始时的位置,性能提高了很多,改动部分的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
template<typename valueType,unsigned long maxLine,int lines>
class hash_shm
{
private:
void *mem; //the start position of the share memory // the mem+memSize space used to storage the runtime data:currentSize
unsigned long memSize; //the size of the share memory
unsigned long modTable[lines]; //modtable,the largest primes
unsigned long modTotal[lines]; //modTotal[i] is the summary of the modTable when x<=i
//used by getPos to improve the performance
...
};

template<typename vT,unsigned long maxLine,int lines>
hash_shm<vT,maxLine,lines>::hash_shm(void *startShm,unsigned long shmSize)
{
...

int i;
for(i=0;i<lines;i++){ //count the maxSize
maxSize+=modTable[i];
if(i!=0)modTotal[i]=modTotal[i-1]+modTable[i-1];
else modTotal[i]=0; //caculate the modTotal
}
...
}

template<typename vT,unsigned long maxLine,int lines>
hash_shm<vT,maxLine,lines>::hash_shm(key_t shm_key)
{ //constructor with get share memory
getMode();
maxSize=0;
for(int i=0;i<lines;i++){
maxSize+=modTable[i];
if(i!=0)modTotal[i]=modTotal[i-1]+modTable[i-1];
else modTotal[i]=0;
}
...
}

1
2
3
4
5
6
7
8
9
10
template<typename vT,unsigned long maxLine,int lines>
void *hash_shm<vT,maxLine,lines>::getPos(unsigned int _row,unsigned long _col)
{
unsigned long pos=_col+modTotal[_row];
//for(int i=0;i<_row;i++) //calculate the positon from the start
// pos+=modTable[i];
if(pos<maxSize)
return (void *)((long)mem+pos*sizeof(hash_node));
return NULL;
}

新增了一个用于遍历的函数foreach

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename vT,unsigned long maxLine,int lines>
void hash_shm<vT,maxLine,lines>::foreach(void (*fn)(unsigned long _key,vT &_value))
{
typedef unsigned long u_long;
u_long beg=(u_long)mem;
u_long end=(u_long)mem+sizeof(hash_node)*(modTable[lines-1]+modTotal[lines-1]);
hash_node *p=NULL;
for(u_long pos=beg;pos<end;pos+=sizeof(hash_node))
{
p=(hash_node *)pos;
if(p->key!=0)fn(p->key,p->value);
}
}

为了利于使用新增一个用于查找的函数find,该函数同find(_key)类似,如果找到_key节点,把它赋给_value以返回
1
int find(unsigned long _key,vT &_value);

Hash碰撞冲突

我们知道,对象Hash的前提是实现equals()和hashCode()两个方法,那么HashCode()的作用就是保证对象返回唯一hash值,但当两个对象计算值一样时,这就发生了碰撞冲突。如下将介绍如何处理冲突,当然其前提是一致性hash。

  1. 开放地址法
    开放地执法有一个公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)
    其中,m为哈希表的表长。di 是产生冲突的时候的增量序列。如果di值可能为1,2,3,…m-1,称线性探测再散列。
    如果di取1,则每次冲突之后,向后移动1个位置.如果di取值可能为1,-1,2,-2,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2),称二次探测再散列。
    如果di取值可能为伪随机数列。称伪随机探测再散列。

  2. 再哈希法
    当发生冲突时,使用第二个、第三个、哈希函数计算地址,直到无冲突时。缺点:计算时间增加。
    比如上面第一次按照姓首字母进行哈希,如果产生冲突可以按照姓字母首字母第二位进行哈希,再冲突,第三位,直到不冲突为止

  3. 链地址法(拉链法)
    将所有关键字为同义词的记录存储在同一线性链表中。如下:

因此这种方法,可以近似的认为是筒子里面套筒子

  1. 建立一个公共溢出区
    假设哈希函数的值域为[0,m-1],则设向量HashTable[0..m-1]为基本表,另外设立存储空间向量OverTable[0..v]用以存储发生冲突的记录。

拉链法的优缺点:
优点:

  • 拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
  • 由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
  • 开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
  • 在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结 点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在 用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。

缺点:

  • 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。

经典字符串hash函数介绍及性能比较

原文:https://blog.csdn.net/djinglan/article/details/8812934

今天根据自己的理解重新整理了一下几个字符串hash函数,使用了模板,使其支持宽字符串,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/// @brief BKDR Hash Function  
/// @detail 本 算法由于在Brian Kernighan与Dennis Ritchie的《The C Programming Language》一书被展示而得 名,是一种简单快捷的hash算法,也是Java目前采用的字符串的Hash算法(累乘因子为31)。
template<class T>
size_t BKDRHash(const T *str)
{
register size_t hash = 0;
while (size_t ch = (size_t)*str++)
{
hash = hash * 131 + ch; // 也可以乘以31、131、1313、13131、131313..
// 有人说将乘法分解为位运算及加减法可以提高效率,如将上式表达为:hash = hash << 7 + hash << 1 + hash + ch;
// 但其实在Intel平台上,CPU内部对二者的处理效率都是差不多的,
// 我分别进行了100亿次的上述两种运算,发现二者时间差距基本为0(如果是Debug版,分解成位运算后的耗时还要高1/3);
// 在ARM这类RISC系统上没有测试过,由于ARM内部使用Booth's Algorithm来模拟32位整数乘法运算,它的效率与乘数有关:
// 当乘数8-31位都为1或0时,需要1个时钟周期
// 当乘数16-31位都为1或0时,需要2个时钟周期
// 当乘数24-31位都为1或0时,需要3个时钟周期
// 否则,需要4个时钟周期
// 因此,虽然我没有实际测试,但是我依然认为二者效率上差别不大
}
return hash;
}
/// @brief SDBM Hash Function
/// @detail 本算法是由于在开源项目SDBM(一种简单的数据库引擎)中被应用而得名,它与BKDRHash思想一致,只是种子不同而已。
template<class T>
size_t SDBMHash(const T *str)
{
register size_t hash = 0;
while (size_t ch = (size_t)*str++)
{
hash = 65599 * hash + ch;
//hash = (size_t)ch + (hash << 6) + (hash << 16) - hash;
}
return hash;
}
/// @brief RS Hash Function
/// @detail 因Robert Sedgwicks在其《Algorithms in C》一书中展示而得名。
template<class T>
size_t RSHash(const T *str)
{
register size_t hash = 0;
size_t magic = 63689;
while (size_t ch = (size_t)*str++)
{
hash = hash * magic + ch;
magic *= 378551;
}
return hash;
}
/// @brief AP Hash Function
/// @detail 由Arash Partow发明的一种hash算法。
template<class T>
size_t APHash(const T *str)
{
register size_t hash = 0;
size_t ch;
for (long i = 0; ch = (size_t)*str++; i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
}
}
return hash;
}
/// @brief JS Hash Function
/// 由Justin Sobel发明的一种hash算法。
template<class T>
size_t JSHash(const T *str)
{
if(!*str) // 这是由本人添加,以保证空字符串返回哈希值0
return 0;
register size_t hash = 1315423911;
while (size_t ch = (size_t)*str++)
{
hash ^= ((hash << 5) + ch + (hash >> 2));
}
return hash;
}
/// @brief DEK Function
/// @detail 本算法是由于Donald E. Knuth在《Art Of Computer Programming Volume 3》中展示而得名。
template<class T>
size_t DEKHash(const T* str)
{
if(!*str) // 这是由本人添加,以保证空字符串返回哈希值0
return 0;
register size_t hash = 1315423911;
while (size_t ch = (size_t)*str++)
{
hash = ((hash << 5) ^ (hash >> 27)) ^ ch;
}
return hash;
}
/// @brief FNV Hash Function
/// @detail Unix system系统中使用的一种著名hash算法,后来微软也在其hash_map中实现。
template<class T>
size_t FNVHash(const T* str)
{
if(!*str) // 这是由本人添加,以保证空字符串返回哈希值0
return 0;
register size_t hash = 2166136261;
while (size_t ch = (size_t)*str++)
{
hash *= 16777619;
hash ^= ch;
}
return hash;
}
/// @brief DJB Hash Function
/// @detail 由Daniel J. Bernstein教授发明的一种hash算法。
template<class T>
size_t DJBHash(const T *str)
{
if(!*str) // 这是由本人添加,以保证空字符串返回哈希值0
return 0;
register size_t hash = 5381;
while (size_t ch = (size_t)*str++)
{
hash += (hash << 5) + ch;
}
return hash;
}
/// @brief DJB Hash Function 2
/// @detail 由Daniel J. Bernstein 发明的另一种hash算法。
template<class T>
size_t DJB2Hash(const T *str)
{
if(!*str) // 这是由本人添加,以保证空字符串返回哈希值0
return 0;
register size_t hash = 5381;
while (size_t ch = (size_t)*str++)
{
hash = hash * 33 ^ ch;
}
return hash;
}
/// @brief PJW Hash Function
/// @detail 本算法是基于AT&T贝尔实验室的Peter J. Weinberger的论文而发明的一种hash算法。
template<class T>
size_t PJWHash(const T *str)
{
static const size_t TotalBits = sizeof(size_t) * 8;
static const size_t ThreeQuarters = (TotalBits * 3) / 4;
static const size_t OneEighth = TotalBits / 8;
static const size_t HighBits = ((size_t)-1) << (TotalBits - OneEighth);

register size_t hash = 0;
size_t magic = 0;
while (size_t ch = (size_t)*str++)
{
hash = (hash << OneEighth) + ch;
if ((magic = hash & HighBits) != 0)
{
hash = ((hash ^ (magic >> ThreeQuarters)) & (~HighBits));
}
}
return hash;
}
/// @brief ELF Hash Function
/// @detail 由于在Unix的Extended Library Function被附带而得名的一种hash算法,它其实就是PJW Hash的变形。
template<class T>
size_t ELFHash(const T *str)
{
static const size_t TotalBits = sizeof(size_t) * 8;
static const size_t ThreeQuarters = (TotalBits * 3) / 4;
static const size_t OneEighth = TotalBits / 8;
static const size_t HighBits = ((size_t)-1) << (TotalBits - OneEighth);
register size_t hash = 0;
size_t magic = 0;
while (size_t ch = (size_t)*str++)
{
hash = (hash << OneEighth) + ch;
if ((magic = hash & HighBits) != 0)
{
hash ^= (magic >> ThreeQuarters);
hash &= ~magic;
}
}
return hash;
}

我对这些hash的散列质量及效率作了一个简单测试,测试结果如下:

测试1:对100000个由大小写字母与数字随机的ANSI字符串(无重复,每个字符串最大长度不超过64字符)进行散列:

测试2:对100000个由任意UNICODE组成随机字符串(无重复,每个字符串最大长度不超过64字符)进行散列:

测试3:对1000000个随机ANSI字符串(无重复,每个字符串最大长度不超过64字符)进行散列:

结论:也许是我的样本存在一些特殊性,在对ASCII码字符串进行散列时,PJW与ELF Hash(它们其实是同一种算法)无论是质量还是效率,都相当糟糕;例如:”b5”与“aE”,这两个字符串按照PJW散列出来的hash值就是一样的。 另外,其它几种依靠异或来散列的哈希函数,如:JS/DEK/DJB Hash,在对字母与数字组成的字符串的散列效果也不怎么好。相对而言,还是BKDR与SDBM这类简单的Hash效率与效果更好。

常用的字符串Hash函数还有ELFHash,APHash等等,都是十分简单有效的方法。这些函数使用位运算使得每一个字符都对最后的函数值产生 影响。另外还有以MD5和SHA1为代表的杂凑函数,这些函数几乎不可能找到碰撞。

常用字符串哈希函数有 BKDRHash,APHash,DJBHash,JSHash,RSHash,SDBMHash,PJWHash,ELFHash等等。对于以上几种哈 希函数,我对其进行了一个小小的评测。

其中数据1为100000个字母和数字组成的随机串哈希冲突个数。数据2为100000个有意义的英文句子哈希冲突个数。数据3为数据1的哈希值与 1000003(大素数)求模后存储到线性表中冲突的个数。数据4为数据1的哈希值与10000019(更大素数)求模后存储到线性表中冲突的个数。

经过比较,得出以上平均得分。平均数为平方平均数。可以发现,BKDRHash无论是在实际效果还是编码实现中,效果都是最突出的。APHash也 是较为优秀的算法。DJBHash,JSHash,RSHash与SDBMHash各有千秋。PJWHash与ELFHash效果最差,但得分相似,其算 法本质是相似的。

Merkle Tree

概念

Merkle Tree,通常也被称作Hash Tree,顾名思义,就是存储hash值的一棵树。Merkle树的叶子是数据块(例如,文件或者文件的集合)的hash值。非叶节点是其对应子节点串联字符串的hash。[1]

Hash

Hash是一个把任意长度的数据映射成固定长度数据的函数[2]。例如,对于数据完整性校验,最简单的方法是对整个数据做Hash运算得到固定长度的Hash值,然后把得到的Hash值公布在网上,这样用户下载到数据之后,对数据再次进行Hash运算,比较运算结果和网上公布的Hash值进行比较,如果两个Hash值相等,说明下载的数据没有损坏。可以这样做是因为输入数据的稍微改变就会引起Hash运算结果的面目全非,而且根据Hash值反推原始输入数据的特征是困难的。

如果从一个稳定的服务器进行下载,采用单一Hash是可取的。但如果数据源不稳定,一旦数据损坏,就需要重新下载,这种下载的效率是很低的。

Hash List

在点对点网络中作数据传输的时候,会同时从多个机器上下载数据,而且很多机器可以认为是不稳定或者不可信的。为了校验数据的完整性,更好的办法是把大的文件分割成小的数据块(例如,把分割成2K为单位的数据块)。这样的好处是,如果小块数据在传输过程中损坏了,那么只要重新下载这一快数据就行了,不用重新下载整个文件。

怎么确定小的数据块没有损坏哪?只需要为每个数据块做Hash。BT下载的时候,在下载到真正数据之前,我们会先下载一个Hash列表。那么问题又来了,怎么确定这个Hash列表本事是正确的哪?答案是把每个小块数据的Hash值拼到一起,然后对这个长字符串在作一次Hash运算,这样就得到Hash列表的根Hash(Top Hash or Root Hash)。下载数据的时候,首先从可信的数据源得到正确的根Hash,就可以用它来校验Hash列表了,然后通过校验后的Hash列表校验数据块。

Merkle Tree

Merkle Tree可以看做Hash List的泛化(Hash List可以看作一种特殊的Merkle Tree,即树高为2的多叉Merkle Tree)。

在最底层,和哈希列表一样,我们把数据分成小的数据块,有相应地哈希和它对应。但是往上走,并不是直接去运算根哈希,而是把相邻的两个哈希合并成一个字符串,然后运算这个字符串的哈希,这样每两个哈希就结婚生子,得到了一个”子哈希“。如果最底层的哈希总数是单数,那到最后必然出现一个单身哈希,这种情况就直接对它进行哈希运算,所以也能得到它的子哈希。于是往上推,依然是一样的方式,可以得到数目更少的新一级哈希,最终必然形成一棵倒挂的树,到了树根的这个位置,这一代就剩下一个根哈希了,我们把它叫做 Merkle Root[3]。

在p2p网络下载网络之前,先从可信的源获得文件的Merkle Tree树根。一旦获得了树根,就可以从其他从不可信的源获取Merkle tree。通过可信的树根来检查接受到的Merkle Tree。如果Merkle Tree是损坏的或者虚假的,就从其他源获得另一个Merkle Tree,直到获得一个与可信树根匹配的Merkle Tree。

Merkle Tree和Hash List的主要区别是,可以直接下载并立即验证Merkle Tree的一个分支。因为可以将文件切分成小的数据块,这样如果有一块数据损坏,仅仅重新下载这个数据块就行了。如果文件非常大,那么Merkle tree和Hash list都很到,但是Merkle tree可以一次下载一个分支,然后立即验证这个分支,如果分支验证通过,就可以下载数据了。而Hash list只有下载整个hash list才能验证。

Merkle Tree的特点

MT是一种树,大多数是二叉树,也可以多叉树,无论是几叉树,它都具有树结构的所有特点;
Merkle Tree的叶子节点的value是数据集合的单元数据或者单元数据HASH。
非叶子节点的value是根据它下面所有的叶子节点值,然后按照Hash算法计算而得出的。[4][5]
  
通常,加密的hash方法像SHA-2和MD5用来做hash。但如果仅仅防止数据不是蓄意的损坏或篡改,可以改用一些安全性低但效率高的校验和算法,如CRC。

Second Preimage Attack: Merkle tree的树根并不表示树的深度,这可能会导致second-preimage attack,即攻击者创建一个具有相同Merkle树根的虚假文档。一个简单的解决方法在Certificate Transparency中定义:当计算叶节点的hash时,在hash数据前加0x00。当计算内部节点是,在前面加0x01。另外一些实现限制hash tree的根,通过在hash值前面加深度前缀。因此,前缀每一步会减少,只有当到达叶子时前缀依然为正,提取的hash链才被定义为有效。

Merkle Tree的操作

创建Merckle Tree

加入最底层有9个数据块。

step1:(红色线)对数据块做hash运算,Node0i = hash(Data0i), i=1,2,…,9

step2: (橙色线)相邻两个hash块串联,然后做hash运算,Node1((i+1)/2) = hash(Node0i+Node0(i+1)), i=1,3,5,7;对于i=9, Node1((i+1)/2) = hash(Node0i)

step3: (黄色线)重复step2

step4:(绿色线)重复step2

step5:(蓝色线)重复step2,生成Merkle Tree Root

易得,创建Merkle Tree是O(n)复杂度(这里指O(n)次hash运算),n是数据块的大小。得到Merkle Tree的树高是log(n)+1。

检索数据块

为了更好理解,我们假设有A和B两台机器,A需要与B相同目录下有8个文件,文件分别是f1 f2 f3 ….f8。这个时候我们就可以通过Merkle Tree来进行快速比较。假设我们在文件创建的时候每个机器都构建了一个Merkle Tree。具体如下图:

从上图可得知,叶子节点node7的value = hash(f1),是f1文件的HASH;而其父亲节点node3的value = hash(v7, v8),也就是其子节点node7 node8的值得HASH。就是这样表示一个层级运算关系。root节点的value其实是所有叶子节点的value的唯一特征。

假如A上的文件5与B上的不一样。我们怎么通过两个机器的merkle treee信息找到不相同的文件? 这个比较检索过程如下:

Step1. 首先比较v0是否相同,如果不同,检索其孩子node1和node2.

Step2. v1 相同,v2不同。检索node2的孩子node5 node6;

Step3. v5不同,v6相同,检索比较node5的孩子node 11 和node 12

Step4. v11不同,v12相同。node 11为叶子节点,获取其目录信息。

Step5. 检索比较完毕。

以上过程的理论复杂度是Log(N)。过程描述图如下:

从上图可以得知真个过程可以很快的找到对应的不相同的文件。

更新,插入和删除

虽然网上有很多关于Merkle Tree的资料,但大部分没有涉及Merkle Tree的更新、插入和删除操作,讨论Merkle Tree的检索和遍历的比较多。我也是非常困惑,一种树结构的操作肯定不仅包括查找,也包括更新、插入和删除的啊。后来查到stackexchange上的一个问题,才稍微有点明白,原文见[6]。

对于Merkle Tree数据块的更新操作其实是很简单的,更新完数据块,然后接着更新其到树根路径上的Hash值就可以了,这样不会改变Merkle Tree的结构。但是,插入和删除操作肯定会改变Merkle Tree的结构,如下图,一种插入操作是这样的:

插入数据块0后(考虑数据块的位置),Merkle Tree的结构是这样的:

而[6]中的同学在考虑一种插入的算法,满足下面条件:

re-hashing操作的次数控制在log(n)以内
数据块的校验在log(n)+1以内
除非原始树的n是偶数,插入数据后的树没有孤儿,并且如果有孤儿,那么孤儿是最后一个数据块
数据块的顺序保持一致
插入后的Merkle Tree保持平衡
然后上面的插入结果就会变成这样:

根据[6]中回答者所说,Merkle Tree的插入和删除操作其实是一个工程上的问题,不同问题会有不同的插入方法。如果要确保树是平衡的或者是树高是log(n)的,可以用任何的标准的平衡二叉树的模式,如AVL树,红黑树,伸展树,2-3树等。这些平衡二叉树的更新模式可以在O(lgn)时间内完成插入操作,并且能保证树高是O(lgn)的。那么很容易可以看出更新所有的Merkle Hash可以在O((lgn)2)时间内完成(对于每个节点如要更新从它到树根O(lgn)个节点,而为了满足树高的要求需要更新O(lgn)个节点)。如果仔细分析的话,更新所有的hash实际上可以在O(lgn)时间内完成,因为要改变的所有节点都是相关联的,即他们要不是都在从某个叶节点到树根的一条路径上,或者这种情况相近。

[6]的回答者说实际上Merkle Tree的结构(是否平衡,树高限制多少)在大多数应用中并不重要,而且保持数据块的顺序也在大多数应用中也不需要。因此,可以根据具体应用的情况,设计自己的插入和删除操作。一个通用的Merkle Tree插入删除操作是没有意义的。

Merkle Tree的应用

数字签名

最初Merkle Tree目的是高效的处理Lamport one-time signatures。 每一个Lamport key只能被用来签名一个消息,但是与Merkle tree结合可以来签名多条Merkle。这种方法成为了一种高效的数字签名框架,即Merkle Signature Scheme。

P2P网络

在P2P网络中,Merkle Tree用来确保从其他节点接受的数据块没有损坏且没有被替换,甚至检查其他节点不会欺骗或者发布虚假的块。大家所熟悉的BT下载就是采用了P2P技术来让客户端之间进行数据传输,一来可以加快数据下载速度,二来减轻下载服务器的负担。BT即BitTorrent,是一种中心索引式的P2P文件分分析通信协议[7]。

要进下载必须从中心索引服务器获取一个扩展名为torrent的索引文件(即大家所说的种子),torrent文件包含了要共享文件的信息,包括文件名,大小,文件的Hash信息和一个指向Tracker的URL[8]。Torrent文件中的Hash信息是每一块要下载的文件内容的加密摘要,这些摘要也可运行在下载的时候进行验证。大的torrent文件是Web服务器的瓶颈,而且也不能直接被包含在RSS或gossiped around(用流言传播协议进行传播)。一个相关的问题是大数据块的使用,因为为了保持torrent文件的非常小,那么数据块Hash的数量也得很小,这就意味着每个数据块相对较大。大数据块影响节点之间进行交易的效率,因为只有当大数据块全部下载下来并校验通过后,才能与其他节点进行交易。

就解决上面两个问题是用一个简单的Merkle Tree代替Hash List。设计一个层数足够多的满二叉树,叶节点是数据块的Hash,不足的叶节点用0来代替。上层的节点是其对应孩子节点串联的hash。Hash算法和普通torrent一样采用SHA1。其数据传输过程和第一节中描述的类似。

Trusted Computing

可信计算是可信计算组为分布式计算环境中参与节点的计算平台提供端点可信性而提出的。可信计算技术在计算平台的硬件层引入可信平台模块(Trusted Platform,TPM),实际上为计算平台提供了基于硬件的可信根(Root of trust,RoT)。从可信根出发,使用信任链传递机制,可信计算技术可对本地平台的硬件及软件实施逐层的完整性度量,并将度量结果可靠地保存再TPM的平台配置寄存器(Platform configuration register,PCR)中,此后远程计算平台可通过远程验证机制(Remote Attestation)比对本地PCR中度量结果,从而验证本地计算平台的可信性。可信计算技术让分布式应用的参与节点摆脱了对中心服务器的依赖,而直接通过用户机器上的TPM芯片来建立信任,使得创建扩展性更好、可靠性更高、可用性更强的安全分布式应用成为可能[10]。可信计算技术的核心机制是远程验证(remote attestation),分布式应用的参与结点正是通过远程验证机制来建立互信,从而保障应用的安全。

文献[10]提出了一种基于Merkle Tree的远程验证机制,其核心是完整性度量值哈希树。

首先,RAMT 在内核中维护的不再是一张完整性度量值列表(ML),而是一棵完整性度量值哈希树(integrity measurement hash tree,简称IMHT).其中,IMHT的叶子结点存储的数据对象是待验证计算平台上被度量的各种程序的完整性哈希值,而其内部结点则依据Merkle 哈希树的构建规则由子结点的连接的哈希值动态生成。

其次,为了维护IMHT 叶子结点的完整性,RAMT 需要使用TPM 中的一段存储器来保存IMHT 可信根哈希的值。

再次,RAMT 的完整性验证过程基于认证路径(authentication path)实施.认证路径是指IMHT 上从待验证叶子结点到根哈希的路径。

IPFS

IPFS(InterPlanetary File System)是很多NB的互联网技术的综合体,如DHT( Distributed HashTable,分布式哈希表),Git版本控制系统,Bittorrent等。它创建了一个P2P的集群,这个集群允许IPFS对象的交换。全部的IPFS对象形成了一个被称作Merkle DAG的加密认证数据结构。

IPFS对象是一个含有两个域的数据结构:

  • Data – 非结构的二进制数据,大小小于256kB
  • Links – 一个Link数据结构的数组。IPFS对象通过他们链接到其他对象

Link数据结构包含三个域:

  • Name – Link的名字
  • Hash – Link链接到对象的Hash
  • Size – Link链接到对象的累积大小,包括它的Links

通过Name和Links,IPFS的集合组成了一个Merkle DAG(有向无环图)。

对于小文件(<256kB),是一个没有Links的IPFS对象。

对于大文件,被表示为一个文件块(<256kB)的集合。只有拥有最小的Data的对象来代表这个大文件。这个对象的Links的名字都为空字符串。

目录结构:目录是没有数据的IPFS对象,它的链接指向其包含的文件和目录。

IPFS可以表示Git使用的数据结构,Git commit object。Commit Object主要的特点是他有一个或多个名为’parent0’和‘parent1’等的链接(这些链接指向前一个版本),以及一个名为object的对象(在Git中成为tree)指向引用这个commit的文件系统结构。

BitCoin和Ethereum

Merkle Proof最早的应用是Bitcoin,它是由中本聪在2009年描述并创建的。Bitcoin的Blockchain利用Merkle proofs来存储每个区块的交易。

而这样做的好处,也就是中本聪描述到的“简化支付验证”(Simplified Payment Verification,SPV)的概念:一个“轻客户端”(light client)可以仅下载链的区块头即每个区块中的80byte的数据块,仅包含五个元素,而不是下载每一笔交易以及每一个区块:

  • 上一区块头的哈希值
  • 时间戳
  • 挖矿难度值
  • 工作量证明随机数(nonce)
  • 包含该区块交易的Merkle Tree的根哈希

如果客户端想要确认一个交易的状态,它只需简单的发起一个Merkle proof请求,这个请求显示出这个特定的交易在Merkle trees的一个之中,而且这个Merkle Tree的树根在主链的一个区块头中。

但是Bitcoin的轻客户端有它的局限。一个局限是,尽管它可以证明包含的交易,但是它不能进行涉及当前状态的证明(如数字资产的持有,名称注册,金融合约的状态等)。

Bitcoin如何查询你当前有多少币?一个比特币轻客户端,可以使用一种协议,它涉及查询多个节点,并相信其中至少会有一个节点会通知你,关于你的地址中任何特定的交易支出,而这可以让你实现更多的应用。但对于其他更为复杂的应用而言,这些远远是不够的。一笔交易影响的确切性质(precise nature),可以取决于此前的几笔交易,而这些交易本身则依赖于更为前面的交易,所以最终你可以验证整个链上的每一笔交易。为了解决这个问题,Ethereum的Merkle Tree的概念,会更进一步。

Ethereum的Merkle Proof

每个以太坊区块头不是包括一个Merkle树,而是为三种对象设计的三棵树:

交易Transaction
收据Receipts(本质上是显示每个交易影响的多块数据)
状态State

这使得一个非常先进的轻客户端协议成为了可能,它允许轻客户端轻松地进行并核实以下类型的查询答案:

这笔交易被包含在特定的区块中了么?
告诉我这个地址在过去30天中,发出X类型事件的所有实例(例如,一个众筹合约完成了它的目标)
目前我的账户余额是多少?
这个账户是否存在?
假如在这个合约中运行这笔交易,它的输出会是什么?

第一种是由交易树(transaction tree)来处理的;第三和第四种则是由状态树(state tree)负责处理,第二种则由收据树(receipt tree)处理。计算前四个查询任务是相当简单的。服务器简单地找到对象,获取Merkle分支,并通过分支来回复轻客户端。

第五种查询任务同样也是由状态树处理,但它的计算方式会比较复杂。这里,我们需要构建一个Merkle状态转变证明(Merkle state transition proof)。从本质上来讲,这样的证明也就是在说“如果你在根S的状态树上运行交易T,其结果状态树将是根为S’,log为L,输出为O” (“输出”作为存在于以太坊的一种概念,因为每一笔交易都是一个函数调用;它在理论上并不是必要的)。

为了推断这个证明,服务器在本地创建了一个假的区块,将状态设为 S,并在请求这笔交易时假装是一个轻客户端。也就是说,如果请求这笔交易的过程,需要客户端确定一个账户的余额,这个轻客户端(由服务器模拟的)会发出一个余额查询请求。如果需要轻客户端在特点某个合约的存储中查询特定的条目,这个轻客户端就会发出这样的请求。也就是说服务器(通过模拟一个轻客户端)正确回应所有自己的请求,但服务器也会跟踪它所有发回的数据。

然后,服务器从上述的这些请求中把数据合并并把数据以一个证明的方式发送给客户端。

然后,客户端会进行相同的步骤,但会将服务器提供的证明作为一个数据库来使用。如果客户端进行步骤的结果和服务器提供的是一样的话,客户端就接受这个证明。

MPT(Merkle Patricia Trees)

前面我们提到,最为简单的一种Merkle Tree大多数情况下都是一棵二叉树。然而,Ethereum所使用的Merkle Tree则更为复杂,我们称之为“梅克尔.帕特里夏树”(Merkle Patricia tree)。

对于验证属于list格式(本质上来讲,它就是一系列前后相连的数据块)的信息而言,二叉Merkle Tree是非常好的数据结构。对于交易树来说,它们也同样是不错的,因为一旦树已经建立,花多少时间来编辑这棵树并不重要,树一旦建立了,它就会永远存在并且不会改变。

但是,对于状态树,情况会更复杂些。以太坊中的状态树基本上包含了一个键值映射,其中的键是地址,而值包括账户的声明、余额、随机数nounce、代码以及每一个账户的存储(其中存储本身就是一颗树)。例如,摩登测试网络(the Morden testnet )的创始状态如下所示:

然而,不同于交易历史记录,状态树需要经常地进行更新:账户余额和账户的随机数nonce经常会更变,更重要的是,新的账户会频繁地插入,存储的键( key)也会经常被插入以及删除。我们需要这样的数据结构,它能在一次插入、更新、删除操作后快速计算到树根,而不需要重新计算整个树的Hash。这种数据结构同样得包括两个非常好的第二特征:

树的深度是有限制的,即使考虑攻击者会故意地制造一些交易,使得这颗树尽可能地深。不然,攻击者可以通过操纵树的深度,执行拒绝服务攻击(DOS attack),使得更新变得极其缓慢。
树的根只取决于数据,和其中的更新顺序无关。换个顺序进行更新,甚至重新从头计算树,并不会改变根。
  MPT是最接近同时满足上面的性质的的数据结构。MPT的工作原理的最简单的解释是,值通过键来存储,键被编码到搜索树必须要经过的路径中。每个节点有16个孩子,因此路径又16进制的编码决定:例如,键‘dog’的16进制编码是6 4 6 15 6 7,所以从root开始到第六个分支,然后到第四个,再到第六个,再到第十五个,这样依次进行到达树的叶子。

在实践中,当树稀少时也会有一些额外的优化,我们会使过程更为有效,但这是基本的原则。

Leetcode二分查找法小结

二分查找法作为一种常见的查找方法,将原本是线性时间提升到了对数时间范围,大大缩短了搜索时间,具有很大的应用场景,而在 LeetCode 中,要运用二分搜索法来解的题目也有很多,但是实际上二分查找法的查找目标有很多种,而且在细节写法也有一些变化。之前有网友留言希望博主能针对二分查找法的具体写法做个总结,博主由于之前一直很忙,一直拖着没写,为了树立博主言出必行的正面形象,不能再无限制的拖下去了,那么今天就来做个了断吧,总结写起来~ (以下内容均为博主自己的总结,并不权威,权当参考,欢迎各位大神们留言讨论指正)

根据查找的目标不同,博主将二分查找法主要分为以下五类:

第一类: 需查找和目标值完全相等的数

这是最简单的一类,也是我们最开始学二分查找法需要解决的问题,比如我们有数组 [2, 4, 5, 6, 9],target = 6,那么我们可以写出二分查找法的代码如下:

1
2
3
4
5
6
7
8
9
10
int find(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return -1;
}

会返回3,也就是 target 的在数组中的位置。注意二分查找法的写法并不唯一,主要可以变动地方有四处:

  • 第一处是 right 的初始化,可以写成 nums.size() 或者 nums.size() - 1。
  • 第二处是 left 和 right 的关系,可以写成 left < right 或者 left <= right。
  • 第三处是更新 right 的赋值,可以写成 right = mid 或者 right = mid - 1。
  • 第四处是最后返回值,可以返回 left,right,或 right - 1。

但是这些不同的写法并不能随机的组合,像博主的那种写法,若 right 初始化为了 nums.size(),那么就必须用 left < right,而最后的 right 的赋值必须用 right = mid。但是如果我们 right 初始化为 nums.size() - 1,那么就必须用 left <= right,并且right的赋值要写成 right = mid - 1,不然就会出错。所以博主的建议是选择一套自己喜欢的写法,并且记住,实在不行就带简单的例子来一步一步执行,确定正确的写法也行。

第一类应用实例: Intersection of Two Arrays

第二类: 查找第一个不小于目标值的数,可变形为查找最后一个小于目标值的数

这是比较常见的一类,因为我们要查找的目标值不一定会在数组中出现,也有可能是跟目标值相等的数在数组中并不唯一,而是有多个,那么这种情况下 nums[mid] == target 这条判断语句就没有必要存在。比如在数组 [2, 4, 5, 6, 9] 中查找数字3,就会返回数字4的位置;在数组 [0, 1, 1, 1, 1] 中查找数字1,就会返回第一个数字1的位置。我们可以使用如下代码:

1
2
3
4
5
6
7
8
9
int find(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return right;
}

最后我们需要返回的位置就是 right 指针指向的地方。在 C++ 的 STL 中有专门的查找第一个不小于目标值的数的函数 lower_bound,在博主的解法中也会时不时的用到这个函数。但是如果面试的时候人家不让使用内置函数,那么我们只能老老实实写上面这段二分查找的函数。

这一类可以轻松的变形为查找最后一个小于目标值的数,怎么变呢。我们已经找到了第一个不小于目标值的数,那么再往前退一位,返回 right - 1,就是最后一个小于目标值的数。

第二类应用实例:Heaters, Arranging Coins, Valid Perfect Square,Max Sum of Rectangle No Larger Than K,Russian Doll Envelopes

第二类变形应用:Valid Triangle Number

第三类: 查找第一个大于目标值的数,可变形为查找最后一个不大于目标值的数

这一类也比较常见,尤其是查找第一个大于目标值的数,在 C++ 的 STL 也有专门的函数 upper_bound,这里跟上面的那种情况的写法上很相似,只需要添加一个等号,将之前的 nums[mid] < target 变成 nums[mid] <= target,就这一个小小的变化,其实直接就改变了搜索的方向,使得在数组中有很多跟目标值相同的数字存在的情况下,返回最后一个相同的数字的下一个位置。比如在数组 [2, 4, 5, 6, 9] 中查找数字3,还是返回数字4的位置,这跟上面那查找方式返回的结果相同,因为数字4在此数组中既是第一个不小于目标值3的数,也是第一个大于目标值3的数,所以 make sense;在数组 [0, 1, 1, 1, 1] 中查找数字1,就会返回坐标5,通过对比返回的坐标和数组的长度,我们就知道是否存在这样一个大于目标值的数。参见下面的代码:

1
2
3
4
5
6
7
8
9
int find(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) left = mid + 1;
else right = mid;
}
return right;
}

这一类可以轻松的变形为查找最后一个不大于目标值的数,怎么变呢。我们已经找到了第一个大于目标值的数,那么再往前退一位,返回 right - 1,就是最后一个不大于目标值的数。比如在数组 [0, 1, 1, 1, 1] 中查找数字1,就会返回最后一个数字1的位置4,这在有些情况下是需要这么做的。

第三类应用实例:Kth Smallest Element in a Sorted Matrix

第三类变形应用示例: Sqrt(x)

第四类: 用子函数当作判断关系(通常由 mid 计算得出)

这是最令博主头疼的一类,而且通常情况下都很难。因为这里在二分查找法重要的比较大小的地方使用到了子函数,并不是之前三类中简单的数字大小的比较,比如 Split Array Largest Sum 那道题中的解法一,就是根据是否能分割数组来确定下一步搜索的范围。类似的还有 Guess Number Higher or Lower 这道题,是根据给定函数 guess 的返回值情况来确定搜索的范围。对于这类题目,博主也很无奈,遇到了只能自求多福了。

第四类应用实例:Split Array Largest Sum, Guess Number Higher or Lower,Find K Closest Elements,Find K-th Smallest Pair Distance,Kth Smallest Number in Multiplication Table,Maximum Average Subarray II,Minimize Max Distance to Gas Station,Swim in Rising Water,Koko Eating Bananas,Nth Magical Number

第五类: 其他(通常 target 值不固定)

有些题目不属于上述的四类,但是还是需要用到二分搜索法,比如这道 Find Peak Element,求的是数组的局部峰值。由于是求的峰值,需要跟相邻的数字比较,那么 target 就不是一个固定的值,而且这道题的一定要注意的是 right 的初始化,一定要是 nums.size() - 1,这是由于算出了 mid 后,nums[mid] 要和 nums[mid+1] 比较,如果 right 初始化为 nums.size() 的话,mid+1 可能会越界,从而不能找到正确的值,同时 while 循环的终止条件必须是 left < right,不能有等号。

类似的还有一道 H-Index II,这道题的 target 也不是一个固定值,而是 len-mid,这就很意思了,跟上面的 nums[mid+1] 有异曲同工之妙,target 值都随着 mid 值的变化而变化,这里的right的初始化,一定要是 nums.size() - 1,而 while 循环的终止条件必须是 left <= right,这里又必须要有等号,是不是很头大 -.-!!!

其实仔细分析的话,可以发现其实这跟第四类还是比较相似,相似点是都很难 -.-!!!,第四类中虽然是用子函数来判断关系,但大部分时候 mid 也会作为一个参数带入子函数进行计算,这样实际上最终算出的值还是受 mid 的影响,但是 right 却可以初始化为数组长度,循环条件也可以不带等号,大家可以对比区别一下~

Top-k问题的一些算法

Top K问题是面试时手写代码的常考题,某些场景下的解法与堆排和快排的关系紧密,所以把它放在堆排后面讲。

言归正传,笔者见过关于Top K问题最全的分类总结是在这里(包括海量数据的处理),个人将这些题分成了两类:一类是容易写代码实现的;另一类侧重考察思路的。毫无疑问,后一种比较简单,你只要记住它的应用场景、解决思路,并能在面试的过程中将它顺利地表达出来,便能以不变应万变。前一种,需要手写代码,就必须要掌握一定的技巧,常见的解法有两种,就是前面说过的堆排和快排的变形。

本文主要来看看方便用代码解决的问题。

堆排解法

用堆排来解决Top K的思路很直接。

前面已经说过,堆排利用的大(小)顶堆所有子节点元素都比父节点小(大)的性质来实现的,这里故技重施:既然一个大顶堆的顶是最大的元素,那我们要找最小的K个元素,是不是可以先建立一个包含K个元素的堆,然后遍历集合,如果集合的元素比堆顶元素小(说明它目前应该在K个最小之列),那就用该元素来替换堆顶元素,同时维护该堆的性质,那在遍历结束的时候,堆中包含的K个元素是不是就是我们要找的最小的K个元素?

实现:
在堆排的基础上,稍作了修改,buildHeap和heapify函数都是一样的实现,不难理解。

速记口诀:最小的K个用最大堆,最大的K个用最小堆。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class TopK {

public static void main(String[] args) {
// TODO Auto-generated method stub
int[] a = { 1, 17, 3, 4, 5, 6, 7, 16, 9, 10, 11, 12, 13, 14, 15, 8 };
int[] b = topK(a, 4);
for (int i = 0; i < b.length; i++) {
System.out.print(b[i] + ", ");
}
}

public static void heapify(int[] array, int index, int length) {
int left = index * 2 + 1;
int right = index * 2 + 2;
int largest = index;
if (left < length && array[left] > array[index]) {
largest = left;
}
if (right < length && array[right] > array[largest]) {
largest = right;
}
if (index != largest) {
swap(array, largest, index);
heapify(array, largest, length);
}
}

public static void swap(int[] array, int a, int b) {
int temp = array[a];
array[a] = array[b];
array[b] = temp;
}

public static void buildHeap(int[] array) {
int length = array.length;
for (int i = length / 2 - 1; i >= 0; i--) {
heapify(array, i, length);
}
}

public static void setTop(int[] array, int top) {
array[0] = top;
heapify(array, 0, array.length);
}

public static int[] topK(int[] array, int k) {
int[] top = new int[k];
for (int i = 0; i < k; i++) {
top[i] = array[i];
}
//先建堆,然后依次比较剩余元素与堆顶元素的大小,比堆顶小的, 说明它应该在堆中出现,则用它来替换掉堆顶元素,然后沉降。
buildHeap(top);
for (int j = k; j < array.length; j++) {
int temp = top[0];
if (array[j] < temp) {
setTop(top, array[j]);
}
}
return top;
}
}

时间复杂度
n*logK
速记:堆排的时间复杂度是n*logn,这里相当于只对前Top K个元素建堆排序,想法不一定对,但一定有助于记忆。

适用场景
实现的过程中,我们先用前K个数建立了一个堆,然后遍历数组来维护这个堆。这种做法带来了三个好处:(1)不会改变数据的输入顺序(按顺序读的);(2)不会占用太多的内存空间(事实上,一次只读入一个数,内存只要求能容纳前K个数即可);(3)由于(2),决定了它特别适合处理海量数据。

这三点,也决定了它最优的适用场景。

快排解法

用快排的思想来解Top K问题,必然要运用到”分治”。

与快排相比,两者唯一的不同是在对”分治”结果的使用上。我们知道,分治函数会返回一个position,在position左边的数都比第position个数小,在position右边的数都比第position大。我们不妨不断调用分治函数,直到它输出的position = K-1,此时position前面的K个数(0到K-1)就是要找的前K个数。

实现:
“分治”还是原来的那个分治,关键是getTopK的逻辑,务必要结合注释理解透彻,自动动手写写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class TopK {

public static void main(String[] args) {
// TODO Auto-generated method stub
int[] array = { 9, 3, 1, 10, 5, 7, 6, 2, 8, 0 };
getTopK(array, 4);
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + ", ");
}
}

// 分治
public static int partition(int[] array, int low, int high) {
if (array != null && low < high) {
int flag = array[low];
while (low < high) {
while (low < high && array[high] >= flag) {
high--;
}
array[low] = array[high];
while (low < high && array[low] <= flag) {
low++;
}
array[high] = array[low];
}
array[low] = flag;
return low;
}
return 0;
}

public static void getTopK(int[] array, int k) {
if (array != null && array.length > 0) {
int low = 0;
int high = array.length - 1;
int index = partition(array, low, high);
//不断调整分治的位置,直到position = k-1
while (index != k - 1) {
//大了,往前调整
if (index > k - 1) {
high = index - 1;
index = partition(array, low, high);
}
//小了,往后调整
if (index < k - 1) {
low = index + 1;
index = partition(array, low, high);
}
}
}
}
}

速记:记住就行,基于partition函数的时间复杂度比较难证明,从来没考过。

适用场景
对照着堆排的解法来看,partition函数会不断地交换元素的位置,所以它肯定会改变数据输入的顺序;既然要交换元素的位置,那么所有元素必须要读到内存空间中,所以它会占用比较大的空间,至少能容纳整个数组;数据越多,占用的空间必然越大,海量数据处理起来相对吃力。

但是,它的时间复杂度很低,意味着数据量不大时,效率极高。

多种不同的排序方法

稳定排序和不稳定排序

首先,排序算法的稳定性大家应该都知道,通俗地讲就是能保证排序前2个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同。在简单形式化一下,如果Ai = Aj,Ai原来在位置前,排序后Ai还是要在Aj位置前。

其次,说一下稳定性的好处。排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。另外,如果排序算法稳定,对基于比较的排序算法而言,元素交换的次数可能会少一些(个人感觉,没有证实)。

回到主题,现在分析一下常见的排序算法的稳定性,每个都给出简单的理由。

冒泡排序

冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,我想你是不会再无聊地把他们俩交换一下的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。

选择排序

选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n - 1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果当前元素比一个元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。比较拗口,举个例子,序列5 8 5 2 9,我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了,所以选择排序不是一个稳定的排序算法。

第一次从R[0]~R[n-1]中选取最小值,与R[0]交换,第二次从R[1]~R[n-1]中选取最小值,与R[1]交换,….,第i次从R[i-1]~R[n-1]中选取最小值,与R[i-1]交换,…..,第n-1次从R[n-2]~R[n-1]中选取最小值,与R[n-2]交换,总共通过n-1次,得到一个按排序码从小到大排列的有序序列。因此它的时间复杂度固定为O(n^2)

插入排序

插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,就是第一个元素。比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面,否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的。

算法分析:

  1. 从序列第一个元素开始,该元素可以认为已经被排序
  2. 取出下一个元素,设为待插入元素,在已经排序的元素序列中从后向前扫描,如果该元素(已排序)大于待插入元素,将该元素移到下一位置。
  3. 重复步骤2,直到找到已排序的元素小于或者等于待排序元素的位置,插入元素
  4. 重复2,3步骤,完成排序。

快速排序

快速排序有两个方向,左边的i下标一直往右走,当a[i] <= a[center_index],其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]。如果i和j都走不动了,i <= j,交换a[i]a[j],重复上面的过程,直到i > j。 交换a[j]a[center_index],完成一趟快速排序。在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为5 3 3 4 3 8 9 10 11,现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j]交换的时刻。

归并排序

归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。

二路归并排序主旨是“分解”与“归并”

  • 分解:  
    • 将一个数组分成两个数组,分别对两个数组进行排序。
    • 循环第一步,直到划分出来的“小数组”只包含一个元素,只有一个元素的数组默认为已经排好序。
  • 归并:
    • 将两个有序的数组合并到一个大的数组中。
    • 从最小的只包含一个元素的数组开始两两合并。此时,合并好的数组也是有序的。

希尔排序(shell)

希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小, 插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比O(n^2)好一些。由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。

堆排序

我们知道堆的结构是节点i的孩子为2 * i2 * i + 1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。在一个长为n 的序列,堆排序的过程是从第n / 2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。但当为n / 2 - 1, n / 2 - 2, … 1这些个父节点选择元素时,就会破坏稳定性。有可能第n / 2个父节点交换把后面一个元素交换过去了,而第n / 2 - 1个父节点把后面一个相同的元素没 有交换,那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序不是稳定的排序算法。

综上,得出结论: 选择排序、快速排序、希尔排序、堆排序不是稳定的排序算法,而冒泡排序、插入排序、归并排序和基数排序是稳定的排序算法

100万个32位整数,如何最快找到中位数。能保证每个数是唯一的,如何实现O(N)算法?

  1. 内存足够时:快排
  2. 内存不足时:分桶法:化大为小,把所有数划分到各个小区间,把每个数映射到对应的区间里,对每个区间中数的个数进行计数,数一遍各个区间,看看中位数落在哪个区间,若够小,使用基于内存的算法,否则 继续划分

基数排序

数据背景

在基数排序中,我们不能再只用一位数的序列来列举示例了。一位数的序列对基数排序来说就是一个计数排序。
这里我们列举无序序列 T = [ 2314, 5428, 373, 2222, 17 ]

排序原理

上面说到基数排序不需要进行元素的比较与交换。如果你有一些算法的功底,或者丰富的项目经验,我想你可能已经想到了这可能类似于一些“打表”或是哈希的做法。而计数排序则是打表或是哈希思想最简单的实现。

计数排序

计数排序的核心思想是,构建一个足够大的数组 hashArray[],数组大小需要保证能够把所有元素都包含在这个数组上 。
假设我们有无序序列 T = [ 2314, 5428, 373, 2222, 17 ]
首先初始化数组 hashArray[] 为一个全零数组。当然,在 Java 里,这一步就不需要了,因为默认就是零了。
在对序列 T 进行排序时,只要依次读取序列 T 中的元素,并修改数组 hashArray[] 中把元素值对应位置上的值即可。这一句有一些绕口。打个比方,我们要把 T[0] 映射到 hashArray[] 中,就是 hashArray[T[0]] = 1. 也就是 hashArray[2314] = 1. 如果序列 T 中有两个相同元素,那么在 hashArray 的相应位置上的值就是 2。
下图是计数排序的原理图:
(假设有无序序列:[ 5, 8, 9, 1, 4, 2, 9, 3, 7, 1, 8, 6, 2, 3, 4, 0, 8 ])

基数排序原理图

上面的计数排序只是一个引导,好让你可以循序渐进地了解基数排序。

上面这幅图,或许你已经在其他的博客里见到过。这是一个很好的引导跟说明。在基数排序里,我们需要一个很大的二维数组,二维数组的大小是 (10 * n)。10 代表的是我们每个元素的每一位都有 10 种可能,也就是 10 进制数。在上图中,我们是以每个数的个位来代表这个数,于是,5428 就被填充到了第 8 个桶中了。下次再进行填充的时候,就是以十位进行填充,比如 5428 在此时,就会选择以 2 来代表它。

算法优化

在算法的原理中,我们是以一张二维数组的表来存储这些无序的元素。使用二维数组有一个很明显的不足就是二维数组太过稀疏。数组的利用率为 10%。
在寻求优化的路上,我们想到一种可以压缩空间的方法,且时间复杂度并没有偏离得太厉害。那就是设计了两个辅助数组,一个是 count[],一个是 bucket[]。count 用于记录在某个桶中的最后一个元素的下标,然后再把原数组中的元素计算一下它应该属于哪个“桶”,并修改相应位置的 count 值。直到最大数的最高位也被添加到桶中,或者说,当所有的元素都被被在第 0 个桶中,基数排序就结束了。
优化后的原理图如下:

算法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import org.algorithm.array.sort.interf.Sortable;

/**
* <p>
* 基数排序/桶排序
* </p>
* 2016年1月19日
*
* @author <a href="http://weibo.com/u/5131020927">Q-WHai</a>
* @see <a href="http://blog.csdn.net/lemon_tree12138">http://blog.csdn.net/lemon_tree12138</a>
* @version 0.1.1
*/
public class RadixSort implements Sortable {

@Override
public int[] sort(int[] array) {
if (array == null) {
return null;
}

int maxLength = maxLength(array);

return sortCore(array, 0, maxLength);
}

private int[] sortCore(int[] array, int digit, int maxLength) {
if (digit >= maxLength) {
return array;
}

final int radix = 10; // 基数
int arrayLength = array.length;
int[] count = new int[radix];
int[] bucket = new int[arrayLength];

// 统计将数组中的数字分配到桶中后,各个桶中的数字个数
for (int i = 0; i < arrayLength; i++) {
count[getDigit(array[i], digit)]++;
}

// 将各个桶中的数字个数,转化成各个桶中最后一个数字的下标索引
for (int i = 1; i < radix; i++) {
count[i] = count[i] + count[i - 1];
}

// 将原数组中的数字分配给辅助数组 bucket
for (int i = arrayLength - 1; i >= 0; i--) {
int number = array[i];
int d = getDigit(number, digit);
bucket[count[d] - 1] = number;
count[d]--;
}

return sortCore(bucket, digit + 1, maxLength);
}

/*
* 一个数组中最大数字的位数
*
* @param array
* @return
*/
private int maxLength(int[] array) {
int maxLength = 0;
int arrayLength = array.length;
for (int i = 0; i < arrayLength; i++) {
int currentLength = length(array[i]);
if (maxLength < currentLength) {
maxLength = currentLength;
}
}

return maxLength;
}

/*
* 计算一个数字共有多少位
*
* @param number
* @return
*/
private int length(int number) {
return String.valueOf(number).length();
}

/*
* 获取 x 这个数的 d 位数上的数字
* 比如获取 123 的 0 位数,结果返回 3
*
* @param x
* @param d
* @return
*/
private int getDigit(int x, int d) {
int a[] = { 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000 };
return ((x / a[d]) % 10);
}
}

基数排序过程图

如果我们的无序是 T = [ 2314, 5428, 373, 2222, 17 ],那么其排序的过程就如下两幅所示。
基数排序过程图-1

基数排序过程图-2

拓扑排序

定义和前置条件

定义:将有向图中的顶点以线性方式进行排序。即对于任何连接自顶点u到顶点v的有向边uv,在最后的排序结果中,顶点u总是在顶点v的前面。

如果这个概念还略显抽象的话,那么不妨考虑一个非常非常经典的例子——选课。我想任何看过数据结构相关书籍的同学都知道它吧。假设我非常想学习一门机器学习的课程,但是在修这么课程之前,我们必须要学习一些基础课程,比如计算机科学概论,C语言程序设计,数据结构,算法等等。那么这个制定选修课程顺序的过程,实际上就是一个拓扑排序的过程,每门课程相当于有向图中的一个顶点,而连接顶点之间的有向边就是课程学习的先后关系。只不过这个过程不是那么复杂,从而很自然的在我们的大脑中完成了。将这个过程以算法的形式描述出来的结果,就是拓扑排序。

那么是不是所有的有向图都能够被拓扑排序呢?显然不是。继续考虑上面的例子,如果告诉你在选修计算机科学概论这门课之前需要你先学习机器学习,你是不是会被弄糊涂?在这种情况下,就无法进行拓扑排序,因为它中间存在互相依赖的关系,从而无法确定谁先谁后。在有向图中,这种情况被描述为存在环路。因此,一个有向图能被拓扑排序的充要条件就是它是一个有向无环图(DAG:Directed Acyclic Graph)。

偏序/全序关系

偏序和全序实际上是离散数学中的概念。这里不打算说太多形式化的定义,形式化的定义教科书上或者上面给的链接中就说的很详细。

还是以上面选课的例子来描述这两个概念。假设我们在学习完了算法这门课后,可以选修机器学习或者计算机图形学。这个或者表示,学习机器学习和计算机图形学这两门课之间没有特定的先后顺序。因此,在我们所有可以选择的课程中,任意两门课程之间的关系要么是确定的(即拥有先后关系),要么是不确定的(即没有先后关系),绝对不存在互相矛盾的关系(即环路)。以上就是偏序的意义,抽象而言,有向图中两个顶点之间不存在环路,至于连通与否,是无所谓的。所以,有向无环图必然是满足偏序关系的。

理解了偏序的概念,那么全序就好办了。所谓全序,就是在偏序的基础之上,有向无环图中的任意一对顶点还需要有明确的关系(反映在图中,就是单向连通的关系,注意不能双向连通,那就成环了)。可见,全序就是偏序的一种特殊情况。回到我们的选课例子中,如果机器学习需要在学习了计算机图形学之后才能学习(可能学的是图形学领域相关的机器学习算法……),那么它们之间也就存在了确定的先后顺序,原本的偏序关系就变成了全序关系。

实际上,很多地方都存在偏序和全序的概念。

比如对若干互不相等的整数进行排序,最后总是能够得到唯一的排序结果(从小到大,下同)。这个结论应该不会有人表示疑问吧:)但是如果我们以偏序/全序的角度来考虑一下这个再自然不过的问题,可能就会有别的体会了。

那么如何用偏序/全序来解释排序结果的唯一性呢?

我们知道不同整数之间的大小关系是确定的,即1总是小于4的,不会有人说1大于或者等于4吧。这就是说,这个序列是满足全序关系的。而对于拥有全序关系的结构(如拥有不同整数的数组),在其线性化(排序)之后的结果必然是唯一的。对于排序的算法,我们评价指标之一是看该排序算法是否稳定,即值相同的元素的排序结果是否和出现的顺序一致。比如,我们说快速排序是不稳定的,这是因为最后的快排结果中相同元素的出现顺序和排序前不一致了。如果用偏序的概念可以这样解释这一现象:相同值的元素之间的关系是无法确定的。因此它们在最终的结果中的出现顺序可以是任意的。而对于诸如插入排序这种稳定性排序,它们对于值相同的元素,还有一个潜在的比较方式,即比较它们的出现顺序,出现靠前的元素大于出现后出现的元素。因此通过这一潜在的比较,将偏序关系转换为了全序关系,从而保证了结果的唯一性。

拓展到拓扑排序中,结果具有唯一性的条件也是其所有顶点之间都具有全序关系。如果没有这一层全序关系,那么拓扑排序的结果也就不是唯一的了。在后面会谈到,如果拓扑排序的结果唯一,那么该拓扑排序的结果同时也代表了一条哈密顿路径。

典型实现算法

Kahn算法

摘一段维基百科上关于Kahn算法的伪码描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
L← Empty list that will contain the sorted elements
S ← Set of all nodes with no incoming edges
while S is non-empty do
remove a node n from S
insert n into L
foreach node m with an edge e from nto m do
remove edge e from thegraph
ifm has no other incoming edges then
insert m into S
if graph has edges then
return error (graph has at least onecycle)
else
return L (a topologically sortedorder)

不难看出该算法的实现十分直观,关键在于需要维护一个入度为0的顶点的集合:

每次从该集合中取出(没有特殊的取出规则,随机取出也行,使用队列/栈也行,下同)一个顶点,将该顶点放入保存结果的List中。

紧接着循环遍历由该顶点引出的所有边,从图中移除这条边,同时获取该边的另外一个顶点,如果该顶点的入度在减去本条边之后为0,那么也将这个顶点放到入度为0的集合中。然后继续从集合中取出一个顶点…………

当集合为空之后,检查图中是否还存在任何边,如果存在的话,说明图中至少存在一条环路。不存在的话则返回结果List,此List中的顺序就是对图进行拓扑排序的结果。

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class KahnTopological
{
private List<Integer> result; // 用来存储结果集
private Queue<Integer> setOfZeroIndegree; // 用来存储入度为0的顶点
private int[] indegrees; // 记录每个顶点当前的入度
private int edges;
private Digraph di;

public KahnTopological(Digraph di)
{
this.di = di;
this.edges = di.getE();
this.indegrees = new int[di.getV()];
this.result = new ArrayList<Integer>();
this.setOfZeroIndegree = new LinkedList<Integer>();

// 对入度为0的集合进行初始化
Iterable<Integer>[] adjs = di.getAdj();
for(int i = 0; i < adjs.length; i++)
{
// 对每一条边 v -> w
for(int w : adjs[i])
{
indegrees[w]++;
}
}

for(int i = 0; i < indegrees.length; i++)
{
if(0 == indegrees[i])
{
setOfZeroIndegree.enqueue(i);
}
}
process();
}

private void process()
{
while(!setOfZeroIndegree.isEmpty())
{
int v = setOfZeroIndegree.dequeue();

// 将当前顶点添加到结果集中
result.add(v);

// 遍历由v引出的所有边
for(int w : di.adj(v))
{
// 将该边从图中移除,通过减少边的数量来表示
edges--;
if(0 == --indegrees[w]) // 如果入度为0,那么加入入度为0的集合
{
setOfZeroIndegree.enqueue(w);
}
}
}
// 如果此时图中还存在边,那么说明图中含有环路
if(0 != edges)
{
throw new IllegalArgumentException("Has Cycle !");
}
}

public Iterable<Integer> getResult()
{
return result;
}
}


对上图进行拓扑排序的结果:

2->8->0->3->7->1->5->6->9->4->11->10->12

复杂度分析:

  • 初始化入度为0的集合需要遍历整张图,检查每个节点和每条边,因此复杂度为O(E+V);
  • 然后对该集合进行操作,又需要遍历整张图中的,每条边,复杂度也为O(E+V);
  • 因此Kahn算法的复杂度即为O(E+V)。

基于DFS的拓扑排序

除了使用上面直观的Kahn算法之外,还能够借助深度优先遍历来实现拓扑排序。这个时候需要使用到栈结构来记录拓扑排序的结果。

同样摘录一段维基百科上的伪码:

1
2
3
4
5
6
7
8
9
10
L ← Empty list that will contain the sorted nodes
S ← Set of all nodes with no outgoing edges
for each node n in S do
visit(n)
function visit(node n)
if n has not been visited yet then
mark n as visited
for each node m with an edgefrom m to ndo
visit(m)
add n to L

DFS的实现更加简单直观,使用递归实现。利用DFS实现拓扑排序,实际上只需要添加一行代码,即上面伪码中的最后一行:add n to L。

需要注意的是,将顶点添加到结果List中的时机是在visit方法即将退出之时。

这个算法的实现非常简单,但是要理解的话就相对复杂一点。

关键在于为什么在visit方法的最后将该顶点添加到一个集合中,就能保证这个集合就是拓扑排序的结果呢?

因为添加顶点到集合中的时机是在dfs方法即将退出之时,而dfs方法本身是个递归方法,只要当前顶点还存在边指向其它任何顶点,它就会递归调用dfs方法,而不会退出。因此,退出dfs方法,意味着当前顶点没有指向其它顶点的边了,即当前顶点是一条路径上的最后一个顶点。

下面简单证明一下它的正确性:

考虑任意的边v->w,当调用dfs(v)的时候,有如下三种情况:

  • dfs(w)还没有被调用,即w还没有被mark,此时会调用dfs(w),然后当dfs(w)返回之后,dfs(v)才会返回
  • dfs(w)已经被调用并返回了,即w已经被mark
  • dfs(w)已经被调用但是在此时调用dfs(v)的时候还未返回

需要注意的是,以上第三种情况在拓扑排序的场景下是不可能发生的,因为如果情况3是合法的话,就表示存在一条由w到v的路径。而现在我们的前提条件是由v到w有一条边,这就导致我们的图中存在环路,从而该图就不是一个有向无环图(DAG),而我们已经知道,非有向无环图是不能被拓扑排序的。

那么考虑前两种情况,无论是情况1还是情况2,w都会先于v被添加到结果列表中。所以边v->w总是由结果集中后出现的顶点指向先出现的顶点。为了让结果更自然一些,可以使用栈来作为存储最终结果的数据结构,从而能够保证边v->w总是由结果集中先出现的顶点指向后出现的顶点。

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class DirectedDepthFirstOrder
{
// visited数组,DFS实现需要用到
private boolean[] visited;
// 使用栈来保存最后的结果
private Stack<Integer> reversePost;

/**
* Topological Sorting Constructor
*/
public DirectedDepthFirstOrder(Digraph di, boolean detectCycle)
{
// 这里的DirectedDepthFirstCycleDetection是一个用于检测有向图中是否存在环路的类
DirectedDepthFirstCycleDetection detect = new DirectedDepthFirstCycleDetection(
di);

if (detectCycle && detect.hasCycle())
throw new IllegalArgumentException("Has cycle");

this.visited = new boolean[di.getV()];
this.reversePost = new Stack<Integer>();

for (int i = 0; i < di.getV(); i++)
{
if (!visited[i])
{
dfs(di, i);
}
}
}

private void dfs(Digraph di, int v)
{
visited[v] = true;

for (int w : di.adj(v))
{
if (!visited[w])
{
dfs(di, w);
}
}

// 在即将退出dfs方法的时候,将当前顶点添加到结果集中
reversePost.push(v);
}

public Iterable<Integer> getReversePost()
{
return reversePost;
}
}

复杂度分析:

复杂度同DFS一致,即O(E+V)。具体而言,首先需要保证图是有向无环图,判断图是DAG可以使用基于DFS的算法,复杂度为O(E+V),而后面的拓扑排序也是依赖于DFS,复杂度为O(E+V)

还是对上文中的那张有向图进行拓扑排序,只不过这次使用的是基于DFS的算法,结果是:

8->7->2->3->0->6->9->10->11->12->1->5->4

两种实现算法的总结

这两种算法分别使用链表和栈来表示结果集。

对于基于DFS的算法,加入结果集的条件是:顶点的出度为0。这个条件和Kahn算法中入度为0的顶点集合似乎有着异曲同工之妙,这两种算法的思想犹如一枚硬币的两面,看似矛盾,实则不然。一个是从入度的角度来构造结果集,另一个则是从出度的角度来构造。

实现上的一些不同之处:

Kahn算法不需要检测图为DAG,如果图为DAG,那么在出度为0的集合为空之后,图中还存在没有被移除的边,这就说明了图中存在环路。而基于DFS的算法需要首先确定图为DAG,当然也能够做出适当调整,让环路的检测和拓扑排序同时进行,毕竟环路检测也能够在DFS的基础上进行。

二者的复杂度均为O(V+E)。

环路检测和拓扑排序同时进行的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
public class DirectedDepthFirstTopoWithCircleDetection
{
private boolean[] visited;
// 用于记录dfs方法的调用栈,用于环路检测
private boolean[] onStack;
// 用于当环路存在时构造之
private int[] edgeTo;
private Stack<Integer> reversePost;
private Stack<Integer> cycle;

/**
* Topological Sorting Constructor
*/
public DirectedDepthFirstTopoWithCircleDetection(Digraph di)
{
this.visited = new boolean[di.getV()];
this.onStack = new boolean[di.getV()];
this.edgeTo = new int[di.getV()];
this.reversePost = new Stack<Integer>();

for (int i = 0; i < di.getV(); i++)
{
if (!visited[i])
{
dfs(di, i);
}
}
}

private void dfs(Digraph di, int v)
{
visited[v] = true;
// 在调用dfs方法时,将当前顶点记录到调用栈中
onStack[v] = true;

for (int w : di.adj(v))
{
if(hasCycle())
{
return;
}
if (!visited[w])
{
edgeTo[w] = v;
dfs(di, w);
}
else if(onStack[w])
{
// 当w已经被访问,同时w也存在于调用栈中时,即存在环路
cycle = new Stack<Integer>();
cycle.push(w);
for(int start = v; start != w; start = edgeTo[start])
{
cycle.push(v);
}
cycle.push(w);
}
}

// 在即将退出dfs方法时,将顶点添加到拓扑排序结果集中,同时从调用栈中退出
reversePost.push(v);
onStack[v] = false;
}

private boolean hasCycle()
{
return (null != cycle);
}

public Iterable<Integer> getReversePost()
{
if(!hasCycle())
{
return reversePost;
}
else
{
throw new IllegalArgumentException("Has Cycle: " + getCycle());
}
}

public Iterable<Integer> getCycle()
{
return cycle;
}
}

拓扑排序解的唯一性

哈密顿路径

哈密顿路径是指一条能够对图中所有顶点正好访问一次的路径。本文中只会解释一些哈密顿路径和拓扑排序的关系,至于哈密顿路径的具体定义以及应用,可以参见本文开篇给出的链接。

前面说过,当一个DAG中的任何两个顶点之间都存在可以确定的先后关系时,对该DAG进行拓扑排序的解是唯一的。这是因为它们形成了全序的关系,而对存在全序关系的结构进行线性化之后的结果必然是唯一的(比如对一批整数使用稳定的排序算法进行排序的结果必然就是唯一的)。

需要注意的是,非DAG也是能够含有哈密顿路径的,为了利用拓扑排序来实现判断,所以这里讨论的主要是判断DAG中是否含有哈密顿路径的算法,因此下文中的图指代的都是DAG。

那么知道了哈密顿路径和拓扑排序的关系,我们如何快速检测一张图是否存在哈密顿路径呢?

根据前面的讨论,是否存在哈密顿路径的关键,就是确定图中的顶点是否存在全序的关系,而全序的关键,就是任意一对顶点之间都是能够确定先后关系的。因此,我们能够设计一个算法,用来遍历顶点集中的每一对顶点,然后检查它们之间是否存在先后关系,如果所有的顶点对有先后关系,那么该图的顶点集就存在全序关系,即图中存在哈密顿路径。

但是很显然,这样的算法十分低效。对于大规模的顶点集,是无法应用这种解决方案的。通常一个低效的解决办法,十有八九是因为没有抓住现有问题的一些特征而导致的。因此我们回过头来再看看这个问题,有什么特征使我们没有利用的。还是举对整数进行排序的例子:

比如现在有3, 2, 1三个整数,我们要对它们进行排序,按照之前的思想,我们分别对(1,2),(2,3),(1,3)进行比较,这样需要三次比较,但是我们很清楚,1和3的那次比较实际上是多余的。我们为什么知道这次比较是多余的呢?我认为,是我们下意识的利用了整数比较满足传递性的这一规则。但是计算机是无法下意识的使用传递性的,因此只能通过其它的方式来告诉计算机,有一些比较是不必要的。所以,也就有了相对插入排序,选择排序更加高效的排序算法,比如归并排序,快速排序等,将n2的算法加速到了nlogn。或者是利用了问题的特点,采取了更加独特的解决方案,比如基数排序等。

扯远了一点,回到正题。现在我们没有利用到的就是全序关系中传递性这一规则。如何利用它呢,最简单的想法往往就是最实用的,我们还是选择排序,排序后对每对相邻元素进行检测不就间接利用了传递性这一规则嘛?所以,我们先使用拓扑排序对图中的顶点进行排序。排序后,对每对相邻顶点进行检测,看看是否存在先后关系,如果每对相邻顶点都存在着一致的先后关系(在有向图中,这种先后关系以有向边的形式体现,即查看相邻顶点对之间是否存在有向边)。那么就可以确定该图中存在哈密顿路径了,反之则不存在。

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* Hamilton Path Detection for DAG
*/
public class DAGHamiltonPath
{
private boolean hamiltonPathPresent;
private Digraph di;
private KahnTopological kts;

// 这里使用Kahn算法进行拓扑排序
public DAGHamiltonPath(Digraph di, KahnTopological kts)
{
this.di = di;
this.kts = kts;

process();
}

private void process()
{
Integer[] topoResult = kts.getResultAsArray();

// 依次检查每一对相邻顶点,如果二者之间没有路径,则不存在哈密顿路径
for(int i = 0; i < topoResult.length - 1; i++)
{
if(!hasPath(topoResult[i], topoResult[i + 1]))
{
hamiltonPathPresent = false;
return;
}
}
hamiltonPathPresent = true;
}

private boolean hasPath(int start, int end)
{
for(int w : di.adj(start))
{
if(w == end)
{
return true;
}
}
return false;
}

public boolean hasHamiltonPath()
{
return hamiltonPathPresent;
}
}

频繁项集的评估标准

什么样的数据才是频繁项集呢?也许你会说,这还不简单,肉眼一扫,一起出现次数多的数据集就是频繁项集吗!的确,这也没有说错,但是有两个问题,第一是当数据量非常大的时候,我们没法直接肉眼发现频繁项集,这催生了关联规则挖掘的算法,比如Apriori,PrefixSpan,CBA。第二是我们缺乏一个频繁项集的标准。比如10条记录,里面A和B同时出现了三次,那么我们能不能说A和B一起构成频繁项集呢?因此我们需要一个评估频繁项集的标准。

常用的频繁项集的评估标准有支持度,置信度和提升度三个。

支持度就是几个关联的数据在数据集中出现的次数占总数据集的比重。或者说几个数据关联出现的概率。如果我们有两个想分析关联性的数据X和Y,则对应的支持度为:

1
Support(X,Y)=P(XY)=number(XY)/num(AllSamples)

以此类推,如果我们有三个想分析关联性的数据X,Y和Z,则对应的支持度为:

1
Support(X,Y,Z)=P(XYZ)=number(XYZ)/num(AllSamples)

一般来说,支持度高的数据不一定构成频繁项集,但是支持度太低的数据肯定不构成频繁项集。

置信度体现了一个数据出现后,另一个数据出现的概率,或者说数据的条件概率。如果我们有两个想分析关联性的数据X和Y,X对Y的置信度为

1
Confidence(X⇐Y)=P(X|Y)=P(XY)/P(Y)

也可以以此类推到多个数据的关联置信度,比如对于三个数据X,Y,Z,则X对于Y和Z的置信度为:

1
Confidence(X⇐YZ)=P(X|YZ)=P(XYZ)/P(YZ)

举个例子,在购物数据中,纸巾对应鸡爪的置信度为40%,支持度为1%。则意味着在购物数据中,总共有1%的用户既买鸡爪又买纸巾;同时买鸡爪的用户中有40%的用户购买纸巾。

提升度表示含有Y的条件下,同时含有X的概率,与X总体发生的概率之比,即:

1
Lift(X⇐Y)=P(X|Y)/P(X)=Confidence(X⇐Y)/P(X)

提升度体先了X和Y之间的关联关系,提升度大于1则X⇐Y是有效的强关联规则, 提升度小于等于1则X⇐Y是无效的强关联规则 。一个特殊的情况,如果X和Y独立,则有Lift(X⇐Y)=1,因为此时P(X|Y)=P(X)。

一般来说,要选择一个数据集合中的频繁数据集,则需要自定义评估标准。最常用的评估标准是用自定义的支持度,或者是自定义支持度和置信度的一个组合。

Apriori算法

Apriori算法是常用的用于挖掘出数据关联规则的算法,它用来找出数据值中频繁出现的数据集合,找出这些集合的模式有助于我们做一些决策。比如在常见的超市购物数据集,或者电商的网购数据集中,如果我们找到了频繁出现的数据集,那么对于超市,我们可以优化产品的位置摆放,对于电商,我们可以优化商品所在的仓库位置,达到节约成本,增加经济效益的目的。下面我们就对Apriori算法做一个总结。

对于Apriori算法,我们使用支持度来作为我们判断频繁项集的标准。Apriori算法的目标是找到最大的K项频繁集。这里有两层意思,首先,我们要找到符合支持度标准的频繁集。但是这样的频繁集可能有很多。第二层意思就是我们要找到最大个数的频繁集。比如我们找到符合支持度的频繁集AB和ABE,那么我们会抛弃AB,只保留ABE,因为AB是2项频繁集,而ABE是3项频繁集。那么具体的,Apriori算法是如何做到挖掘K项频繁集的呢?

Apriori算法采用了迭代的方法,先搜索出候选1项集及对应的支持度,剪枝去掉低于支持度的1项集,得到频繁1项集。然后对剩下的频繁1项集进行连接,得到候选的频繁2项集,筛选去掉低于支持度的候选频繁2项集,得到真正的频繁二项集,以此类推,迭代下去,直到无法找到频繁k+1项集为止,对应的频繁k项集的集合即为算法的输出结果。

可见这个算法还是很简洁的,第i次的迭代过程包括扫描计算候选频繁i项集的支持度,剪枝得到真正频繁i项集和连接生成候选频繁i+1项集三步。

我们下面这个简单的例子看看:

我们的数据集D有4条记录,分别是134,235,1235和25。现在我们用Apriori算法来寻找频繁k项集,最小支持度设置为50%。首先我们生成候选频繁1项集,包括我们所有的5个数据并计算5个数据的支持度,计算完毕后我们进行剪枝,数据4由于支持度只有25%被剪掉。我们最终的频繁1项集为1235,现在我们链接生成候选频繁2项集,包括12,13,15,23,25,35共6组。此时我们的第一轮迭代结束。

进入第二轮迭代,我们扫描数据集计算候选频繁2项集的支持度,接着进行剪枝,由于12和15的支持度只有25%而被筛除,得到真正的频繁2项集,包括13,23,25,35。现在我们链接生成候选频繁3项集,123,135和235共3组,这部分图中没有画出。通过计算候选频繁3项集的支持度,我们发现123和135的支持度均为25%,因此接着被剪枝,最终得到的真正频繁3项集为235一组。由于此时我们无法再进行数据连接,进而得到候选频繁4项集,最终的结果即为频繁3三项集235。

Aprior算法流程

下面我们对Aprior算法流程做一个总结。

  • 输入:数据集合D,支持度阈值α
  • 输出:最大的频繁k项集
  1. 扫描整个数据集,得到所有出现过的数据,作为候选频繁1项集。k=1,频繁0项集为空集。
  2. 挖掘频繁k项集
    1. 扫描数据计算候选频繁k项集的支持度
    2. 去除候选频繁k项集中支持度低于阈值的数据集,得到频繁k项集。如果得到的频繁k项集为空,则直接返回频繁k-1项集的集合作为算法结果,算法结束。如果得到的频繁k项集只有一项,则直接返回频繁k项集的集合作为算法结果,算法结束。
    3. 基于频繁k项集,连接生成候选频繁k+1项集。
  3. 令k=k+1,转入步骤2。

从算法的步骤可以看出,Aprior算法每轮迭代都要扫描数据集,因此在数据集很大,数据种类很多的时候,算法效率很低。

Aprior算法总结

Aprior算法是一个非常经典的频繁项集的挖掘算法,很多算法都是基于Aprior算法而产生的,包括FP-Tree,GSP,CBA等。这些算法利用了Aprior算法的思想,但是对算法做了改进,数据挖掘效率更好一些,因此现在一般很少直接用Aprior算法来挖掘数据了,但是理解Aprior算法是理解其它Aprior类算法的前提,同时算法本身也不复杂,因此值得好好研究一番。

FP Tree

Apriori算法需要多次扫描数据,I/O是很大的瓶颈。为了解决这个问题,FP Tree算法(也称FP Growth算法)采用了一些技巧,无论多少数据,只需要扫描两次数据集,因此提高了算法运行的效率。下面我们就对FP Tree算法做一个总结。

FP Tree数据结构

为了减少I/O次数,FP Tree算法引入了一些数据结构来临时存储数据。这个数据结构包括三部分,如下图所示:

第一部分是一个项头表。里面记录了所有的1项频繁集出现的次数,按照次数降序排列。比如上图中B在所有10组数据中出现了8次,因此排在第一位,这部分好理解。第二部分是FP Tree,它将我们的原始数据集映射到了内存中的一颗FP树,这个FP树比较难理解,它是怎么建立的呢?这个我们后面再讲。第三部分是节点链表。所有项头表里的1项频繁集都是一个节点链表的头,它依次指向FP树中该1项频繁集出现的位置。这样做主要是方便项头表和FP Tree之间的联系查找和更新,也好理解。

项头表的建立

FP树的建立需要首先依赖项头表的建立。首先我们看看怎么建立项头表。

我们第一次扫描数据,得到所有频繁一项集的的计数。然后删除支持度低于阈值的项,将1项频繁集放入项头表,并按照支持度降序排列。接着第二次也是最后一次扫描数据,将读到的原始数据剔除非频繁1项集,并按照支持度降序排列。

上面这段话很抽象,我们用下面这个例子来具体讲解。我们有10条数据,首先第一次扫描数据并对1项集计数,我们发现O,I,L,J,P,M, N都只出现一次,支持度低于20%的阈值,因此他们不会出现在下面的项头表中。剩下的A,C,E,G,B,D,F按照支持度的大小降序排列,组成了我们的项头表。

接着我们第二次扫描数据,对于每条数据剔除非频繁1项集,并按照支持度降序排列。比如数据项ABCEFO,里面O是非频繁1项集,因此被剔除,只剩下了ABCEF。按照支持度的顺序排序,它变成了ACEBF。其他的数据项以此类推。为什么要将原始数据集里的频繁1项数据项进行排序呢?这是为了我们后面的FP树的建立时,可以尽可能的共用祖先节点。

通过两次扫描,项头表已经建立,排序后的数据集也已经得到了,下面我们再看看怎么建立FP树。

FP Tree的建立

有了项头表和排序后的数据集,我们就可以开始FP树的建立了。开始时FP树没有数据,建立FP树时我们一条条的读入排序后的数据集,插入FP树,插入时按照排序后的顺序,插入FP树中,排序靠前的节点是祖先节点,而靠后的是子孙节点。如果有共用的祖先,则对应的公用祖先节点计数加1。插入后,如果有新节点出现,则项头表对应的节点会通过节点链表链接上新节点。直到所有的数据都插入到FP树后,FP树的建立完成。

似乎也很抽象,我们还是用第二节的例子来描述。

首先,我们插入第一条数据ACEBF,如下图所示。此时FP树没有节点,因此ACEBF是一个独立的路径,所有节点计数为1,项头表通过节点链表链接上对应的新增节点。

接着我们插入数据ACG,如下图所示。由于ACG和现有的FP树可以有共有的祖先节点序列AC,因此只需要增加一个新节点G,将新节点G的计数记为1。同时A和C的计数加1成为2。当然,对应的G节点的节点链表要更新

同样的办法可以更新后面8条数据,如下8张图。由于原理类似,这里就不多文字讲解了,大家可以自己去尝试插入并进行理解对比。相信如果大家自己可以独立的插入这10条数据,那么FP树建立的过程就没有什么难度了。








FP Tree的挖掘

下面我们讲如何从FP树里挖掘频繁项集。得到了FP树和项头表以及节点链表,我们首先要从项头表的底部项依次向上挖掘。对于项头表对应于FP树的每一项,我们要找到它的条件模式基。所谓条件模式基是以我们要挖掘的节点作为叶子节点所对应的FP子树。得到这个FP子树,我们将子树中每个节点的的计数设置为叶子节点的计数,并删除计数低于支持度的节点。从这个条件模式基,我们就可以递归挖掘得到频繁项集了。

实在太抽象了,之前我看到这也是一团雾水。还是以上面的例子来讲解。我们看看先从最底下的F节点开始,我们先来寻找F节点的条件模式基,由于F在FP树中只有一个节点,因此候选就只有下图左所示的一条路径,对应{A:8,C:8,E:6,B:2,F:2}。我们接着将所有的祖先节点计数设置为叶子节点的计数,即FP子树变成{A:2,C:2,E:2,B:2,F:2}。一般我们的条件模式基可以不写叶子节点,因此最终的F的条件模式基如下图右所示。

通过它,我们很容易得到F的频繁2项集为{A:2,F:2},{C:2,F:2},{E:2,F:2},{B:2,F:2}。递归合并二项集,得到频繁三项集为{A:2,C:2,F:2},{A:2,E:2,F:2},…还有一些频繁三项集,就不写了。当然一直递归下去,最大的频繁项集为频繁5项集,为{A:2,C:2,E:2,B:2,F:2}。

F挖掘完了,我们开始挖掘D节点。D节点比F节点复杂一些,因为它有两个叶子节点,因此首先得到的FP子树如下图左。我们接着将所有的祖先节点计数设置为叶子节点的计数,即变成{A:2,C:2,E:1 G:1,D:1,D:1}此时E节点和G节点由于在条件模式基里面的支持度低于阈值,被我们删除,最终在去除低支持度节点并不包括叶子节点后D的条件模式基为{A:2,C:2}。通过它,我们很容易得到D的频繁2项集为{A:2,D:2},{C:2,D:2}。递归合并二项集,得到频繁三项集为{A:2,C:2,D:2}。D对应的最大的频繁项集为频繁3项集。

同样的方法可以得到B的条件模式基如下图右边,递归挖掘到B的最大频繁项集为频繁4项集{A:2,C:2,E:2,B:2}。

继续挖掘G的频繁项集,挖掘到的G的条件模式基如下图右边,递归挖掘到G的最大频繁项集为频繁4项集{A:5,C:5,E:4,G:4}。

E的条件模式基如下图右边,递归挖掘到E的最大频繁项集为频繁3项集{A:6,C:6,E:6}。

C的条件模式基如下图右边,递归挖掘到C的最大频繁项集为频繁2项集{A:8,C:8}。

至于A,由于它的条件模式基为空,因此可以不用去挖掘了。

至此我们得到了所有的频繁项集,如果我们只是要最大的频繁K项集,从上面的分析可以看到,最大的频繁项集为5项集。包括{A:2,C:2,E:2,B:2,F:2}。

通过上面的流程,相信大家对FP Tree的挖掘频繁项集的过程也很熟悉了。

FP Tree算法归纳

这里我们对FP Tree算法流程做一个归纳。FP Tree算法包括以下几步:

  1. 扫描数据,得到所有频繁一项集的的计数。然后删除支持度低于阈值的项,将1项频繁集放入项头表,并按照支持度降序排列。
  2. 扫描数据,将读到的原始数据剔除非频繁1项集,并按照支持度降序排列。
  3. 读入排序后的数据集,插入FP树,插入时按照排序后的顺序,插入FP树中,排序靠前的节点是祖先节点,而靠后的是子孙节点。如果有共用的祖先,则对应的公用祖先节点计数加1。插入后,如果有新节点出现,则项头表对应的节点会通过节点链表链接上新节点。直到所有的数据都插入到FP树后,FP树的建立完成。
  4. 从项头表的底部项依次向上找到项头表项对应的条件模式基。从条件模式基递归挖掘得到项头表项项的频繁项集(可以参见第4节对F的条件模式基的频繁二项集到频繁5五项集的挖掘)。
  5. 如果不限制频繁项集的项数,则返回步骤4所有的频繁项集,否则只返回满足项数要求的频繁项集。

FP tree算法总结

FP Tree算法改进了Apriori算法的I/O瓶颈,巧妙的利用了树结构,这让我们想起了BIRCH聚类,BIRCH聚类也是巧妙的利用了树结构来提高算法运行速度。利用内存数据结构以空间换时间是常用的提高算法运行时间瓶颈的办法。

在实践中,FP Tree算法是可以用于生产环境的关联算法,而Apriori算法则做为先驱,起着关联算法指明灯的作用。除了FP Tree,像GSP,CBA之类的算法都是Apriori派系的。

PrefixSpan算法

前面我们讲到频繁项集挖掘的关联算法Apriori和FP Tree。这两个算法都是挖掘频繁项集的。而今天我们要介绍的PrefixSpan算法也是关联算法,但是它是挖掘频繁序列模式的,因此要解决的问题目标稍有不同。

项集数据和序列数据

首先我们看看项集数据和序列数据有什么不同,如下图所示。

左边的数据集就是项集数据,在Apriori和FP Tree算法中我们也已经看到过了,每个项集数据由若干项组成,这些项没有时间上的先后关系。而右边的序列数据则不一样,它是由若干数据项集组成的序列。比如第一个序列,它由a,abc,ac,d,cf共5个项集数据组成,并且这些项有时间上的先后关系。对于多于一个项的项集我们要加上括号,以便和其他的项集分开。同时由于项集内部是不区分先后顺序的,为了方便数据处理,我们一般将序列数据内所有的项集内部按字母顺序排序。

子序列与频繁序列

了解了序列数据的概念,我们再来看看上面是子序列。子序列和我们数学上的子集的概念很类似,也就是说,如果某个序列A所有的项集在序列B中的项集都可以找到,则A就是B的子序列。当然,如果用严格的数学描述,子序列是这样的:

对于序列A={a1,a2,…an}和序列B={b1,b2,…bm},n≤m,如果存在数字序列1≤j1≤j2≤…≤jn≤m, 满足a1⊆bj1,a2⊆bj2…an⊆bjn,则称A是B的子序列。当然反过来说, B就是A的超序列。

而频繁序列则和我们的频繁项集很类似,也就是频繁出现的子序列。比如对于下图,支持度阈值定义为50%,也就是需要出现两次的子序列才是频繁序列。而子序列<(ab)c>是频繁序列,因为它是图中的第一条数据和第三条序列数据的子序列,对应的位置用蓝色标示。

PrefixSpan算法的一些概念

PrefixSpan算法的全称是Prefix-Projected Pattern Growth,即前缀投影的模式挖掘。里面有前缀和投影两个词。那么我们首先看看什么是PrefixSpan算法中的前缀prefix。

在PrefixSpan算法中的前缀prefix通俗意义讲就是序列数据前面部分的子序列。比如对于序列数据B=<a(abc)(ac)d(cf)>,而A=<a(abc)a>,则A是B的前缀。当然B的前缀不止一个,比如<a><aa><a(ab)>也都是B的前缀。

看了前缀,我们再来看前缀投影,其实前缀投影这儿就是我们的后缀,有前缀就有后缀嘛。前缀加上后缀就可以构成一个我们的序列。下面给出前缀和后缀的例子。对于某一个前缀,序列里前缀后面剩下的子序列即为我们的后缀。如果前缀最后的项是项集的一部分,则用一个“_”来占位表示。

下面这个例子展示了序列<a(abc)(ac)d(cf)>的一些前缀和后缀,还是比较直观的。要注意的是,如果前缀的末尾不是一个完全的项集,则需要加一个占位符。

在PrefixSpan算法中,相同前缀对应的所有后缀的结合我们称为前缀对应的投影数据库。

PrefixSpan算法思想

现在我们来看看PrefixSpan算法的思想,PrefixSpan算法的目标是挖掘出满足最小支持度的频繁序列。那么怎么去挖掘出所有满足要求的频繁序列呢。回忆Aprior算法,它是从频繁1项集出发,一步步的挖掘2项集,直到最大的K项集。PrefixSpan算法也类似,它从长度为1的前缀开始挖掘序列模式,搜索对应的投影数据库得到长度为1的前缀对应的频繁序列,然后递归的挖掘长度为2的前缀所对应的频繁序列,。。。以此类推,一直递归到不能挖掘到更长的前缀挖掘为止。

比如对应于我们第二节的例子,支持度阈值为50%。里面长度为1的前缀包括<a><b><c><d><e><f><g>我们需要对这6个前缀分别递归搜索找各个前缀对应的频繁序列。如下图所示,每个前缀对应的后缀也标出来了。由于g只在序列4出现,支持度计数只有1,因此无法继续挖掘。我们的长度为1的频繁序列为<a><b><c><d><e><f>。去除所有序列中的g,即第4条记录变成<e(af)cbc>

现在我们开始挖掘频繁序列,分别从长度为1的频繁项开始。这里我们以d为例子来递归挖掘,其他的节点递归挖掘方法和D一样。方法如下图,首先我们对d的后缀进行计数,得到{a:1, b:2, c:3, d:0, e:1, f:1,_f:1}。注意f和_f是不一样的,因为前者是在和前缀d不同的项集,而后者是和前缀d同项集。由于此时a,d,e,f,_f都达不到支持度阈值,因此我们递归得到的前缀为d的2项频繁序列为<db><dc>。接着我们分别递归db和dc为前缀所对应的投影序列。首先看db前缀,此时对应的投影后缀只有<_c(ae)>,此时_c,a,e支持度均达不到阈值,因此无法找到以db为前缀的频繁序列。现在我们来递归另外一个前缀dc。以dc为前缀的投影序列为<_f><(bc)(ae)><b>,此时我们进行支持度计数,结果为{b:2, a:1, c:1, e:1, _f:1},只有b满足支持度阈值,因此我们得到前缀为dc的三项频繁序列为<dcb>。我们继续递归以<dcb>为前缀的频繁序列。由于前缀<dcb>对应的投影序列<(_c)ae>支持度全部不达标,因此不能产生4项频繁序列。至此以d为前缀的频繁序列挖掘结束,产生的频繁序列为<d><db><dc><dcb>

同样的方法可以得到其他以<a><b><c><e><f>为前缀的频繁序列。

PrefixSpan算法流程

下面我们对PrefixSpan算法的流程做一个归纳总结。

  • 输入:序列数据集S和支持度阈值α
  • 输出:所有满足支持度要求的频繁序列集
  1. 找出所有长度为1的前缀和对应的投影数据库
  2. 对长度为1的前缀进行计数,将支持度低于阈值α的前缀对应的项从数据集S删除,同时得到所有的频繁1项序列,i=1.
  3. 对于每个长度为i满足支持度要求的前缀进行递归挖掘:
    1. 找出前缀所对应的投影数据库。如果投影数据库为空,则递归返回。
    2. 统计对应投影数据库中各项的支持度计数。如果所有项的支持度计数都低于阈值α,则递归返回。
    3. 将满足支持度计数的各个单项和当前的前缀进行合并,得到若干新的前缀。
    4. 令i=i+1,前缀为合并单项后的各个前缀,分别递归执行第3步。

jemalloc是一个通用的malloc(3)实现,着重于减少内存碎片和提高并发性能,在许多项目中都有用到,比如RustRedis

背景知识

内存的来源

Linux提供了几个系统调用用于分配内存:

  • brk():调整program break,改变data segment的大小。
  • mmap():在进程的虚拟地址空间中创建新的内存映射。内存分配器一般使用该系统调用创建私有匿名映射分配内存,内核会以page size大小倍数来分配,page size一般为 4096 字节。

对应的释放内存也有几种方式:

  • brk():既可以增大也可以缩小。
  • munmap():解除映射。
  • madvise():该系统调用会告诉操作系统这块内存之后会如何使用,由操作系统进行处理。释放时,会使用MADV_DONTNEEDMADV_FREE:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
MADV_DONTNEED
Do not expect access in the near future. (For the time being,
the application is finished with the given range, so the
kernel can free resources associated with it.)

After a successful MADV_DONTNEED operation, the semantics of
memory access in the specified region are changed: subsequent
accesses of pages in the range will succeed, but will result
in either repopulating the memory contents from the up-to-date
contents of the underlying mapped file (for shared file
mappings, shared anonymous mappings, and shmem-based
techniques such as System V shared memory segments) or zero-
fill-on-demand pages for anonymous private mappings.

Note that, when applied to shared mappings, MADV_DONTNEED
might not lead to immediate freeing of the pages in the range.
The kernel is free to delay freeing the pages until an
appropriate moment. The resident set size (RSS) of the
calling process will be immediately reduced however.

MADV_DONTNEED cannot be applied to locked pages, Huge TLB
pages, or VM_PFNMAP pages. (Pages marked with the kernel-
internal VM_PFNMAP flag are special memory areas that are not
managed by the virtual memory subsystem. Such pages are
typically created by device drivers that map the pages into
user space.)

可以看出来,对于私有匿名映射会立即释放,而在之后再次访问这块内存时,不需要重新分配。

False cache line sharing

为了减少存储器访问延迟,CPU中会有本地CacheCache被划分为cache line,大小一般为64BCPU访问内存时,会首先将内存缓存在cache line中。 在多处理器系统中,每个CPU都有自己的本地Cache,会导致数据多副本,也就带来了一致性问题:多个CPUcache line中有相同地址的内存。需要实现Cache Coherence Protocol来解决这个问题。现代处理器一般使用MESI协议实现Cache Coherence,这会带来通讯耗时、总线压力、导致cache line的抖动,影响性能。

避免这个问题主要有下面几个方法:

  • __declspec (align(64)):变量起始地址按cache line对齐
  • 当使用数组或结构体时,不仅需要起始地址对齐,还需要padding,使得数组元素或结构体大小为cache line倍数
  • 避免多线程使用相近地址的内存,多使用局部变量

内存着色

现代CPUCache映射策略有很多,如组相联、全相联、直接相联。不同地址的内存有可能映射到相同的cache line(主要发生在地址对齐的情况,如不同对象的地址按照page size对齐),如果频繁交替访问映射到相同cache line的内存,就会造成cache line的颠簸。 内存着色通过给对象地址增加cache line大小倍数的偏移,从而映射到不同的cache line,来避免上面的问题。

为什么需要内存分配器

因为mmap()按照page size进行分配,一般是 4096 字节,若每次分配时都调用一次会造成极大的内存浪费,并且性能不好。若由程序员自己管理page,容易出错且性能不好,所以glibc中提供了标准malloc(3)供程序员使用。

内存分配器的目标

内存分配器的目标主要有2个:

  • 减少内存碎片,包括内部碎片和外部碎片:
    • 内部碎片:分配出去的但没有使用到的内存,比如需要 32 字节,分配了 40 字节,多余的 8 字节就是内部碎片。
    • 外部碎片:大小不合适导致无法分配出去的内存,比如一直申请 16 字节的内存,但是内存分配器中保存着部分 8 字节的内存,一直分配不出去。
  • 提高性能:
    • 单线程性能
    • 多线程性能

jemalloctcmalloc都是对glibc中的优化,目的也是为了减少内存碎片和提高性能。

常用内存分配器算法

Dynamic memory allocation

首先分配一整块内存,然后按需从这块内存中分配。一般会在分配出的内存前面保存metadata,还会维护freelist用于查找空闲内存。但这会导致比较严重的外部碎片:

Buddy memory allocation

Binary buddy algorithm为例:同样从一块内存中分配,但此时不是按需分配大小,而是这块内存不断分成一半,直到到达目标大小或者下界。在释放的时候,会和之前分裂的且空闲的进行合并。 一般会用有序结构如红黑树,来存储不同大小的buddy block,这样分配和合并时可以快速查找合适的内存。

这种算法能够有效减少外部碎片,但内部碎片很严重,Binary buddy algorithm最严重会带来 50% 的内部碎片。

Slab allocation

对象的初始化和释放往往比内存的分配和释放代价大,基于此发明了slabslab会提前分配一块内存,然后将这块连续内存划分为大小相同的slots,使用相应的数据结构记录每个slots的分配状况,如bitmap。 当需要分配时,就查找对应大小的slab,分配出一个空闲slot,而释放时就是把这个slot标记为空闲。

slabsize classes影响碎片的产生,需要精心选择:

  • size classes太稀疏会导致内部碎片
  • size classes太密集又会导致外部碎片

jemalloc 源码分析

Redis一般不使用glibc中默认的内存分配器,在编译时可以指定使用自带的jemalloc,版本为 4.0.3,编译参数如下:

1
./configure --with-lg-quantum=3 --with-jemalloc-prefix=je_ --enable-cc-silence CFLAGS="-std=gnu99 -Wall -pipe -g3 -O3 -funroll-loops " LDFLAGS=""
  1. --with-lg-quantum=<lg-quantum>:Base 2 log of minimum allocation alignment. 8字节对齐
  2. --with-jemalloc-prefix=<prefix>:Prefix to prepend to all public APIs.
  3. --disable-cc-silence:Do not silence irrelevant compiler warnings.

jemalloc可以在编译时配置也支持运行时配置,配置项可以查看文档,可配置的有 page size、chunksize、quantum 等。配置支持 4 种方式:

  • /etc/malloc.conf 符号链接
  • MALLOC_OPTIONS 环境变量
  • _malloc_options 全局变量
  • je_mallctl() 在代码里进行配置

1 - Some structures

page

最底层是从操作系统申请内存,由pages.h/pages.c封装了跨平台实现,Linux中使用mmap(2)。主要关注下面几个函数:

  • pages_map():调用mmap()分配可读可写、私有匿名映射。
  • pages_unmap():调用mummap()删除指定范围的映射。
  • pages_trim()trim头尾部分的内存映射,用于内存对齐。
  • pages_purge():调用madvise()清除(purge)部分内存页,也就是释放。

mmap()会以page size的倍数分配内存,匿名映射会初始化为0,私有映射采用 COW 策略。

chunk

每当内存不够用了,jemalloc会以chunk为单位从操作系统申请内存,大小为page size倍数,默认为 2 MiB,分配的函数为chunk_alloc_mmap()chunk_alloc_map()会调用pages_map()分配地址按chunk_size对齐的内存,既可以避免 false cache line sharing,也可以在常量时间内得到起始地址。 但是pages_map()不能保证对齐,首先会调用pages_map()分配一块内存查看是否对齐,若没对齐,会重新多分配一些内存,然后调用pages_trim()截取两端使内存对齐 ,所以可能会有多次mmap()munmap()的过程。

chunk分配出来需要进行管理,每个chunk会分配一个头部extent_node_t记录其中的信息,如:

  • en_arena:负责该chunkarena(后面介绍)。
  • en_addr:该chunk的起始地址。
    chunk分配出来会插入到chunks_rtree(radix tree)中,保存chunk地址到extent_node_t的映射,以便能快速从地址找到node,方便后面huge object的释放。

base

jemalloc不可能只使用栈空间或全局变量,内部也需要动态分配一些内存。base.h/base.c实现了内部使用的内存分配器。

base通过地址对齐和padding避免 false cache line sharing:chunk会按照chunksize地址对齐,且分配的大小会paddingcache line大小倍数。

basechunk为单位申请内存,记录chunk信息的extent_node_t使用chunk的起始内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static extent_node_t *
base_chunk_alloc(size_t minsize)
{
extent_node_t *node;
size_t csize, nsize;
void *addr;

assert(minsize != 0);
/* 尝试从 base_nodes 中复用 node */
node = base_node_try_alloc();
/* Allocate enough space to also carve a node out if necessary. */
// 需要分配的 node 的内存
nsize = (node == NULL) ? CACHELINE_CEILING(sizeof(extent_node_t)) : 0;
// 多分配 node size,也按照 chunk size 对齐
csize = CHUNK_CEILING(minsize + nsize);
// 内部调用 chunk_alloc_mmap()
addr = chunk_alloc_base(csize);
if (addr == NULL) {
if (node != NULL)
base_node_dalloc(node);
return (NULL);
}
base_mapped += csize;
if (node == NULL) {
// 使用 chunk 的起始内存
node = (extent_node_t *)addr;
addr = (void *)((uintptr_t)addr + nsize);
csize -= nsize;
if (config_stats) {
base_allocated += nsize;
base_resident += PAGE_CEILING(nsize);
}
}
extent_node_init(node, NULL, addr, csize, true, true);
return (node);
}

base使用extent_node_t组成的红黑树base_avail_szad管理chunk。每次需要分配时,会从红黑树中查找内存大小相同或略大的、地址最低的node,然后从node负责的chunk中分配内存,剩下的内存会继续由该node负责,修改大小和地址后再次插入到红黑树中;若该node负责的内存全部分配完了,会将该node添加到链表头base_nodes,留待后续分配时复用。当没有合适的node时,会新分配chunk大小倍数的内存,由node负责,这个node优先从链表base_nodes中分配,也可能是新分配的连续内存的起始位置构成的node

base_alloc():从base_avail_szad中查找大小相同或略大的、地址最低的extent_node_t,再从chunk里分配内存。如果没有合适的内存,会先调用base_chunk_alloc()分配chunk大小倍数的内存,返回负责这块内存的node,然后进行分配。

1
2
3
4
5
6
7
8
9
10
ret = extent_node_addr_get(node); /* node 中用于分配内存的起始地址 */
if (extent_node_size_get(node) > csize) {
extent_node_addr_set(node, (void *)((uintptr_t)ret + csize)); /* 起始地址增加 csize,表明之前的内存被分配出去 */
extent_node_size_set(node, extent_node_size_get(node) - csize); /* 内存大小减少 */
extent_tree_szad_insert(&base_avail_szad, node); /* 按照大小、地址顺序插入到红黑树 */
} else
/* 这种情况只发生在 extent_node_size_get(node) == csize 这种情况。
* 此时该 node 负责的内存已经全部分配了,会将该 node 插入到一个链表中去,备用。
* 该链表用嵌入式实现,在 node 的起始内存存放下一个 node 的地址,节省空间 */
base_node_dalloc(node);

为了减少内存浪费,base_nodes链表缓存了之前分配的extent_node_tbase_nodes指向链表头,base_node_dalloc()node添加到表头,而base_node_try_alloc()移除表头。 采用嵌入式实现,比较晦涩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static extent_node_t *
base_node_try_alloc(void)
{
extent_node_t *node;

if (base_nodes == NULL)
return (NULL);
// 返回链表头
node = base_nodes;
// base_nodes 指向下一个 node
base_nodes = *(extent_node_t **)node;
JEMALLOC_VALGRIND_MAKE_MEM_UNDEFINED(node, sizeof(extent_node_t));
return (node);
}

/* base_mtx must be held. */
static void
base_node_dalloc(extent_node_t *node)
{

JEMALLOC_VALGRIND_MAKE_MEM_UNDEFINED(node, sizeof(extent_node_t));
// 将 base_nodes 指向的地址保存在 node 的指向的内存起始处
// 形成一个 node 的链表,base_nodes 指向链表头,内存起始处为
// 下一个 node 的地址
*(extent_node_t **)node = base_nodes;
base_nodes = node;
}

arena

arenajemalloc中最重要的部分,内存大多数由arena管理,分配算法是Buddy allocationSlab allocation的组合:

  • chunk使用Buddy allocation划分为不同大小的run
  • run使用Slab allocation划分为固定大小的region,大部分内存分配直接查找对应的run,从中分配空闲的region,释放就是标记region为空闲。
  • run被释放会和空闲的、相邻的run进行合并。当合并为整个chunk时,若发现有相邻的空闲chunk,也会进行合并。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
struct arena_s {
/* This arena's index within the arenas array. */
unsigned ind;

/*
* Number of threads currently assigned to this arena. This field is
* protected by arenas_lock.
*/
unsigned nthreads;

/*
* There are three classes of arena operations from a locking
* perspective:
* 1) Thread assignment (modifies nthreads) is protected by arenas_lock.
* 2) Bin-related operations are protected by bin locks.
* 3) Chunk- and run-related operations are protected by this mutex.
*/
malloc_mutex_t lock;

arena_stats_t stats;
/*
* List of tcaches for extant threads associated with this arena.
* Stats from these are merged incrementally, and at exit if
* opt_stats_print is enabled.
*/
ql_head(tcache_t) tcache_ql;

uint64_t prof_accumbytes;

/*
* PRNG state for cache index randomization of large allocation base
* pointers.
*/
uint64_t offset_state;

dss_prec_t dss_prec;

/*
* In order to avoid rapid chunk allocation/deallocation when an arena
* oscillates right on the cusp of needing a new chunk, cache the most
* recently freed chunk. The spare is left in the arena's chunk trees
* until it is deleted.
*
* There is one spare chunk per arena, rather than one spare total, in
* order to avoid interactions between multiple threads that could make
* a single spare inadequate.
*/
arena_chunk_t *spare;

/* Minimum ratio (log base 2) of nactive:ndirty. */
ssize_t lg_dirty_mult;

/* True if a thread is currently executing arena_purge(). */
bool purging;

/* Number of pages in active runs and huge regions. */
// 已经分配出的 page 个数
size_t nactive;

/*
* Current count of pages within unused runs that are potentially
* dirty, and for which madvise(... MADV_DONTNEED) has not been called.
* By tracking this, we can institute a limit on how much dirty unused
* memory is mapped for each arena.
*/
// runs_dirty 中的page数目(包含 chunk)
size_t ndirty;

/*
* Size/address-ordered tree of this arena's available runs. The tree
* is used for first-best-fit run allocation.
*/
// 红黑树
arena_avail_tree_t runs_avail;

/*
* Unused dirty memory this arena manages. Dirty memory is conceptually
* tracked as an arbitrarily interleaved LRU of dirty runs and cached
* chunks, but the list linkage is actually semi-duplicated in order to
* avoid extra arena_chunk_map_misc_t space overhead.
*
* LRU-----------------------------------------------------------MRU
*
* /-- arena ---\
* | |
* | |
* |------------| /- chunk -\
* ...->|chunks_cache|<--------------------------->| /----\ |<--...
* |------------| | |node| |
* | | | | | |
* | | /- run -\ /- run -\ | | | |
* | | | | | | | | | |
* | | | | | | | | | |
* |------------| |-------| |-------| | |----| |
* ...->|runs_dirty |<-->|rd |<-->|rd |<---->|rd |<----...
* |------------| |-------| |-------| | |----| |
* | | | | | | | | | |
* | | | | | | | \----/ |
* | | \-------/ \-------/ | |
* | | | |
* | | | |
* \------------/ \---------/
*/
// 空闲的 dirty run 会存在这,用于 purge
arena_runs_dirty_link_t runs_dirty;
// 都是 runs_dirty 中存在的,是为了保存脏的 chunk
extent_node_t chunks_cache;

/* Extant huge allocations. */
ql_head(extent_node_t) huge;
/* Synchronizes all huge allocation/update/deallocation. */
malloc_mutex_t huge_mtx;

/*
* Trees of chunks that were previously allocated (trees differ only in
* node ordering). These are used when allocating chunks, in an attempt
* to re-use address space. Depending on function, different tree
* orderings are needed, which is why there are two trees with the same
* contents.
*/
// 用于复用 chunk
// 2种树的内容一样,order 不同
extent_tree_t chunks_szad_cached;
extent_tree_t chunks_ad_cached;
extent_tree_t chunks_szad_retained;
extent_tree_t chunks_ad_retained;

malloc_mutex_t chunks_mtx;
/* Cache of nodes that were allocated via base_alloc(). */
ql_head(extent_node_t) node_cache;
malloc_mutex_t node_cache_mtx;

/* User-configurable chunk hook functions. */
// chunk_hooks_default
chunk_hooks_t chunk_hooks;

/* bins is used to store trees of free regions. */
arena_bin_t bins[NBINS];
};

整体的结构图如下,忽略了很多细节:

run

small classesrun中使用slab算法分配,每个run对应一块连续的内存,大小为page size倍数,划分为等大小的region,分配时就从run中分配一个空闲region,释放时就标记该region为空闲,留待之后分配。

arena_run_t记录了run的分配情况:

1
2
3
4
5
6
7
8
9
10
11
struct arena_run_s {
/* Index of bin this run is associated with. */
szind_t binind;

/* Number of free regions in run. */
unsigned nfree;

/* Per region allocated/deallocated bitmap. */
// 记录 run 中 region 的分配情况,每 bit 对应1个 region
bitmap_t bitmap[BITMAP_GROUPS_MAX];
};

现在看一下如何从run中分配:

  • 首先设置bitmap中第一个未设置的并返回,也就是要分配的region id
  • 返回对应的region,具体的地址计算后面再来看
  • nfree--
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
JEMALLOC_INLINE_C void *
arena_run_reg_alloc(arena_run_t *run, arena_bin_info_t *bin_info)
{
void *ret;
unsigned regind;
arena_chunk_map_misc_t *miscelm;
void *rpages;

assert(run->nfree > 0);
assert(!bitmap_full(run->bitmap, &bin_info->bitmap_info));

// set first unset 并 返回
regind = bitmap_sfu(run->bitmap, &bin_info->bitmap_info);
miscelm = arena_run_to_miscelm(run);
rpages = arena_miscelm_to_rpages(miscelm);
// 获取 run 中对应的 region,返回
ret = (void *)((uintptr_t)rpages + (uintptr_t)bin_info->reg0_offset +
(uintptr_t)(bin_info->reg_interval * regind));
run->nfree--;
return (ret);
}

释放是相反的过程:

  • 首先获取该ptrregion id
  • unset对应的bitmap
  • nfree++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
JEMALLOC_INLINE_C void
arena_run_reg_dalloc(arena_run_t *run, void *ptr)
{
arena_chunk_t *chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(run);
size_t pageind = ((uintptr_t)ptr - (uintptr_t)chunk) >> LG_PAGE;
size_t mapbits = arena_mapbits_get(chunk, pageind);
szind_t binind = arena_ptr_small_binind_get(ptr, mapbits);
arena_bin_info_t *bin_info = &arena_bin_info[binind];
unsigned regind = arena_run_regind(run, bin_info, ptr);

assert(run->nfree < bin_info->nregs);
/* Freeing an interior pointer can cause assertion failure. */
assert(((uintptr_t)ptr -
((uintptr_t)arena_miscelm_to_rpages(arena_run_to_miscelm(run)) +
(uintptr_t)bin_info->reg0_offset)) %
(uintptr_t)bin_info->reg_interval == 0);
assert((uintptr_t)ptr >=
(uintptr_t)arena_miscelm_to_rpages(arena_run_to_miscelm(run)) +
(uintptr_t)bin_info->reg0_offset);
/* Freeing an unallocated pointer can cause assertion failure. */
assert(bitmap_get(run->bitmap, &bin_info->bitmap_info, regind));

bitmap_unset(run->bitmap, &bin_info->bitmap_info, regind);
run->nfree++;
}

bin

jemallocsmall size classes都使用slab算法分配,所以会有多种不同的runbin管理相同类型的runbin_info记录了对应的run的内存格式。

bin_info_init()根据size classes初始化small class bins的信息arena_bin_info[NBINS]。数组中每个元素记录了bin对应的run的信息:

  • reg_size:每个region的大小,对应着small size classes大小
  • run_sizebin对应的整个run的大小,page_size的倍数,一般为reg_sizepage_size的最小公倍数,但是不能超过arena_maxrun
  • nregs:该runregion的个数
  • reg0_offset:第一个region距离run起始地址的偏移

还有一些其他的信息,主要用于debug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
* Read-only information associated with each element of arena_t's bins array
* is stored separately, partly to reduce memory usage (only one copy, rather
* than one per arena), but mainly to avoid false cacheline sharing.
*
* Each run has the following layout:
*
* /--------------------\
* | pad? |
* |--------------------|
* | redzone |
* reg0_offset | region 0 |
* | redzone |
* |--------------------| \
* | redzone | |
* | region 1 | > reg_interval
* | redzone | /
* |--------------------|
* | ... |
* | ... |
* | ... |
* |--------------------|
* | redzone |
* | region nregs-1 |
* | redzone |
* |--------------------|
* | alignment pad? |
* \--------------------/
*
* reg_interval has at least the same minimum alignment as reg_size; this
* preserves the alignment constraint that sa2u() depends on. Alignment pad is
* either 0 or redzone_size; it is present only if needed to align reg0_offset.
*/

bin的结构如下:

  • runcur:指向有空闲region且地址最低的run
  • runs:红黑树,管理有空闲regionrun,按照run的地址排序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct arena_bin_s {
/*
* All operations on runcur, runs, and stats require that lock be
* locked. Run allocation/deallocation are protected by the arena lock,
* which may be acquired while holding one or more bin locks, but not
* vise versa.
*/
malloc_mutex_t lock;

/*
* Current run being used to service allocations of this bin's size
* class.
*/
arena_run_t *runcur;

/*
* Tree of non-full runs. This tree is used when looking for an
* existing run when runcur is no longer usable. We choose the
* non-full run that is lowest in memory; this policy tends to keep
* objects packed well, and it can also help reduce the number of
* almost-empty chunks.
*/
// 红黑树 non-full runs,按照地址排序
arena_run_tree_t runs;

/* Bin statistics. */
malloc_bin_stats_t stats;
};

来看下如何从bin中分配run:

  • runcur != NULL,则从该run分配
  • runs中查找地址最低的run,分配

当从run中释放region时,根据run的状态会有不同的操作:

  • 若该run原先已满,则会调用arena_bin_lower_run()设置为runcur或者插入到runs
  • 若该run之前有空闲空间,说明是runcur或已经在runs中,此时无特殊处理
  • 若该run释放region后已空,则会将该runbin解除关系,返回到arena中,后面再来看这种情况

bin->runcur指向的永远是地址最低的run,目的是减少active pages

chunk

chunkjemalloc中申请内存的基本单位。arena中有如下元素管理chunk:

  • spare:缓存最近空闲的chunk,为了避免频繁的chunk分配和释放
  • chunks_szad_cached/chunks_ad_cachedextent_node_t的红黑树,缓存之前分配的、空闲的chunk,数据一样,只是顺序不同:
  • szad:按照sizeaddress排序
  • ad:按照address排序
  • chunks_szad_retained/chunks_ad_retainedextent_node_t的红黑树,缓存已经被释放的、空闲的chunk,在后面purge阶段会看到

现在来看一下chunk的申请过程:

  • spare != NULL,则返回spare
  • cached中查找
  • retained中查找
  • 调用chunk_alloc_mmap()新分配一个chunk

第2、3步会调用chunk_recycle()实施伙伴算法的分裂过程:从对应的树中进行分配指定大小的chunkchunk起始地址会按chunk_size对齐。因为需要对齐且大小不一定相等,所以前后需要进行裁剪,leadsizetrailsize也会重新插入树中,留待之后的分配使用。

相对应的,chunk释放过程如下:

  • spare == NULL,则设置为spare
  • 将原先的spare插入到cached中,设置为spare

第2步会调用chunk_record()实施伙伴算法的合并过程:会查找连续地址空间的前后的chunk在不在树中,如果在的话会进行合并,然后再插入到树中。

arena_chunk_t

runchunk中分配,同样采用伙伴算法。一整个chunk的内存分为4个部分:

  • extent_node_t:记录chunk的状态,用于之后管理chunk
  • arena_chunk_map_bits_t:一一对应chunk内每个page,记录从chunk分配出去的run的大小和信息、记录page的分配状态。
  • arena_chunk_map_misc_t:一一对应chunk内每个page对应的run
  • page:大小为 4096B

这些记录chunk信息的header存放在每个chunk起始地址处,所以会占用掉部分内存。这些headerchunk中的page个数有关,而chunk中减去header的内存又和page的个数有关,所以arena_boot()中使用循环计算header占用的page个数(map_bias):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* Compute the header size such that it is large enough to contain the
* page map. The page map is biased to omit entries for the header
* itself, so some iteration is necessary to compute the map bias.
*
* 1) Compute safe header_size and map_bias values that include enough
* space for an unbiased page map.
* 2) Refine map_bias based on (1) to omit the header pages in the page
* map. The resulting map_bias may be one too small.
* 3) Refine map_bias based on (2). The result will be >= the result
* from (2), and will always be correct.
*/
map_bias = 0;
for (i = 0; i < 3; i++) {
size_t header_size = offsetof(arena_chunk_t, map_bits) +
((sizeof(arena_chunk_map_bits_t) +
sizeof(arena_chunk_map_misc_t)) * (chunk_npages-map_bias));
map_bias = (header_size + PAGE_MASK) >> LG_PAGE;
}

header使用连续内存存放而不是每个page头部存放有如下好处:

  • 提高header的缓存局部性
  • 提高page中分配出去的缓存局部性
  • 可以减少rss占用,因为操作系统按照page管理虚拟地址,若每个空闲page都有些header占用,会使一整个page驻留在内存中

arena_chunk_map_bits_t在64位系统上,共有64bits,记录了chunk内每个page的分配情况,这些信息用于快速的查找metadata。对于不同状态的page有不同的格式:

  • 未分配page:连续、未分配的page会作为一个整体,由起始page对应的run进行管理。首尾page对应的arena_chunk_map_bits_t中会设置连续的空闲page的数量,中间的page不设置。同时,管理这些空闲pagerun会插入到runs_avail中,该run的大小就是整个空闲page的大小(从arena_chunk_map_bits_t中获取)
  • 已分配的run对应的page:每个page会设置该pagerun中第几个page(run page offset),并且设置run对应的bin id
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/*
* Run address (or size) and various flags are stored together. The bit
* layout looks like (assuming 32-bit system):
*
* ???????? ???????? ???nnnnn nnndumla
*
* ? : Unallocated: Run address for first/last pages, unset for internal
* pages.
* Small: Run page offset.
* Large: Run page count for first page, unset for trailing pages.
* n : binind for small size class, BININD_INVALID for large size class.
* d : dirty?
* u : unzeroed?
* m : decommitted?
* l : large?
* a : allocated?
*
* Following are example bit patterns for the three types of runs.
*
* p : run page offset (这个page是run中第几个page (offset))
* s : run size (连续的空闲 page 个数)
* n : binind for size class; large objects set these to BININD_INVALID(该 page 对应的 bin Id)
* x : don't care
* - : 0
* + : 1
* [DUMLA] : bit set
* [dumla] : bit unset
*
* Unallocated (clean):
* ssssssss ssssssss sss+++++ +++dum-a
* xxxxxxxx xxxxxxxx xxxxxxxx xxx-Uxxx
* ssssssss ssssssss sss+++++ +++dUm-a
*
* Unallocated (dirty):
* ssssssss ssssssss sss+++++ +++D-m-a
* xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
* ssssssss ssssssss sss+++++ +++D-m-a
*
* Small:
* pppppppp pppppppp pppnnnnn nnnd---A
* pppppppp pppppppp pppnnnnn nnn----A
* pppppppp pppppppp pppnnnnn nnnd---A
*
* Large:
* ssssssss ssssssss sss+++++ +++D--LA
* xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
* -------- -------- ---+++++ +++D--LA
*
* Large (sampled, size <= LARGE_MINCLASS):
* ssssssss ssssssss sssnnnnn nnnD--LA
* xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
* -------- -------- ---+++++ +++D--LA
*
* Large (not sampled, size == LARGE_MINCLASS):
* ssssssss ssssssss sss+++++ +++D--LA
* xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
* -------- -------- ---+++++ +++D--LA
*/

arena_chunk_map_misc_t顾名思义,有很多用途,主要用于记录runmetadatarun大小是page size倍数,每个run会由起始page对应的arena_chunk_map_misc_t中的run管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct arena_chunk_map_misc_s {
/*
* Linkage for run trees. There are two disjoint uses:
*
* 1) arena_t's runs_avail tree.
* 2) arena_run_t conceptually uses this linkage for in-use non-full
* runs, rather than directly embedding linkage.
*/
rb_node(arena_chunk_map_misc_t) rb_link;

union {
/* Linkage for list of dirty runs. */
arena_runs_dirty_link_t rd;

/* Profile counters, used for large object runs. */
union {
void *prof_tctx_pun;
prof_tctx_t *prof_tctx;
};

/* Small region run metadata. */
arena_run_t run;
};
};

接下来看一下如何从chunk中分配run

第一个chunk是调用chunk_alloc_mmap()分配的,然后调用arena_mapbits_unallocated_set()设置首尾page对应的arena_chunk_map_bits_t,然后将整个空闲chunk作为大小为arena_maxrun的空闲run插入到runs_avail中:

然后调用arena_run_split_small()将该run分解为对应的bin管理的run:

  • run中分配出需要的page,多余的page会设置首尾page对应的map_bits,再次插入到avail_runs中留待后续分配
  • 设置分配出去的run对应的map_bits,返回分配出去的第一个page对应的misc中的run
  • 之后run就会有对应的bin进行管理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
static bool
arena_run_split_small(arena_t *arena, arena_run_t *run, size_t size,
szind_t binind)
{
arena_chunk_t *chunk;
arena_chunk_map_misc_t *miscelm;
size_t flag_dirty, flag_decommitted, run_ind, need_pages, i;

assert(binind != BININD_INVALID);

chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(run);
miscelm = arena_run_to_miscelm(run);
run_ind = arena_miscelm_to_pageind(miscelm);
flag_dirty = arena_mapbits_dirty_get(chunk, run_ind);
flag_decommitted = arena_mapbits_decommitted_get(chunk, run_ind);
// size 是 bin 对应的 run_size
need_pages = (size >> LG_PAGE);
assert(need_pages > 0);

if (flag_decommitted != 0 && arena->chunk_hooks.commit(chunk, chunksize,
run_ind << LG_PAGE, size, arena->ind))
return (true);

arena_run_split_remove(arena, chunk, run_ind, flag_dirty,
flag_decommitted, need_pages);

// 设置分配出去的 page 对应的 map_bits
for (i = 0; i < need_pages; i++) {
size_t flag_unzeroed = arena_mapbits_unzeroed_get(chunk,
run_ind+i);
arena_mapbits_small_set(chunk, run_ind+i, i, binind,
flag_unzeroed);
if (config_debug && flag_dirty == 0 && flag_unzeroed == 0)
arena_run_page_validate_zeroed(chunk, run_ind+i);
}
JEMALLOC_VALGRIND_MAKE_MEM_UNDEFINED((void *)((uintptr_t)chunk +
(run_ind << LG_PAGE)), (need_pages << LG_PAGE));
return (false);
}

// 从 run_ind 对应 run 中分配出 need_pages,剩余的再次插入到 avail_runs 中
static void
arena_run_split_remove(arena_t *arena, arena_chunk_t *chunk, size_t run_ind,
size_t flag_dirty, size_t flag_decommitted, size_t need_pages)
{
size_t total_pages, rem_pages;

assert(flag_dirty == 0 || flag_decommitted == 0);

total_pages = arena_mapbits_unallocated_size_get(chunk, run_ind) >>
LG_PAGE;
assert(arena_mapbits_dirty_get(chunk, run_ind+total_pages-1) ==
flag_dirty);
assert(need_pages <= total_pages);
rem_pages = total_pages - need_pages;

arena_avail_remove(arena, chunk, run_ind, total_pages);
if (flag_dirty != 0)
arena_run_dirty_remove(arena, chunk, run_ind, total_pages);
arena_cactive_update(arena, need_pages, 0);
arena->nactive += need_pages;

/* Keep track of trailing unused pages for later use. */
if (rem_pages > 0) {
size_t flags = flag_dirty | flag_decommitted;
size_t flag_unzeroed_mask = (flags == 0) ? CHUNK_MAP_UNZEROED :
0;

// 设置 run 对应的 page 的信息,设置开头和结尾的 page 对应的 map_bits 的
// 未分配内存大小
arena_mapbits_unallocated_set(chunk, run_ind+need_pages,
(rem_pages << LG_PAGE), flags |
(arena_mapbits_unzeroed_get(chunk, run_ind+need_pages) &
flag_unzeroed_mask));
arena_mapbits_unallocated_set(chunk, run_ind+total_pages-1,
(rem_pages << LG_PAGE), flags |
(arena_mapbits_unzeroed_get(chunk, run_ind+total_pages-1) &
flag_unzeroed_mask));
if (flag_dirty != 0) {
arena_run_dirty_insert(arena, chunk, run_ind+need_pages,
rem_pages);
}
arena_avail_insert(arena, chunk, run_ind+need_pages, rem_pages);
}
}

bin中有完全空闲的run时,会返回给arena管理:

  • 调用arena_dissociate_bin_run()解除该runbin的关系:
  • 若该runbin->runcur,设置bin->runcur =NULL
  • bin->runs中移除
  • 调用arena_run_coalesce()尝试合并相邻的空闲run
  • run插入到avail_runs
  • 若该run大小已经达到arena_maxrun,表明整个chunk都是空闲的,调用arena_chunk_dalloc()释放run

2 - Basic structures

相对于Dl, Je引入了更多更复杂的分配结构,如arena,chunk,bin,run,region,tcache等等。其中有些类似Dl,但更多的具有不同含义,本节将对它们做一一介绍。

2.1 - Overview

首先,先给出一个整体的概念。Je对内存划分按照如下由高到低的顺序,

  1. 内存是由一定数量的arenas进行管理。
  2. 一个arena被分割成若干chunks,后者主要负责记录bookkeeping.
  3. chunk内部又包含着若干runs,作为分配小块内存的基本单元。
  4. run由pages组成,最终被划分成一定数量的regions,
  5. 对于small size的分配请求来说,这些region就相当于user memory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    Arena #0
+----------------------------------------------------------------------------+
| |
| Chunk #0 Chunk #1 |
| +---------------------------------+ +---------------------------------+ |
| | | | | |
| | Run #0 Run #1 | | Run #0 Run #1 | |
| | +-------------+ +-------------+ | | +-------------+ +-------------+ | |
| | | | | | | | | | | | | |
| | | Page | | Page | | | | Page | | Page | | |
| | | +---------+ | | +---------+ | | | | +---------+ | | +---------+ | | |
| | | | | | | | | | | | | | | | | | | | | |
| | | | Regions | | | | Regions | | | | | | Regions | | | | Regions | | | |
| | | |[] [] [] | | | |[] [] [] | | | | | |[] [] [] | | | |[] [] [] | | | |
| | | | | | | | | | | | | | | | | | | | | |
| | | +---------+ | | +---------+ | | | | +---------+ | | +---------+ | | |
| | | | | | | | | | | | | |
| | | Page | | Page | | | | Page | | Page | | |
| | | +---------+ | | +---------+ | | | | +---------+ | | +---------+ | | |
| | | | | | | | | | | | | | | | | | | | | |
| | | | ... | | | | ... | | | | | | ... | | | | ... | | | |
| | | +---------+ | | +---------+ | | | | +---------+ | | +---------+ | | |
| | +-------------+ +-------------+ | | +-------------+ +-------------+ | |
| +---------------------------------+ +---------------------------------+ |
+----------------------------------------------------------------------------+

2.2 - Arena (arena_t)

如前所述,Arena是Je中最大或者说最顶层的基础结构。这个概念其实上是针对”对称多处理机(SMP)”产生的。在SMP中,导致性能劣化的一个重要原因在于”false sharing”导致cache-line失效。

为了解决cache-line共享问题,同时保证更少的内部碎片(internal fragmentation),Je使用了arena。

2.2.1 - CPU Cache-Line

Cache是嵌入到cpu内部的一组SRAM,速度是主存的N倍,但造价较高,因此一般容量很小。有些cpu设计了容量逐级逐渐增大的多级cache,但速度逐级递减。多级处理更复杂,但原理类似,为了简化,仅讨论L1 data cache。

cache同主存进行数据交换有一个最小粒度,称为cache-line,通常这个值为64。例如在一个ILP32的机器上,一次cache交换可以读写连续16个int型数据。因此当访问数组#0元素时,后面15个元素也被同时”免费”读到了cache中,这对于数组的连续访问是非常有利的。

然而这种免费加载不总是会带来好处,有时候甚至起到反效果,所谓”false sharing”。试想两个线程A和B分别执行在不同的cpu核心中并分别操作各自上下文中的变量x和y,如果因为某种原因(比如x,y可能位于同一个class内部,或者分别是数组中的两个相邻元素),两者位于相同的cache-line中,则在两个core的L1 cache里都存在x和y的副本。

倘若线程A修改了x的值,就会导致在B中的x与A中看到的不一致。尽管这个变量x对B可能毫无用处,但cpu为了保证前后的正确和一致性,只能判定core #1的cache失效。因此core #0必须将cache-line回写到主存,然后core #1再重新加载cache-line,反之亦然。如果恰好两个线程交替操作同一cache-line中的数据,将对cache将造成极大的损害,导致严重的性能退化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
+-----------------------+        +-----------------------+
| core #0 | | core #1 |
| | | |
| +----------+ | | +----------+ |
| | ThreadA | | | | ThreadB | |
| +----------+ | | +----------+ |
| | | | | |
| +---+ | | | |
| | | | | |
| v D-cache | | v D-cache |
| +-----------------+ | | +-----------------+ |
| | x'| y | ... ... | <---+ +---> | x | y'| ... ... | |
| |-----------------| | | | | |-----------------| |
| | ... ... | | | | | | ... ... | |
| | ... ... | | | | | | ... ... | |
| | ... ... | | | | | | ... ... | |
| +-----------------+ | | | | +-----------------+ |
+-----------------------+ | | +-----------------------+
| |
+------+ |
| |
v v
memory +-----------------------------
| ... | x | y | ... ...
+-----------------------------

说到底,从程序的角度看,变量是独立的地址单元,但在CPU看来则是以cache-line为整体的单元。单独的变量竞争可以在代码中增加同步来解决,而cache-line的竞争是透明的,不可控的,只能被动由CPU仲裁。这种观察角度和处理方式的区别,正是false sharing的根源。

2.2.2 - Arena原理

回到memory allocator的话题上。对于一个多线程+多CPU核心的运行环境,传统分配器中大量开销被浪费在lock contention和false sharing上,随着线程数量和核心数量增多,这种分配压力将越来越大。

针对多线程,一种解决方法是将一把global lock分散成很多与线程相关的lock。而针对多核心,则要尽量把不同线程下分配的内存隔离开,避免不同线程使用同一个cache-line的情况。按照上面的思路,一个较好的实现方式就是引入arena。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+---------+     +---------+     +---------+     +---------+     +---------+
| threadA | | threadB | | threadC | | threadD | | threadE |
+---------+ +---------+ +---------+ +---------+ +---------+
| | | | |
| +---------------|---------------|---------+ |
+------------------+ +-------+ +------+ | |
+-----------------|----|----------------|----------------|-----+
| | | | |
v v v v v
+----------+ +----------+ +----------+ +----------+
| | | | | | | |
| Arena #0 | | Arena #1 | | Arena #2 | | Arena #3 |
| | | | | | | |
+----------+ +----------+ +----------+ +----------+

Je将内存划分成若干数量的arenas,线程最终会与某一个arena绑定。比如上图中的threadA和B就分别绑定到arena #1和#3上。由于两个arena在地址空间上几乎不存在任何联系,就可以在无锁的状态下完成分配。同样由于空间不连续,落到同一个cache-line中的几率也很小,保证了各自独立。

由于arena的数量有限,因此不能保证所有线程都能独占arena,比如,图中threadA和C就都绑定到了arena1上。分享同一个arena的所有线程,由该arena内部的lock保持同步。

Je将arena保存到一个数组中,该数组全局记录了所有arenas,

1
arena_t            **arenas;

事实上,该数组是动态分配的,arenas仅仅是一个数组指针。默认情况下arenas数组的长度与如下变量相关,

1
2
3
unsigned    narenas_total;
unsigned narenas_auto;
size_t opt_narenas = 0;

而它们又与当前cpu核心数量相关。核心数量记录在另外一个全局变量ncpus里,

1
unsigned    ncpus;

如果ncpus等于1,则有且仅有一个arena,如果大于1,则默认arenas的数量为ncpus的四倍。即双核下8个arena,四核下16个arena,依此类推。

1
2
3
4
(gdb) p ncpus
$20 = 4
(gdb) p narenas_total
$21 = 16

jemalloc变体很多,不同变体对arenas的数量有所调整,比如firefox中arena固定为1,而android被限定为最大不超过2. 这个限制被写到android jemalloc的mk文件中。

2.2.3 - choose_arena

最早引入arena并非由Je首创,但早期线程与arena绑定是通过hash线程id实现的,相对来说随机性比较强。Je改进了绑定的算法,使之更加科学合理。

Je中线程与arena绑定由函数choose_arena完成,被绑定的arena记录在该线程的tls中,

1
2
3
4
5
6
7
8
9
10
11
12
JEMALLOC_INLINE arena_t *
choose_arena(arena_t *arena)
{
......
// xf: 通常情况下线程所绑定的arena记录在arenas_tls中
if ((ret = *arenas_tsd_get()) == NULL) {
// xf: 如果当前thread未绑定arena,则为其指定一个,并保存到tls
ret = choose_arena_hard();
}

return (ret);
}

初次搜索arenas_tsd_get可能找不到该函数在何处被定义。实际上,Je使用了一组宏,来生成一个函数族,以达到类似函数模板的目的。tsd相关的函数族被定义在tsd.h中。

  1. malloc_tsd_protos - 定义了函数声明,包括初始化函数boot, get/set函数
  2. malloc_tsd_externs - 定义变量声明,包括tls,初始化标志等等
  3. malloc_tsd_data - tls变量定义
  4. malloc_tsd_funcs - 定义了1中声明函数的实现。

arena tsd相关的函数和变量声明如下,

1
2
3
4
malloc_tsd_protos(JEMALLOC_ATTR(unused), arenas, arena_t *)
malloc_tsd_externs(arenas, arena_t *)
malloc_tsd_data(, arenas, arena_t *, NULL)
malloc_tsd_funcs(JEMALLOC_ALWAYS_INLINE, arenas, arena_t *, NULL, arenas_cleanup)

当线程还未与任何arena绑定时,会进一步通过choose_arena_hard寻找一个合适的arena进行绑定。Je会遍历arenas数组,并按照优先级由高到低的顺序挑选,

  1. 如果找到当前线程绑定数为0的arena,则优先使用它。
  2. 如果当前已初始化arena中没有线程绑定数为0的,则优先使用剩余空的数组位置构造一个新的arena. 需要说明的是,arenas数组遵循lazy create原则,初始状态整个数组只有0号元素是被初始化的,其他的slot位置都是null指针。因此随着新的线程不断创造出来,arena数组也被逐渐填满。
  3. 如果1,2两条都不满足,则选择当前绑定数最小的,且slot位置更靠前的一个arena。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
arena_t * choose_arena_hard(void)
{
......
if (narenas_auto > 1) {
......
first_null = narenas_auto;
// xf: 循环遍历所有arenas,找到绑定thread数量最小的arena,并记录
// first_null索引值
for (i = 1; i < narenas_auto; i++) {
if (arenas[i] != NULL) {
if (arenas[i]->nthreads <
arenas[choose]->nthreads)
choose = i;
} else if (first_null == narenas_auto) {
first_null = i;
}
}

// xf: 若选定的arena绑定thread为0,或者当前arena数组中已满,则返回
// 被选中的arena
if (arenas[choose]->nthreads == 0
|| first_null == narenas_auto) {
ret = arenas[choose];
} else {
// xf: 否则构造并初始化一个新的arena
ret = arenas_extend(first_null);
}
......
} else {
// xf: 若不存在多于一个arena(单核cpu或人为强制设定),则返回唯一的
// 0号arena
ret = arenas[0];
......
}

// xf: 将已绑定的arena设置到tsd中
arenas_tsd_set(&ret);

return (ret);
}

对比早期的绑定方式,Je的算法显然更加公平,尽可能的让各个cpu核心平分当前线程,平衡负载。

2.2.4 - Arena结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct arena_s {
unsigned ind;
unsigned nthreads;
malloc_mutex_t lock;
arena_stats_t stats;
ql_head(tcache_t) tcache_ql;
uint64_t prof_accumbytes;
dss_prec_t dss_prec;
arena_chunk_tree_t chunks_dirty;
arena_chunk_t *spare;
size_t nactive;
size_t ndirty;
size_t npurgatory;
arena_avail_tree_t runs_avail;
chunk_alloc_t *chunk_alloc;
chunk_dalloc_t *chunk_dalloc;
arena_bin_t bins[NBINS];
};
  • ind: 在arenas数组中的索引值。
  • nthreads: 当前绑定的线程数。
  • lock: 局部arena lock,取代传统分配器的global lock。一般地,如下操作需要arena lock同步,
    • 线程绑定,需要修改nthreads
    • new chunk alloc
    • new run alloc
  • stats: 全局统计,需要打开统计功能。
  • tcache_ql: ring queue,注册所有绑定线程的tcache,作为统计用途,需要打开统计功能。
  • dss_prec: 代表当前chunk alloc时对系统内存的使用策略,分为几种情况,     
1
2
3
4
5
6
typedef enum {
dss_prec_disabled = 0,
dss_prec_primary = 1,
dss_prec_secondary = 2,
dss_prec_limit = 3
} dss_prec_t;

第一个代表禁止使用系统DSS,后两种代表是否优先选用DSS。如果使用primary,则本着先dss->mmap的顺序,否则按照先mmap->dss。默认使用dss_prec_secondary

  • chunks_dirty: rb tree,代表所有包含dirty page的chunk集合。后面在chunk中会详细介绍。
  • spare: 是一个缓存变量,记录最近一次被释放的chunk。当arena收到一个新的chunk alloc请求时,会优先从spare中开始查找,由此提高频繁分配释放时,可能导致内部chunk利用率下降的情况。
  • runs_avail: rb tree,记录所有未被分配的runs,用来在分配new run时寻找合适的available run。一般作为alloc run时的仓库。
  • chunk_alloc/chunk_dalloc: 用户可定制的chunk分配/释放函数,Je提供了默认的版本,chunk_alloc_default/chunk_dalloc_default
  • bins: bins数组,记录不同class size可用free regions的分配信息,后面会详细介绍。

2.3 - Chunk (arena_chunk_t)

chunk是仅次于arena的次级内存结构。如果有了解过Dlmalloc,就会知道在Dl中同样定义了名为’chunk’的基础结构。但这个概念在两个分配器中含义完全不同,Dl中的chunk指代最低级分配单元,而Je中则是一个较大的内存区域。

2.3.1 - overview

从前面arena的数据结构可以发现,它是一个非常抽象的概念,其大小也不代表实际的内存分配量。原始的内存数据既非挂载在arena外部,也并没有通过内部指针引用,而是记录在chunk中。按照一般的思路,chunk包含原始内存数据,又从属于arena,因此后者应该会有一个数组之类的结构以记录所有chunk信息。但事实上同样找不到这样的记录。那Je又如何获得chunk指针呢?

所谓的chunk结构,只是整个chunk的一个header,bookkeeping以及user memory都挂在header外面。另外Je对chunk又做了规定,默认每个chunk大小为4MB,同时还必须对齐到4MB的边界上。

1
#define    LG_CHUNK_DEFAULT    22

这个宏定义了chunk的大小。注意到前缀LG_,代表log即指数部分。Je中所有该前缀的代码都是这个含义,便于通过bit操作进行快速的运算。

有了上述规定,获得chunk就变得几乎没有代价。因为返回给user程序的内存地址肯定属于某个chunk,而该chunk header对齐到4M边界上,且不可能超过4M大小,所以只需要对该地址做一个下对齐就得到chunk指针,如下,

1
2
#define    CHUNK_ADDR2BASE(a)                        \
((void *)((uintptr_t)(a) & ~chunksize_mask))

计算相对于chunk header的偏移量,

1
2
#define    CHUNK_ADDR2OFFSET(a)                        \
((size_t)((uintptr_t)(a) & chunksize_mask))

以及上对齐到chunk边界的计算,

1
2
#define    CHUNK_CEILING(s)                        \
(((s) + chunksize_mask) & ~chunksize_mask)

用图来表示如下,

1
2
3
4
5
6
7
chunk_ptr(4M aligned)                memory for user
| |
v v
+--------------+--------------------------------------------
| chunk header | ... ... | region | ... ...
+--------------+--------------------------------------------
|<------------- offset ------------>|

2.3.2 - Chunk结构

1
2
3
4
5
6
7
8
struct arena_chunk_s {
arena_t *arena;
rb_node(arena_chunk_t) dirty_link;
size_t ndirty;
size_t nruns_avail;
size_t nruns_adjac;
arena_chunk_map_t map[1];
}
  • arena: chunk属于哪个arena
  • dirty_link: 用于rb tree的链接节点。如果某个chunk内部含有任何dirty page,就会被挂载到arena中的chunks_dirty tree上。
  • ndirty: 内部dirty page数量。
  • nruns_avail: 内部available runs数量。
  • nruns_adjac: available runs又分为dirty和clean两种,相邻的两种run是无法合并的,除非其中的dirty runs通过purge才可以。该数值记录的就是可以通过purge合并的run数量。
  • map: 动态数组,每一项对应chunk中的一个page状态(不包含header即map本身的占用)。chunk(包括内部的run)都是由page组成的。
    • page又分为unallocated,small,large三种。
    • unallocated指的那些还未建立run的page。
    • small/large分别指代该page所属run的类型是small/large run。
    • 这些page的分配状态,属性,偏移量,及其他的标记信息等等,都记录在arena_chunk_map_t中。
1
2
3
4
5
6
7
8
9
10
|<--------- map_bias ----------->|
| page | page | ... ... | page |
+-----------------------------------------------------------------------+
| chunk_header | chunk map | page #0 | page #1 | ... | page #n |
| ... ... | [0] [1] ... [n] | | | | |
+-----------------|---|-------|-----------------------------------------+
| | | ^ ^ ^
+---|-------|-------+ | |
+-------|------------------+ |
+ -----------------------------------+

至于由chunk header和chunk map占用的page数量,保存在map_bias变量中。该变量是Je在arena boot时通过迭代算法预先计算好的,所有chunk都是相同的。迭代方法如下,

  1. 第一次迭代初始map_bias等于0,计算最大可能大小,即header_size + chunk_npages * map_size获得header+map需要的page数量,结果肯定高于最终的值。
  2. 第二次将之前计算的map_bias迭代回去,将最大page数减去map_bias数,重新计算header+map大小,由于第一次迭代map_bias过大,第二次迭代必定小于最终结果。
  3. 第三次再将map_bias迭代回去,得到最终大于第二次且小于第一次的计算结果。

相关代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
void
arena_boot(void)
{
......
map_bias = 0;
for (i = 0; i < 3; i++) {
header_size = offsetof(arena_chunk_t, map) +
(sizeof(arena_chunk_map_t) * (chunk_npages-map_bias));
map_bias = (header_size >> LG_PAGE) + ((header_size & PAGE_MASK)
!= 0);
}
......
}

2.3.3 - chunk map (arena_chunk_map_t)

chunk记录page状态的结构为arena_chunk_map_t,为了节省空间,使用了bit压缩存储信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct arena_chunk_map_s {
#ifndef JEMALLOC_PROF
union {
#endif
union {
rb_node(arena_chunk_map_t) rb_link;
ql_elm(arena_chunk_map_t) ql_link;
} u;
prof_ctx_t *prof_ctx;
#ifndef JEMALLOC_PROF
};
#endif
size_t bits;
}

chunk map内部包含两个link node,分别可以挂载到rb tree或环形队列上,同时为了节省空间又使用了union。由于run本身也是由连续page组成的,因此chunk map除了记录page状态之外,还负责run的基址检索。

举例来说,Je会把所有已分配run记录在内部rb tree上以快速检索,实际地操作是将该run中第一个page对应的chunk_map作为rb node挂载到tree上。检索时也是先找出将相应的chunk map,再进行地址转换得到run的基址。

按照通常的设计思路,我们可能会把run指针作为节点直接保存到rb tree中。但Je中的设计明显要更复杂。究其原因,如果把link node放到run中,后果是bookkeeping和user memory将混淆在一起,这对于分配器的安全性是很不利的。包括Dl在内的传统分配器都具有这样的缺陷。而如果单独用link node记录run,又会造成空间浪费。

正因为Je中无论是chunk还是run都是连续page组成,所以用首个page对应的chunk map就能同时代表该run的基址。

Je中通常用mapelm换算出pageind,再将pageind << LG_PAGE + chunk_base,就能得到run指针,代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
arena_chunk_t *run_chunk = CHUNK_ADDR2BASE(mapelm);
size_t pageind = arena_mapelm_to_pageind(mapelm);
run = (arena_run_t *)((uintptr_t)run_chunk + (pageind <<
LG_PAGE));

JEMALLOC_INLINE_C size_t
arena_mapelm_to_pageind(arena_chunk_map_t *mapelm)
{
uintptr_t map_offset =
CHUNK_ADDR2OFFSET(mapelm) - offsetof(arena_chunk_t, map);

return ((map_offset / sizeof(arena_chunk_map_t)) + map_bias);
}

chunk map对page状态描述都压缩记录到bits中,由于内容较多,直接引用Je代码中的注释,

  • 下面是一个假想的ILP32系统下的bits layout,
  • ???????? ???????? ????nnnn nnnndula
  • “?”的部分分三种情况,分别对应unallocated, small和large.
    • Unallocated: 首尾page写入该run的地址,而内部page则不做要求。
    • Small: 全部是page的偏移量。
    • Large: 首page是run size,后续的page不做要求。
  • n : 对于small run指其所在bin的index,对large run写入BININD_INVALID.
  • d : dirty?
  • u : unzeroed?
  • l : large?
  • a : allocated?

  • 下面是对三种类型的run page做的举例,

    • p : run page offset
    • s : run size
    • n : binind for size class; large objects set these to BININD_INVALID
    • x : don’t care
      • : 0
      • : 1
    • [DULA] : bit set
    • [dula] : bit unset
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Unallocated (clean):
ssssssss ssssssss ssss++++ ++++du-a
xxxxxxxx xxxxxxxx xxxxxxxx xxxx-Uxx
ssssssss ssssssss ssss++++ ++++dU-a

Unallocated (dirty):
ssssssss ssssssss ssss++++ ++++D--a
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
ssssssss ssssssss ssss++++ ++++D--a

Small:
pppppppp pppppppp ppppnnnn nnnnd--A
pppppppp pppppppp ppppnnnn nnnn---A
pppppppp pppppppp ppppnnnn nnnnd--A

Small page需要注意的是,这里代表的p并非是一个固定值,而是该page相对于其所在run的第一个page的偏移量,比如可能是这样,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  00000000 00000000 0000nnnn nnnnd--A
00000000 00000000 0001nnnn nnnn---A
00000000 00000000 0010nnnn nnnn---A
00000000 00000000 0011nnnn nnnn---A
...
00000000 00000001 1010nnnn nnnnd--A

Large:
ssssssss ssssssss ssss++++ ++++D-LA
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
-------- -------- ----++++ ++++D-LA

Large (sampled, size <= PAGE):
ssssssss ssssssss ssssnnnn nnnnD-LA

Large (not sampled, size == PAGE):
ssssssss ssssssss ssss++++ ++++D-LA

为了提取/设置map bits内部的信息,Je提供了一组函数,这里列举两个最基本的,剩下的都是读取mapbits后做一些位运算而已,

读取mapbits

1
2
3
4
5
JEMALLOC_ALWAYS_INLINE size_t
arena_mapbits_get(arena_chunk_t *chunk, size_t pageind)
{
return (arena_mapbitsp_read(arena_mapbitsp_get(chunk, pageind)));
}

根据pageind获取对应的chunk map

1
2
3
4
5
6
JEMALLOC_ALWAYS_INLINE arena_chunk_map_t *
arena_mapp_get(arena_chunk_t *chunk, size_t pageind)
{
......
return (&chunk->map[pageind-map_bias]);
}

2.4 - Run (arena_run_t)

如同在2.1节所述,在Je中run才是真正负责分配的主体(前提是对small region来说)。run的大小对齐到page size上,并且在内部划分成大小相同的region。当有外部分配请求时,run就会从内部挑选一个free region返回。可以认为run就是small region仓库。

2.4.1 - Run结构

1
2
3
4
5
struct arena_run_s {
arena_bin_t *bin;
uint32_t nextind;
unsigned nfree;
};

run的结构非常简单,但同chunk类似,所谓的arena_run_t不过是整个run的header部分。

  • bin: 与该run相关联的bin。每个run都有其所属的bin,详细内容在之后介绍。
  • nextind: 记录下一个clean region的索引。
  • nfree: 记录当前空闲region数量。

除了header部分之外,run的真实layout如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
              /--------------------\
| arena_run_t header |
| ... |
bitmap_offset | bitmap |
| ... |
|--------------------|
| redzone |
reg0_offset | region 0 |
| redzone |
|--------------------| \
| redzone | |
| region 1 | > reg_interval
| redzone | /
|--------------------|
| ... |
| ... |
| ... |
|--------------------|
| redzone |
| region nregs-1 |
| redzone |
|--------------------|
| alignment pad? |
\--------------------/

正如chunk通过chunk map记录内部所有page状态一样,run通过在header后挂载bitmap来记录其内部的region状态。bitmap之后是regions区域。内部region大小相等,且在前后都有redzone保护(需要在设置里打开redzone选项)。

简单来说,run就是通过查询bitmap来找到可用的region。而传统分配器由于使用boundary tag,空闲region一般是被双向链表管理的。相比之下,传统方式查找速度更快,也更简单。缺点之前也提到过,安全和稳定性都存在缺陷。从这一点可以看到,Je在设计思路上将bookkeeping和user memory分离是贯穿始终的原则,更甚于对性能的影响(事实上这点影响在并发条件下被大大赚回来了).

2.4.2 - size classes

内存分配器对内部管理的region往往按照某种特殊规律来分配。比如Dl将内存划分成small和large两种类型。small类型从8字节开始每8个字节为一个分割直至256字节。而large类型则从256字节开始,挂载到dst上。这种划分方式有助于分配器对内存进行有效的管理和控制,让已分配的内存更加紧实(tightly packed),以降低外部碎片率。

Je进一步优化了分配效率。采用了类似于”二分伙伴(Binary Buddy)算法”的分配方式。在Je中将不同大小的类型称为size class。

在Je源码的size_classes.h文件中,定义了不同体系架构下的region size。该文件实际是通过名为size_classes.sh的shell script自动生成的。script按照四种不同量纲定义来区分各个体系平台的区别,然后将它们做排列组合,就可以兼容各个体系。这四种量纲分别是,

  • LG_SIZEOF_PTR: 代表指针长度,ILP32下是2, LP64则是3.
  • LG_QUANTUM: 量子,binary buddy分得的最小单位。除了tiny size,其他的size classes都是quantum的整数倍大小。
  • LG_TINY_MIN: 是比quantum更小的size class,且必须对齐到2的指数倍上。它是Je可分配的最小的size class.
  • LG_PAGE: 就是page大小

根据binary buddy算法,Je会将内存不断的二平分,每一份称作一个group。同一个group内又做四等分。例如,一个典型的ILP32, tiny等于8byte, quantum为16byte,page为4096byte的系统,其size classes划分是这样的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#if (LG_SIZEOF_PTR == 2 && LG_TINY_MIN == 3 && LG_QUANTUM == 4 && LG_PAGE == 12)
#define SIZE_CLASSES \
index, lg_grp, lg_delta, ndelta, bin, lg_delta_lookup \
SC( 0, 3, 3, 0, yes, 3) \
\
SC( 1, 3, 3, 1, yes, 3) \
SC( 2, 4, 4, 1, yes, 4) \
SC( 3, 4, 4, 2, yes, 4) \
SC( 4, 4, 4, 3, yes, 4) \
\
SC( 5, 6, 4, 1, yes, 4) \
SC( 6, 6, 4, 2, yes, 4) \
SC( 7, 6, 4, 3, yes, 4) \
SC( 8, 6, 4, 4, yes, 4) \
\
SC( 9, 7, 5, 1, yes, 5) \
SC( 10, 7, 5, 2, yes, 5) \
SC( 11, 7, 5, 3, yes, 5) \
SC( 12, 7, 5, 4, yes, 5) \

... ...

宏SIZE_CLASSES主要功能就是可以生成几种类型的table。而SC则根据不同的情况被定义成不同的含义。SC传入的6个参数的含义如下,

  • index: 在table中的位置
  • lg_grp: 所在group的指数
  • lg_delta: group内偏移量指数
  • ndelta: group内偏移数
  • bin: 是否由bin记录。small region是记录在bins中
  • lg_delta_lookup: 在lookup table中的调用S2B_#的尾数后缀

因此得到reg_size的计算公式,reg_size = 1 << lg_grp + ndelta << lg_delta根据该公式,可以得到region的范围,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────┬─────────┬───────────────────────────────────────┐
│Category │ Spacing │ Size │
├─────────┼─────────┼───────────────────────────────────────┤
│ │ lg │ [8] │
│ ├─────────┼───────────────────────────────────────┤
│ │ 16 │ [16, 32, 48, ..., 128] │
│ ├─────────┼───────────────────────────────────────┤
│ │ 32 │ [160, 192, 224, 256] │
│ ├─────────┼───────────────────────────────────────┤
│Small │ 64 │ [320, 384, 448, 512] │
│ ├─────────┼───────────────────────────────────────┤
│ │ 128 │ [640, 768, 896, 1024] │
│ ├─────────┼───────────────────────────────────────┤
│ │ 256 │ [1280, 1536, 1792, 2048] │
│ ├─────────┼───────────────────────────────────────┤
│ │ 512 │ [2560, 3072, 3584] │
├─────────┼─────────┼───────────────────────────────────────┤
│Large │ 4 KiB │ [4 KiB, 8 KiB, 12 KiB, ..., 4072 KiB] │
├─────────┼─────────┼───────────────────────────────────────┤
│Huge │ 4 MiB │ [4 MiB, 8 MiB, 12 MiB, ...] │
└─────────┴─────────┴───────────────────────────────────────┘

除此之外,在size_classes.h中还定义了一些常量,

tiny bins的数量

1
2
3
4
5
6
#define    NTBINS            1
``

可以通过lookup table查询的bins数量
```C
#define NLBINS 29

small bins的数量

1
#define    NBINS            28

最大tiny size class的指数

1
#define    LG_TINY_MAXCLASS    3

最大lookup size class,也就是NLBINS - 1个bins

1
#define    LOOKUP_MAXCLASS        ((((size_t)1) << 11) + (((size_t)4) << 9))

最大small size class,也就是NBINS - 1个bins

1
#define    SMALL_MAXCLASS        ((((size_t)1) << 11) + (((size_t)3) << 9))

2.4.3 - size2bin/bin2size

通过SIZE_CLASSES建立的table就是为了在O(1)的时间复杂度内快速进行size2bin或者bin2size切换。同样的技术在Dl中有所体现,来看Je是如何实现的。

size2bin切换提供了两种方式,较快的是通过查询lookup table,较慢的是计算得到。从原理上,只有small size class需要查找bins,但可通过lookup查询的size class数量要小于整个small size class数量。因此,部分size class只能计算得到。在原始Je中统一只采用查表法,但在android版本中可能是考虑减小lookup table size,而增加了直接计算法。

1
2
3
4
5
6
7
8
9
JEMALLOC_ALWAYS_INLINE size_t
small_size2bin(size_t size)
{
......
if (size <= LOOKUP_MAXCLASS)
return (small_size2bin_lookup(size));
else
return (small_size2bin_compute(size));
}

小于LOOKUP_MAXCLASS的请求通过small_size2bin_lookup直接查表。查询的算法是这样的,

1
size_t ret = ((size_t)(small_size2bin_tab[(size-1) >> LG_TINY_MIN]));

也就是说,Je通过一个

1
f(x) = (x - 1) / 2^LG_TINY_MIN

的变换将size映射到lookup table的相应区域。这个table在gdb中可能是这样的,

1
2
3
4
5
6
7
8
(gdb) p  /d small_size2bin
$6 = {0, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 9, 10, 10, 10, 10,
11, 11, 11, 11, 12, 12, 12, 12, 13, 13, 13, 13, 13, 13, 13, 13, 14, 14, 14,
14, 14, 14, 14, 14, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 16,
16, 16, 17 <repeats 16 times>, 18 <repeats 16 times>, 19 <repeats 16 times>,
20 <repeats 16 times>, 21 <repeats 32 times>, 22 <repeats 32 times>,
23 <repeats 32 times>, 24 <repeats 32 times>, 25 <repeats 64 times>,
26 <repeats 64 times>, 27 <repeats 64 times>}

该数组的含义与binary buddy算法是一致的。对应的bin index越高,其在数组中占用的字节数就越多。除了0号bin之外,相邻的4个bin属于同一group,两个group之间相差二倍,因此在数组中占用的字节数也就相差2倍。所以,上面数组的group划分如下,

1
{0}, {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}, {13, 14, 15, 16}, ...

以bin#9为例,其所管辖的范围(128, 160],由于其位于更高一级group,因此相比bin#8在lookup table中多一倍的字节数,假设我们需要查询132,经过映射,

1
(132 - 1) >> 3 = 16

这样可以快速得到其所在的bin #9。如图,

1
2
3
4
5
6
7
8
9
    bin #1     bin #3          132 is HERE!
| | |
v v v
+----------------------------------------------------------------
| 0 | 1 | 2 2 | 3 3 | ... | 8 8 | 9 9 9 9 | ... | 16 ... 16 | ...
+----------------------------------------------------------------
^ ^ ^ ^ ^
| | | | |
bin #0 bin #2 bin #8 bin #9 bin #16

Je巧妙的通过前面介绍CLASS_SIZE宏生成了这个lookup table,代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
JEMALLOC_ALIGNED(CACHELINE)
const uint8_t small_size2bin_tab[] = {
#define S2B_3(i) i,
#define S2B_4(i) S2B_3(i) S2B_3(i)
#define S2B_5(i) S2B_4(i) S2B_4(i)
#define S2B_6(i) S2B_5(i) S2B_5(i)
#define S2B_7(i) S2B_6(i) S2B_6(i)
#define S2B_8(i) S2B_7(i) S2B_7(i)
#define S2B_9(i) S2B_8(i) S2B_8(i)
#define S2B_no(i)
#define SC(index, lg_grp, lg_delta, ndelta, bin, lg_delta_lookup) \
S2B_##lg_delta_lookup(index)
SIZE_CLASSES
#undef S2B_3
#undef S2B_4
#undef S2B_5
#undef S2B_6
#undef S2B_7
#undef S2B_8
#undef S2B_9
#undef S2B_no
#undef SC
};

这里的S2B_xx是一系列宏的嵌套展开,最终对应的就是不同group在lookup table中占据的字节数以及bin索引。相信看懂了前面的介绍就不难理解。

另一方面,大于LOOKUP_MAXCLASS但小于SMALL_MAXCLASS的size class不能查表获得,需要进行计算。简言之,一个bin number是三部分组成的,

bin_number = NTBIN + group_number << LG_SIZE_CLASS_GROUP + mod

即tiny bin数量加上其所在group再加上group中的偏移(0-2)。源码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
JEMALLOC_INLINE size_t
small_size2bin_compute(size_t size)
{
......
{
// xf: lg_floor相当于ffs
size_t x = lg_floor((size<<1)-1);

// xf: 计算size class所在group number
size_t shift = (x < LG_SIZE_CLASS_GROUP + LG_QUANTUM) ? 0 :
x - (LG_SIZE_CLASS_GROUP + LG_QUANTUM);
size_t grp = shift << LG_SIZE_CLASS_GROUP;

size_t lg_delta = (x < LG_SIZE_CLASS_GROUP + LG_QUANTUM + 1)
? LG_QUANTUM : x - LG_SIZE_CLASS_GROUP - 1;

size_t delta_inverse_mask = ZI(-1) << lg_delta;
// xf: 计算剩余mod部分
size_t mod = ((((size-1) & delta_inverse_mask) >> lg_delta)) &
((ZU(1) << LG_SIZE_CLASS_GROUP) - 1);

// xf: 组合计算bin number
size_t bin = NTBINS + grp + mod;
return (bin);
}
}

其中LG_SIZE_CLASS_GROUP是size_classes.h中的定值,代表一个group中包含的bin数量,根据binary buddy算法,该值通常情况下是2。而要找到size class所在的group,与其最高有效位相关。Je通过类似于ffs的函数
首先获得size的最高有效位x

1
size_t x = lg_floor((size<<1)-1);

至于group number,则与quantum size有关。因为除了tiny class, quantum size位于group #0的第一个。因此不难推出,

1
group_number = 2^x / quantum_size / 2^LG_SIZE_CLASS_GROUP

对应代码就是,

1
2
size_t shift = (x < LG_SIZE_CLASS_GROUP + LG_QUANTUM) ? 0 :
x - (LG_SIZE_CLASS_GROUP + LG_QUANTUM);

而对应group起始位置就是,

1
size_t grp = shift << LG_SIZE_CLASS_GROUP;

至于mod部分,与之相关的是最高有效位之后的两个bit。Je在这里经过了复杂的位变换,

1
2
3
size_t lg_delta = (x < LG_SIZE_CLASS_GROUP + LG_QUANTUM + 1) ? LG_QUANTUM : x - LG_SIZE_CLASS_GROUP - 1;
size_t delta_inverse_mask = ZI(-1) << lg_delta;
size_t mod = ((((size-1) & delta_inverse_mask) >> lg_delta)) & ((ZU(1) << LG_SIZE_CLASS_GROUP) - 1);

上面代码直白的翻译,实际上就是要求得如下两个bit,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
                        1 0000
10 0000
11 0000
group #0 100 0000
-------------------------------------------------
+--+
101 0000 - 1 = 1|00| 1111
110 0000 - 1 = 1|01| 1111
111 0000 - 1 = 1|10| 1111
group #1 1000 0000 - 1 = 1|11| 1111
+--+
--------------------------------------------------
+--+
1010 0000 - 1 = 1|00|1 1111
1100 0000 - 1 = 1|01|1 1111
1110 0000 - 1 = 1|10|1 1111
group #2 10000 0000 - 1 = 1|11|1 1111
+--+
--------------------------------------------------

根据这个图示再去看Je的代码就不难理解了。mod的计算结果就是从0-3的数值。

而最终的结果是前面三部分的组合即,

1
size_t bin = NTBINS + grp + mod;

而bin2size查询就简单得多。上一节介绍SIZE_CLASSES时提到过small region的计算公式,只需要根据该公式提前计算出所有bin对应的region size,直接查表即可。

2.5 - bins (arena_bin_t)

run是分配的执行者,而分配的调度者是bin。这个概念同Dl中的bin是类似的,但Je中bin要更复杂一些。直白地说,可以把bin看作non-full run的仓库,bin负责记录当前arena中某一个size class范围内所有non-full run的使用情况。当有分配请求时,arena查找相应size class的bin,找出可用于分配的run,再由run分配region。当然,因为只有small region分配需要run,所以bin也只对应small size class。

与bin相关的数据结构主要有两个,分别是arena_bin_t和arena_bin_info_t。在2.1.3中提到arena_t内部保存了一个bin数组,其中的成员就是arena_bin_t。其结构如下,

1
2
3
4
5
6
struct arena_bin_s {
malloc_mutex_t lock;
arena_run_t *runcur;
arena_run_tree_t runs;
malloc_bin_stats_t stats;
};

  • lock: 该lock同arena内部的lock不同,主要负责保护current run。而对于run本身的分配和释放还是需要依赖arena lock。通常情况下,获得bin lock的前提是获得arena lock,但反之不成立。
  • runcur: 当前可用于分配的run,一般情况下指向地址最低的non-full run,同一时间一个bin只有一个current run用于分配。
  • runs: rb tree,记录当前arena中该bin对应size class的所有non-full runs。因为分配是通过current run完成的,所以也相当于current run的仓库。
  • stats: 统计信息。

另一个与bin相关的结构是arena_bin_info_t。与前者不同,bin_info保存的是arena_bin_t的静态信息,包括相对应size class run的bitmap offset,region size,region number,bitmap info等等(此类信息只要class size决定,就固定下来)。所有上述信息在Je中由全局数组arena_bin_info记录。因此与arena无关,全局仅保留一份。

arena_bin_info_t的定义如下,

1
2
3
4
5
6
7
8
9
10
struct arena_bin_info_s {
size_t reg_size;
size_t redzone_size;
size_t reg_interval;
size_t run_size;
uint32_t nregs;
uint32_t bitmap_offset;
bitmap_info_t bitmap_info;
uint32_t reg0_offset;
};

  • reg_size: 与当前bin的size class相关联的region size.
  • reg_interval: reg_size+redzone_size
  • run_size: 当前bin的size class相关联的run size.
  • nregs: 当前bin的size class相关联的run中region数量。
  • bitmap_offset: 当前bin的size class相关联的run中bitmap偏移。
  • bitmap_info: 记录当前bin的size class相关联的run中bitmap信息。
  • reg0_offset: index为0的region在run中的偏移量。

以上记录的静态信息中尤为重要的是bitmap_info和bitmap_offset.

其中bitmap_info_t定义如下,

1
2
3
4
5
struct bitmap_info_s {
size_t nbits;
unsigned nlevels;
bitmap_level_t levels[BITMAP_MAX_LEVELS+1];
};

  • nbits: bitmap中逻辑bit位数量(特指level#0的bit数)
  • nlevels: bitmap的level数量
  • levels: level偏移量数组,每一项记录该级level在bitmap中的起始index
1
2
3
struct bitmap_level_s {
size_t group_offset;
};

在2.3.1节中介绍arena_run_t时曾提到Je通过外挂bitmap将bookkeeping和user memory分离。但bitmap查询速度要慢于boundary tag。为了弥补这个缺陷,Je对此做了改进,通过多级level缓冲以替代线性查找。

Je为bitmap增加了多级level, bottom level同普通bitmap一致,每1bit代表一个region。而高一级level中1bit代表前一级level中一个byte。譬如说,若我们在当前run中存在128个region,则在ILP32系统上,需要128/32 = 4byte来表示这128个region。Je将这4个byte看作level #0。为了进一步表示这4个字节是否被占用,又额外需要1byte以缓存这4byte的内容(仅使用了4bit),此为level#1。即整个bitmap,一共有2级level,共5byte来描述。

1
2
3
4
5
6
7
                  +--------------+              +--------+
+-----------|------------ +| +----------|-------+|
v v || v v ||
+--------------------------------------------------------------------------
| 1101 0010 | 0000 0000 | ... | 10?? ???? | ???? ???? | 1??? ???? | ...
+--------------------------------------------------------------------------
|<--------- level #0 -------->|<----- level #1 ------>|<- level #2 ->|

2.6 - Thread caches (tcache_t)

TLS/TSD是另一种针对多线程优化使用的分配技术,Je中称为tcache。tcache解决的是同一cpu core下不同线程对heap的竞争。通过为每个线程指定专属分配区域,来减小线程间的干扰。但显然这种方法会增大整体内存消耗量。为了减小副作用,je将tcache设计成一个bookkeeping结构,在tcache中保存的仅仅是指向外部region的指针,region对象仍然位于各个run当中。换句话说,如果一个region被tcache记录了,那么从run的角度看,它就已经被分配了。

tcache的内容如下,

1
2
3
4
5
6
7
8
struct tcache_s {
ql_elm(tcache_t) link;
uint64_t prof_accumbytes;
arena_t *arena;
unsigned ev_cnt;
unsigned next_gc_bin;
tcache_bin_t tbins[1];
};
  • link: 链接节点,用于将同一个arena下的所有tcache链接起来。
  • prof_accumbytes: memory profile相关。
  • arena: 该tcache所属的arena指针。
  • ev_cnt: 是tcache内部的一个周期计数器。每当tcache执行一次分配或释放时,ev_cnt会记录一次。直到周期到来,Je会执行一次incremental gc.这里的gc会清理tcache中多余的region,将它们释放掉。尽管这不意味着系统内存会获得释放,但可以解放更多的region交给其他更饥饿的线程以分配。
  • next_gc_bin: 指向下一次gc的binidx。tcache gc按照一周期清理一个bin执行。
  • tbins: tcache bin数组。同样外挂在tcache后面。

同arena bin类似,tcache同样有tcache_bin_t和tcache_bin_info_t。tcache_bin_t作用类似于arena bin,但其结构要比后者更简单。准确的说,tcache bin并没有分配调度的功能,而仅起到记录作用。其内部通过一个stack记录指向外部arena run中的region指针。而一旦region被cache到tbins内,就不能再被其他任何线程所使用,尽管它可能甚至与其他线程tcache中记录的region位于同一个arena run中。

tcache bin结构如下,

1
2
3
4
5
6
7
struct tcache_bin_s {
tcache_bin_stats_t tstats;
int low_water;
unsigned lg_fill_div;
unsigned ncached;
void **avail;
}
  • tstats: tcache bin内部统计。
  • low_water: 记录两次gc间tcache内部使用的最低水线。该数值与下一次gc时尝试释放的region数量有关。释放量相当于low water数值的3/4.
  • lg_fill_div: 用作tcache refill时作为除数。当tcache耗尽时,会请求arena run进行refill。但refill不会一次性灌满tcache,而是依照其最大容量缩小2^lg_fill_div的倍数。该数值同low_water一样是动态的,两者互相配合确保tcache处于一个合理的充满度。
  • ncached: 指当前缓存的region数量,同时也代表栈顶index.
  • avail: 保存region指针的stack,称为avail-stack.

tcache_bin_info_t保存tcache bin的静态信息。其本身只保存了tcache max容量。该数值是在tcache boot时根据相对应的arena bin的nregs决定的。通常等于nregs的二倍,但不得超过TCACHE_NSLOTS_SMALL_MAX。该数值默认为200,但在android中大大提升了该限制,small bins不得超过8, large bins则为16.

1
2
3
struct tcache_bin_info_s {
unsigned ncached_max;
};

tcache layout如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
                +---------------+
/ | link |
tcache_t < | next_gc_bin |
\ | ... |
|---------------|
/ | tstats |
tbins[0] < | ... |
| | ncached |
\ | avail --------------+
|---------------| |
| ... | |
| ... | |
| ... | |
|---------------| |
/ | tstats | |
tbins[nhb < | ... | |
ins] | | ncached | |
\ | avail --------------|---+
|---------------| | | current arena run
| padding | | | +----------------+
|---------------| <---+ | | run header |
/ | stack[0] | | | ... |
avail-stack < | stack[1] | | | bitmap |
for tbins[0] | | ... | | | ... |
| | ... | | |----------------|
| | stack[ncached | | | region #0 |
\ | _max - 1] | | | ... |
|---------------| | |----------------|
| ... | | +--------> | region #1 |
| ... | | | | ... |
| ... | | | |----------------|
|---------------| <-------+ | | ... |
avail-stack / | stack[0] |--------------|--+ | ... |
for tbins[ < | ... | | | |----------------|
nhbins] | | stack[n] |--------------|--|-----> | region #n |
| | ... | | | | ... |
| | stack[ncached | | | |----------------|
\ | _max - 1] |--------------+ | | ... |
+---------------+ | | ... |
| |----------------|
+-----> | region #nregs-1|
| ... |
+----------------+

2.7 - Extent Node (extent_node_t)

extent node代表huge region,即大于chunk大小的内存单元。同arena run不同,extent node并非是一个header构造,而是外挂的。因此其本身仍属small region。只不过并不通过bin分配,而由base_nodes直接动态创建。

Je中对所有huge region都是通过rb tree管理。因此extent node同时也是tree node。一个node节点被同时挂载到两棵rb tree上。一棵采用szad的查询方式,另一棵则采用纯ad的方式。作用是当执行chunk recycle时查询到可用region,后面会详述。

1
2
3
4
5
6
7
8
9
struct extent_node_s {
rb_node(extent_node_t) link_szad;
rb_node(extent_node_t) link_ad;
prof_ctx_t *prof_ctx;
void *addr;
size_t size;
arena_t *arena;
bool zeroed;
};
  • link_szad: szad tree的link节点。
  • link_ad: ad tree的link节点。
  • prof_ctx: 用于memory profile.
  • addr: 指向huge region的指针。
  • size: region size.
  • arena: huge region所属arena.
  • zeroed: 代表是否zero-filled, chunk recycle时会用到。

2.8 - Base

base并不是数据类型,而是一块特殊区域,主要服务于内部meta data(例如arena_t,tcache_t,extent_node_t等等)的分配。base区域管理与如下变量相关,

1
2
3
4
5
static malloc_mutex_t    base_mtx;
static void *base_pages;
static void *base_next_addr;
static void *base_past_addr;
static extent_node_t *base_nodes;

  • base_mtx: base lock
  • base_pages: base page指针,代表整个区域的起始位置。
  • base_next_addr: 当前base指针,类似于brk指针。
  • base_past_addr: base page的上限指针。
  • base_nodes: 指向extent_node_t链表的外挂头指针。

base page源于arena中的空闲chunk,通常情况下,大小相当于chunk。当base耗尽时,会以chunk alloc的名义重新申请新的base pages。

为了保证内部meta data的快速分配和访问。Je将内部请求大小都对齐到cache-line上,以避免在SMP下的false sharing。而分配方式上,采用了快速移动base_next_addr指针进行高速开采的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void *
base_alloc(size_t size)
{
......
// xf: 将内部分配请求对齐的cache-line上,阻止false sharing
csize = CACHELINE_CEILING(size);

malloc_mutex_lock(&base_mtx);
// xf: 如果base耗尽,则重新分配base_pages。默认大小为chunksize.
if ((uintptr_t)base_next_addr + csize > (uintptr_t)base_past_addr) {
if (base_pages_alloc(csize)) {
malloc_mutex_unlock(&base_mtx);
return (NULL);
}
}
// xf: 快速向前开采
ret = base_next_addr;
base_next_addr = (void *)((uintptr_t)base_next_addr + csize);
malloc_mutex_unlock(&base_mtx);

return (ret);
}

与通常的base alloc有所不同,分配extent_node_t会优先从一个node链表中获取节点,而base_nodes则为该链表的外挂头指针。只有当其耗尽时,才使用前面的分配方式。这里区别对待extent_node_t与其他类型,主要与chunk recycle机制有关,后面会做详细说明。有意思的是,该链表实际上借用了extent node内部rb tree node的左子树节点指针作为其link指针。如2.7节所述,extent_node_t结构的起始位置存放一个rb node.但在这里,当base_nodes赋值给ret后,会强制将ret转型成(extent_node_t **),实际上就是指向extent_node_t结构体的第一个field的指针,并将其指向的node指针记录到base_nodes里,成为新的header节点。这里需要仔细体会这个强制类型转换的巧妙之处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ret = base_nodes
|
v +---- (extent_node_t**)ret
+---|------------------------------ +
| | extent_node |
| +-|-------------------------+ |
| | v rb_node | |
| | +----------+-----------+ | |
| | | rbn_left | rbn_right | | ... |
| | +----------+-----------+ | |
| +-------|-------------------+ |
+---------|-------------------------+
v
base_nodes---> +---------------+
| extent_node |
+---------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
extent_node_t *
base_node_alloc(void)
{
extent_node_t *ret;

malloc_mutex_lock(&base_mtx);
if (base_nodes != NULL) {
ret = base_nodes;
// xf: 这里也可以理解为,base_nodes = (extent_node_t*)(*ret);
base_nodes = *(extent_node_t **)ret;
malloc_mutex_unlock(&base_mtx);
} else {
malloc_mutex_unlock(&base_mtx);
ret = (extent_node_t *)base_alloc(sizeof(extent_node_t));
}

return (ret);
}

3 - Allocation

3.1 - Overview

在2.3.2节中得知,Je将size class划分成small, large, huge三种类型。分配时这三种类型分别按照不同的算法执行。后面的章节也将按照这个类型顺序描述。

总体来说,Je分配函数从je_malloc入口开始,经过,

1
2
3
je_malloc -> imalloc_body -> imalloc -> imalloct ---> arena_malloc
|
+-> huge_malloc

实际执行分配的分别是对应small/large的arena malloc和对应huge的huge malloc。分配算法可以概括如下,

  1. 首先检查Je是否初始化,如果没有则初始化Je,并标记全局malloc_initialized标记。
  2. 检查请求size是否大于huge,如果是则执行8,否则进入下一步。
  3. 执行arena_malloc,首先检查size是否小于等于small maxclass,如果是则下一步,否则执行6.
  4. 如果允许且当前线程已绑定tcache,则从tcache分配small,并返回。否则下一步。
  5. choose arena,并执行arena malloc small,返回。
  6. 如果允许且当前线程已绑定tcache,则从tcache分配large,并返回。否则下一步。
  7. choose arena,并执行arena malloc large,返回。
  8. 执行huge malloc,并返回。

3.2 - Initialize

Je通过全局标记malloc_initialized指代是否初始化。在每次分配时,需要检查该标记,如果没有则执行malloc_init。

但通常条件下,malloc_init是在Je库被载入之前就调用的。通过gcc的编译扩展属性”constructor”实现,

1
2
3
4
5
6
JEMALLOC_ATTR(constructor)
static void
jemalloc_constructor(void)
{
malloc_init();
}

接下来由malloc_init_hard执行各项初始化工作。这里首先需要考虑的是多线程初始化导致的重入,Je通过malloc_initialized和malloc_initializer两个标记来识别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
malloc_mutex_lock(&init_lock);
// xf: 如果在获得init_lock前已经有其他线程完成malloc_init,
// 或者当前线程在初始化过程中执行了malloc,导致递归初始化,则立即退出。
if (malloc_initialized || IS_INITIALIZER) {
malloc_mutex_unlock(&init_lock);
return (false);
}
// xf: 如果开启多线程初始化,需要执行busy wait直到malloc_init在另外线程中
// 执行完毕后返回。
#ifdef JEMALLOC_THREADED_INIT
if (malloc_initializer != NO_INITIALIZER && IS_INITIALIZER == false) {
do {
malloc_mutex_unlock(&init_lock);
CPU_SPINWAIT;
malloc_mutex_lock(&init_lock);
} while (malloc_initialized == false);
malloc_mutex_unlock(&init_lock);
return (false);
}
#endif
// xf: 将当前线程注册为initializer
malloc_initializer = INITIALIZER;

初始化工作由各个xxx_boot函数完成。注意的是,boot函数返回false代表成功,否则代表失败。

  • tsd boot: Thread specific data初始化,主要负责tsd析构函数数组长度初始化。
  • base boot: base是Je内部用于meta data分配的保留区域,使用内部独立的分配方式。base boot负责base node和base mutex的初始化。
  • chunk boot: 主要有三件工作,
    • 确认chunk_size和chunk_npages
    • chunk_dss_boot,chunk dss指chunk分配的dss(Data Storage Segment)方式。其中涉及dss_base,dss_prev指针的初始化工作。
    • chunk tree的初始化,在chunk recycle时要用到。
  • arena boot: 主要是确认arena_maxclass,这个size代表arena管理的最大region,超过该值被认为huge region.在2.2.2小节中有过介绍,先通过多次迭代计算出map_bias,再用chunksize - (map_bias << LG_PAGE)即可得到。另外还对另一个重要的静态数组arena_bin_info执行了初始化。可参考2.3.2介绍class size的部分。
  • tcache boot: 分为tcache_boot0和tcache_boot1两个部分执行。前者负责tcache所有静态信息,包含tcache_bin_info,stack_nelms,nhbins等的初始化。后者负责tcache tsd数据的初始化(tcache保存到线程tsd中).
  • huge boot: 负责huge mutex和huge tree的初始化。

除此之外,其他重要的初始化还包括分配arenas数组。注意arenas是一个指向指针数组的指针,因此各个arena还需要动态创建。这里Je采取了lazy create的方式,只有当choose_arena时才可能由choose_arena_hard创建真实的arena实例。但在malloc_init中,首个arena还是会在此时创建,以保证基本的分配。

相关代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
arena_t *init_arenas[1];
......

// xf: 此时narenas_total只有1
narenas_total = narenas_auto = 1;
arenas = init_arenas;
memset(arenas, 0, sizeof(arena_t *) * narenas_auto);

// xf: 创建首个arena实例,保存到临时数组init_arenas中
arenas_extend(0);
......

// xf: 获得当前系统核心数量
ncpus = malloc_ncpus();
......

// xf: 默认的narenas为核心数量的4倍
if (opt_narenas == 0) {
if (ncpus > 1)
opt_narenas = ncpus << 2;
else
opt_narenas = 1;
}

// xf: android中max arenas限制为2,参考mk文件
#if defined(ANDROID_MAX_ARENAS)
if (opt_narenas > ANDROID_MAX_ARENAS)
opt_narenas = ANDROID_MAX_ARENAS;
#endif
narenas_auto = opt_narenas;
......

// xf: 修正narenas_total
narenas_total = narenas_auto;

// xf: 根据total数量,构造arenas数组,并置空
arenas = (arena_t **)base_alloc(sizeof(arena_t *) * narenas_total);
......
memset(arenas, 0, sizeof(arena_t *) * narenas_total);

// xf: 将之前的首个arena实例指针保存到新构造的arenas数组中
arenas[0] = init_arenas[0];

3.3 - Small allocation (Arena)

先介绍最复杂的arena malloc small.

  1. 先通过small_size2bin查到bin index(2.4.3节有述).
  2. 若对应bin中current run可用则进入下一步,否则执行4.
  3. 由arena_run_reg_alloc在current run中直接分配,并返回。
  4. current run耗尽或不存在,尝试从bin中获得可用run以填充current run,成功则执行9,否则进入下一步。
  5. 当前bin的run tree中没有可用run,转而从arena的avail-tree上尝试切割一个可用run,成功则执行9,否则进入下一步。
  6. 当前arena没有可用的空闲run,构造一个新的chunk以分配new run. 成功则执行9,否则进入下一步。
  7. chunk分配失败,再次查询arena的avail-tree,查找可用run. 成功则执行9,否则进入下一步。
  8. alloc run尝试彻底失败,则再次查询当前bin的run-tree,尝试获取run。
  9. 在使用新获得run之前,重新检查当前bin的current run,如果可用(这里有两种可能,其一是其他线程可能通过free释放了多余的region或run,另一种可能是抢在当前线程之前已经分配了新run),则使用其分配,并返回。另外,如果当前手中的new run是空的,则将其释放掉。否则若其地址比current run更低,则交换二者,将旧的current run插回avail-tree。
  10. 在new run中分配region,并返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void *
arena_malloc_small(arena_t *arena, size_t size, bool zero)
{
......
// xf: 根据size计算bin index
binind = small_size2bin(size);
assert(binind < NBINS);
bin = &arena->bins[binind];
size = small_bin2size(binind);

malloc_mutex_lock(&bin->lock);
// xf: 如果bin中current run不为空,且存在空闲region,则在current
// run中分配。否则在其他run中分配。
if ((run = bin->runcur) != NULL && run->nfree > 0)
ret = arena_run_reg_alloc(run, &arena_bin_info[binind]);
else
ret = arena_bin_malloc_hard(arena, bin);

// xf: 若返回null,则分配失败。
if (ret == NULL) {
malloc_mutex_unlock(&bin->lock);
return (NULL);
}
......

return (ret);
}

3.3.1 - arena_run_reg_alloc

  1. 首先根据bin_info中的静态信息bitmap_offset计算bitmap基址。
  2. 扫描当前run的bitmap,获得第一个free region所在的位置。
  3. region地址 = run基址 + 第一个region的偏移量 + free region索引 * region内部size
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static inline void *
arena_run_reg_alloc(arena_run_t *run, arena_bin_info_t *bin_info)
{
......
// xf: 计算bitmap基址
bitmap_t *bitmap = (bitmap_t *)((uintptr_t)run +
(uintptr_t)bin_info->bitmap_offset);
......

// xf: 获得当前run中第一个free region所在bitmap中的位置
regind = bitmap_sfu(bitmap, &bin_info->bitmap_info);
// xf: 计算返回值
ret = (void *)((uintptr_t)run + (uintptr_t)bin_info->reg0_offset +
(uintptr_t)(bin_info->reg_interval * regind));
// xf: free减1
run->nfree--;
......

return (ret);
}

其中bitmap_sfu是执行bitmap遍历并设置第一个unset bit。如2.5节所述,bitmap由多级组成,遍历由top level开始循环迭代,直至bottom level。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
JEMALLOC_INLINE size_t
bitmap_sfu(bitmap_t *bitmap, const bitmap_info_t *binfo)
{
......
// xf: 找到最高级level,并计算ffs
i = binfo->nlevels - 1;
g = bitmap[binfo->levels[i].group_offset];
bit = jemalloc_ffsl(g) - 1;
// xf: 循环迭代,直到level0
while (i > 0) {
i--;
// xf: 根据上一级level的结果,计算当前level的group
g = bitmap[binfo->levels[i].group_offset + bit];
// xf: 根据当前level group,计算下一级需要的bit
bit = (bit << LG_BITMAP_GROUP_NBITS) + (jemalloc_ffsl(g) - 1);
}

// xf: 得到level0的bit,设置bitmap
bitmap_set(bitmap, binfo, bit);
return (bit);
}

bitmap_set同普通bitmap操作没有什么区别,只是在set/unset之后需要反向迭代更新各个高等级level对应的bit位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
JEMALLOC_INLINE void
bitmap_set(bitmap_t *bitmap, const bitmap_info_t *binfo, size_t bit)
{
......
// xf: 计算该bit所在level0中的group
goff = bit >> LG_BITMAP_GROUP_NBITS;
// xf: 得到目标group的值g
gp = &bitmap[goff];
g = *gp;
// xf: 根据remainder,找到target bit,并反转
g ^= 1LU << (bit & BITMAP_GROUP_NBITS_MASK);
*gp = g;
......
// xf: 若target bit所在group为0,则需要更新highlevel的相应bit,
// 是bitmap_sfu的反向操作。
if (g == 0) {
unsigned i;
for (i = 1; i < binfo->nlevels; i++) {
bit = goff;
goff = bit >> LG_BITMAP_GROUP_NBITS;
gp = &bitmap[binfo->levels[i].group_offset + goff];
g = *gp;
assert(g & (1LU << (bit & BITMAP_GROUP_NBITS_MASK)));
g ^= 1LU << (bit & BITMAP_GROUP_NBITS_MASK);
*gp = g;
if (g != 0)
break;
}
}
}

3.3.2 - arena_bin_malloc_hard

  1. 从bin中获得可用的nonfull run,这个过程中bin->lock有可能被解锁。
  2. 暂不使用new run,返回检查bin->runcur是否重新可用。如果是,则直接在其中分配region(其他线程在bin lock解锁期间可能提前修改了runcur)。否则,执行4.
  3. 重新检查1中得到的new run,如果为空,则释放该run.否则与当前runcur作比较,若地址低于runcur,则与其做交换。将旧的runcur插回run tree。并返回new rigion.
  4. 用new run填充runcur,并在其中分配region,返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static void *
arena_bin_malloc_hard(arena_t *arena, arena_bin_t *bin)
{
......
// xf: 获得bin对应的arena_bin_info,并将current run置空
binind = arena_bin_index(arena, bin);
bin_info = &arena_bin_info[binind];
bin->runcur = NULL;

// xf: 从指定bin中获得一个可用的run
run = arena_bin_nonfull_run_get(arena, bin);

// 对bin->runcur做重新检查。如果可用且未耗尽,则直接分配。
if (bin->runcur != NULL && bin->runcur->nfree > 0) {
ret = arena_run_reg_alloc(bin->runcur, bin_info);

// xf: 若new run为空,则将其释放。否则重新插入run tree.
if (run != NULL) {
arena_chunk_t *chunk;
chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(run);
if (run->nfree == bin_info->nregs)
arena_dalloc_bin_run(arena, chunk, run, bin);
else
arena_bin_lower_run(arena, chunk, run, bin);
}
return (ret);
}

if (run == NULL)
return (NULL);

// xf: 到这里在bin->runcur中分配失败,用当前新获得的run填充current run
bin->runcur = run;

// xf: 在new run中分配region
return (arena_run_reg_alloc(bin->runcur, bin_info));
}

3.3.3 - arena_bin_nonfull_run_get

  1. 尝试在当前run tree中寻找可用run,成功则返回,否则进入下一步
  2. 解锁bin lock,并加锁arena lock,尝试在当前arena中分配new run。之后重新解锁arena lock,并加锁bin lock。如果成功则返回,否则进入下一步。
  3. 分配失败,重新在当前run tree中寻找一遍可用run.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
static arena_run_t *
arena_bin_nonfull_run_get(arena_t *arena, arena_bin_t *bin)
{
......
// xf: 尝试从当前run tree中寻找一个可用run,如果存在就返回
run = arena_bin_nonfull_run_tryget(bin);
if (run != NULL)
return (run);
......

// xf: 打开bin lock,让其他线程可以操作当前的bin tree
malloc_mutex_unlock(&bin->lock);
// xf: 锁住arena lock,以分配new run
malloc_mutex_lock(&arena->lock);

// xf: 尝试分配new run
run = arena_run_alloc_small(arena, bin_info->run_size, binind);
if (run != NULL) {
// 初始化new run和bitmap
bitmap_t *bitmap = (bitmap_t *)((uintptr_t)run +
(uintptr_t)bin_info->bitmap_offset);

run->bin = bin;
run->nextind = 0;
run->nfree = bin_info->nregs;
bitmap_init(bitmap, &bin_info->bitmap_info);
}

// xf: 解锁arena lock
malloc_mutex_unlock(&arena->lock);
// xf: 重新加锁bin lock
malloc_mutex_lock(&bin->lock);

if (run != NULL) {
......
return (run);
}

// xf: 如果run alloc失败,则回过头重新try get一次(前面解锁bin lock
// 给了其他线程机会).
run = arena_bin_nonfull_run_tryget(bin);
if (run != NULL)
return (run);

return (NULL);
}

3.3.4 - Small Run Alloc

  1. 从arena avail tree上获得一个可用run,并对其切割。失败进入下一步。
  2. 尝试给arena分配新的chunk,以构造new run。此过程可能会解锁arena lock.失败进入下一步。
  3. 其他线程可能在此过程中释放了某些run,重新检查avail-tree,尝试获取run。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static arena_run_t *
arena_run_alloc_small(arena_t *arena, size_t size, size_t binind)
{
......
// xf: 从available tree上尝试寻找并切割一个合适的run,并对其初始化
run = arena_run_alloc_small_helper(arena, size, binind);
if (run != NULL)
return (run);

// xf: 当前arena内没有可用的空闲run,构造一个新的chunk以分配new run.
chunk = arena_chunk_alloc(arena);
if (chunk != NULL) {
run = (arena_run_t *)((uintptr_t)chunk + (map_bias << LG_PAGE));
arena_run_split_small(arena, run, size, binind);
return (run);
}

// xf: 重新检查arena avail-tree.
return (arena_run_alloc_small_helper(arena, size, binind));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static arena_run_t *
arena_run_alloc_small_helper(arena_t *arena, size_t size, size_t binind)
{
......
// xf: 在arena的available tree中寻找一个大于等于size大小的最小run
key = (arena_chunk_map_t *)(size | CHUNK_MAP_KEY);
mapelm = arena_avail_tree_nsearch(&arena->runs_avail, key);
if (mapelm != NULL) {
arena_chunk_t *run_chunk = CHUNK_ADDR2BASE(mapelm);
size_t pageind = arena_mapelm_to_pageind(mapelm);

// xf: 计算候选run的地址
run = (arena_run_t *)((uintptr_t)run_chunk + (pageind <<
LG_PAGE));
// xf: 根据分配需求,切割候选run
arena_run_split_small(arena, run, size, binind);
return (run);
}

return (NULL);
}

切割small run主要分为4步,

  1. 将候选run的arena_chunk_map_t节点从avail-tree上摘除。
  2. 根据节点储存的原始page信息,以及need pages信息,切割该run.
  3. 更新remainder节点信息(只需更新首尾page),重新插入avail-tree.
  4. 设置切割后new run所有page对应的map节点信息(根据2.3.3节所述).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static void
arena_run_split_small(arena_t *arena, arena_run_t *run, size_t size,
size_t binind)
{
......
// xf: 获取目标run的dirty flag
chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(run);
run_ind = (unsigned)(((uintptr_t)run - (uintptr_t)chunk) >> LG_PAGE);
flag_dirty = arena_mapbits_dirty_get(chunk, run_ind);
need_pages = (size >> LG_PAGE);

// xf: 1. 将候选run从available tree上摘除
// 2. 根据need pages对候选run进行切割
// 3. 将remainder重新插入available tree
arena_run_split_remove(arena, chunk, run_ind, flag_dirty, need_pages);

// xf: 设置刚刚被split后的run的第一个page
arena_mapbits_small_set(chunk, run_ind, 0, binind, flag_dirty);
......

// xf: 依次设置run中的其他page, run index依次递增
for (i = 1; i < need_pages - 1; i++) {
arena_mapbits_small_set(chunk, run_ind+i, i, binind, 0);
.......
}

// xf: 设置run中的最后一个page
arena_mapbits_small_set(chunk, run_ind+need_pages-1, need_pages-1,
binind, flag_dirty);
......
}

3.3.5 - Chunk Alloc

arena获取chunk一般有两个途径。其一是通过内部的spare指针。该指针缓存了最近一次chunk被释放的记录。因此该方式速度很快。另一种更加常规,通过内部分配函数分配,最终将由chunk_alloc_core执行。但在Je的设计中,执行arena chunk的分配器是可定制的,你可以替换任何第三方chunk分配器。这里仅讨论默认情况。

Je在chunk_alloc_core中同传统分配器如Dl有较大区别。通常情况下,从系统获取内存无非是morecore或mmap两种方式。Dl中按照先morecore->mmap的顺序,而Je更为灵活,具体的顺序由dss_prec_t决定。

该类型是一个枚举,定义如下,

1
2
3
4
5
6
typedef enum {
dss_prec_disabled = 0,
dss_prec_primary = 1,
dss_prec_secondary = 2,
dss_prec_limit = 3
} dss_prec_t;

这里dss和morecore含义是相同的。primary表示优先dss,secondary则优先mmap。Je默认使用后者。

实际分配时,无论采用哪种策略,都会优先执行chunk_recycle,再执行chunk alloc,如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void *
chunk_alloc_core(size_t size, size_t alignment, bool base, bool *zero,
dss_prec_t dss_prec)
{
void *ret;

if (have_dss && dss_prec == dss_prec_primary) {
if ((ret = chunk_recycle(&chunks_szad_dss, &chunks_ad_dss, size,
alignment, base, zero)) != NULL)
return (ret);
if ((ret = chunk_alloc_dss(size, alignment, zero)) != NULL)
return (ret);
}

if ((ret = chunk_recycle(&chunks_szad_mmap, &chunks_ad_mmap, size,
alignment, base, zero)) != NULL)
return (ret);
if ((ret = chunk_alloc_mmap(size, alignment, zero)) != NULL)
return (ret);

if (have_dss && dss_prec == dss_prec_secondary) {
if ((ret = chunk_recycle(&chunks_szad_dss, &chunks_ad_dss, size,
alignment, base, zero)) != NULL)
return (ret);
if ((ret = chunk_alloc_dss(size, alignment, zero)) != NULL)
return (ret);
}

return (NULL);
}

所谓chunk recycle是在alloc chunk之前,优先在废弃的chunk tree上搜索可用chunk,并分配base node以储存meta data的过程。好处是其一可以加快分配速度,其二是使空间分配更加紧凑,并节省内存。

在Je中存在4棵全局的rb tree,分别为,

1
2
3
4
static extent_tree_t    chunks_szad_mmap;
static extent_tree_t chunks_ad_mmap;
static extent_tree_t chunks_szad_dss;
static extent_tree_t chunks_ad_dss;

它们分别对应mmap和dss方式。当一个chunk或huge region被释放后,将收集到这4棵tree中。szad和ad在内容上并无本质区别,只是检索方式不一样。前者采用先size后address的方式,后者则是纯address的检索。

recycle算法概括如下,

  1. 检查base标志,如果为真则直接返回,否则进入下一步。开始的检查是必要的,因为recycle过程中可能会创建新的extent node,要求调用base allocator分配。另一方面,base alloc可能因为耗尽的原因而反过来调用chunk alloc。如此将导致dead loop.
  2. 根据alignment计算分配大小,并在szad tree(mmap还是dss需要上一级决定)上寻找一个大于等于alloc size的最小node.
  3. chunk tree上的node未必对齐到alignment上,将地址对齐,之后将得到leadsize和trailsize.
  4. 将原node从chunk tree上remove。若leadsize不为0,则将其作为新的chunk重新insert回chunk tree。trailsize不为0的情况亦然。若leadsize和trailsize同时不为0,则通过base_node_alloc为trailsize生成新的node并插入。若base alloc失败,则整个新分配的region都要销毁。
  5. 若leadsize和trailsize都为0,则将node(注意仅仅是节点)释放。返回对齐后的chunk地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
static void *
chunk_recycle(extent_tree_t *chunks_szad, extent_tree_t *chunks_ad, size_t size,
size_t alignment, bool base, bool *zero)
{
......
// xf: 由于构造extent_node时可能因为内存不足的原因,同样需要构造chunk,
// 这样就导致recursively dead loop。因此依靠base标志,区分普通alloc和
// base node alloc。如果是base alloc,则立即返回。
if (base) {
return (NULL);
}

// xf: 计算分配大小
alloc_size = size + alignment - chunksize;
......
key.addr = NULL;
key.size = alloc_size;

// xf: 在指定的szad tree上寻找大于等于alloc size的最小可用node
malloc_mutex_lock(&chunks_mtx);
node = extent_tree_szad_nsearch(chunks_szad, &key);
......

// xf: 将候选节点基址对齐到分配边界上,并计算leadsize, trailsize
// 以及返回地址。
leadsize = ALIGNMENT_CEILING((uintptr_t)node->addr, alignment) -
(uintptr_t)node->addr;
trailsize = node->size - leadsize - size;
ret = (void *)((uintptr_t)node->addr + leadsize);
......

// xf: 将原node从szad/ad tree上移除
extent_tree_szad_remove(chunks_szad, node);
extent_tree_ad_remove(chunks_ad, node);

// xf: 如果存在leadsize,则将前面多余部分作为一个chunk重新插入
// szad/ad tree上。
if (leadsize != 0) {
node->size = leadsize;
extent_tree_szad_insert(chunks_szad, node);
extent_tree_ad_insert(chunks_ad, node);
node = NULL;
}

// xf: 同样如果存在trailsize,也将后面的多余部分插入。
if (trailsize != 0) {
// xf: 如果leadsize不为0,这时原来的extent_node已经被用过了,
// 则必须为trailsize部分重新分配新的extent_node
if (node == NULL) {
malloc_mutex_unlock(&chunks_mtx);
node = base_node_alloc();
......
}
// xf: 计算trail chunk,并插入
node->addr = (void *)((uintptr_t)(ret) + size);
node->size = trailsize;
node->zeroed = zeroed;
extent_tree_szad_insert(chunks_szad, node);
extent_tree_ad_insert(chunks_ad, node);
node = NULL;
}
malloc_mutex_unlock(&chunks_mtx);

// xf: leadsize & basesize都不存在,将node释放。
if (node != NULL)
base_node_dalloc(node);
......

return (ret);
}

常规分配方式先来看dss。由于dss是与当前进程的brk指针相关的,任何线程(包括可能不通过Je执行分配的线程)都有权修改该指针值。因此,首先要把dss指针调整到对齐在chunksize边界的位置,否则很多与chunk相关的计算都会失效。接下来,还要做第二次调整对齐到外界请求的alignment边界。在此基础上再进行分配。

与dss分配相关的变量如下,

1
2
3
4
static malloc_mutex_t    dss_mtx;
static void *dss_base;
static void *dss_prev;
static void *dss_max;
  • dss_mtx: dss lock。注意其并不能起到保护dss指针的作用,因为brk是一个系统资源。该lock保护的是dss_prev, dss_max指针。
  • dss_base: 只在chunk_dss_boot时更新一次。主要用作识别chunk在线性地址空间中所处的位置,与mmap作出区别。
  • dss_prev: 当前dss指针,是系统brk指针的副本,值等于-1代表dss耗尽。
  • dss_max: 当前dss区域上限。

dss alloc算法如下,

  1. 获取brk指针,更新到dss_max.
  2. 将dss_max对齐到chunksize边界上,计算padding大小gap_size
  3. 再将dss_max对齐到aligment边界上,得到cpad_size
  4. 计算需要分配的大小,并尝试sbrk。incr = gap_size + cpad_size + size
  5. 分配成功,检查cpad是否非0,是则将这部分重新回收。而gap_size部分因为不可用则被废弃。
  6. 如果分配失败,则检查dss是否耗尽,如果没有则返回1重新尝试。否则返回。

示意图,

1
2
3
4
5
6
7
8
9
10
11
chunk_base             cpad        ret        dss_next
| | | |
v v v v
+--------+----------+-----------+------ ---+
| used | gap_size | cpad_size | size ... |
+--------+----------+-----------+------ ---+
|<------------- incr -------------->|
^ ^ ^
| | |
dss_max chunk_base + +-- chunk_base +
chunk_size alignment

源码注释,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void *
chunk_alloc_dss(size_t size, size_t alignment, bool *zero)
{
......
// xf: dss是否耗尽
malloc_mutex_lock(&dss_mtx);
if (dss_prev != (void *)-1) {
......
do {
// xf: 获取当前dss指针
dss_max = chunk_dss_sbrk(0);

// xf: 计算对齐到chunk size边界需要的padding大小
gap_size = (chunksize - CHUNK_ADDR2OFFSET(dss_max)) &
chunksize_mask;
// xf: 对齐到chunk边界的chunk起始地址
cpad = (void *)((uintptr_t)dss_max + gap_size);
// xf: 对齐到alignment边界的起始地址
ret = (void *)ALIGNMENT_CEILING((uintptr_t)dss_max,
alignment);
cpad_size = (uintptr_t)ret - (uintptr_t)cpad;
// xf: dss_max分配后的更新地址
dss_next = (void *)((uintptr_t)ret + size);
......
incr = gap_size + cpad_size + size;
// xf: 从dss分配
dss_prev = chunk_dss_sbrk(incr);
if (dss_prev == dss_max) {
dss_max = dss_next;
malloc_mutex_unlock(&dss_mtx);
// xf: 如果ret和cpad对齐不在同一个位置,则将cpad开始
// cpad_size大小的内存回收到szad/ad tree中。而以之前
// dss起始的gap_size大小内存由于本身并非对齐到
// chunk_size,则被废弃。
if (cpad_size != 0)
chunk_unmap(cpad, cpad_size);
......
return (ret);
}
} while (dss_prev != (void *)-1); // xf: 反复尝试直至dss耗尽
}
malloc_mutex_unlock(&dss_mtx);

return (NULL);
}

最后介绍chunk_alloc_mmap。同dss方式类似,mmap也存在对齐的问题。由于系统mmap调用无法指定alignment,因此Je实现了一个可以实现对齐但速度更慢的mmap slow方式。作为弥补,在chunk alloc mmap时先尝试依照普通方式mmap,如果返回值恰好满足对齐要求则直接返回(多数情况下是可行的)。否则将返回值munmap,再调用mmap slow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void *
chunk_alloc_mmap(size_t size, size_t alignment, bool *zero)
{
......
ret = pages_map(NULL, size);
if (ret == NULL)
return (NULL);
offset = ALIGNMENT_ADDR2OFFSET(ret, alignment);
if (offset != 0) {
pages_unmap(ret, size);
return (chunk_alloc_mmap_slow(size, alignment, zero));
}
......

return (ret);
}

mmap slow通过事先分配超量size,对齐后再执行trim,去掉前后余量实现mmap对齐。page trim通过两次munmap将leadsize和trailsize部分分别释放。因此理论上,mmap slow需要最多三次munmap.

1
2
3
4
5
6
7
|<-------------alloc_size---------->|
+-----------+----- --+------------+
| lead_size | size... | trail_size |
+-----------+----- --+------------+
^ ^
| |
pages ret(alignment)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void *
chunk_alloc_mmap_slow(size_t size, size_t alignment, bool *zero)
{
......
alloc_size = size + alignment - PAGE;
if (alloc_size < size)
return (NULL);
do {
pages = pages_map(NULL, alloc_size);
if (pages == NULL)
return (NULL);
leadsize = ALIGNMENT_CEILING((uintptr_t)pages, alignment) -
(uintptr_t)pages;
ret = pages_trim(pages, alloc_size, leadsize, size);
} while (ret == NULL);
......
return (ret);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void *
pages_trim(void *addr, size_t alloc_size, size_t leadsize, size_t size)
{
void *ret = (void *)((uintptr_t)addr + leadsize);
......
{
size_t trailsize = alloc_size - leadsize - size;

if (leadsize != 0)
pages_unmap(addr, leadsize);
if (trailsize != 0)
pages_unmap((void *)((uintptr_t)ret + size), trailsize);
return (ret);
}
}

3.4 - Small allocation (tcache)

tcache内分配按照先easy后hard的方式。easy方式直接从tcache bin的avail-stack中获得可用region。如果tbin耗尽,使用hard方式,先refill avail-stack,再执行easy分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
JEMALLOC_ALWAYS_INLINE void *
tcache_alloc_small(tcache_t *tcache, size_t size, bool zero)
{
......
// xf: 先从tcache bin尝试分配
ret = tcache_alloc_easy(tbin);
// xf: 如果尝试失败,则refill tcache bin,并尝试分配
if (ret == NULL) {
ret = tcache_alloc_small_hard(tcache, tbin, binind);
if (ret == NULL)
return (NULL);
}
......

// xf: 执行tcache event
tcache_event(tcache);
return (ret);
}

JEMALLOC_ALWAYS_INLINE void *
tcache_alloc_easy(tcache_bin_t *tbin)
{
void *ret;

// xf: 如果tcache bin耗尽,更新水线为-1
if (tbin->ncached == 0) {
tbin->low_water = -1;
return (NULL);
}
// xf: pop栈顶的region,如果需要更新水线
tbin->ncached--;
if ((int)tbin->ncached < tbin->low_water)
tbin->low_water = tbin->ncached;
ret = tbin->avail[tbin->ncached];
return (ret);
}

void *
tcache_alloc_small_hard(tcache_t *tcache, tcache_bin_t *tbin, size_t binind)
{
void *ret;

arena_tcache_fill_small(tcache->arena, tbin, binind,
config_prof ? tcache->prof_accumbytes : 0);
if (config_prof)
tcache->prof_accumbytes = 0;
ret = tcache_alloc_easy(tbin);

return (ret);
}

tcache fill同普通的arena bin分配类似。首先,获得与tbin相同index的arena bin。之后确定fill值,该数值与2.7节介绍的lg_fill_div有关。如果arena run的runcur可用则直接分配并push stack,否则arena_bin_malloc_hard分配region。push后的顺序按照从低到高排列,低地址的region更靠近栈顶位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void
arena_tcache_fill_small(arena_t *arena, tcache_bin_t *tbin, size_t binind,
uint64_t prof_accumbytes)
{
......
// xf: 得到与tbin同index的arena bin
bin = &arena->bins[binind];
malloc_mutex_lock(&bin->lock);
// xf: tbin的充满度与lg_fill_div相关
for (i = 0, nfill = (tcache_bin_info[binind].ncached_max >>
tbin->lg_fill_div); i < nfill; i++) {
// xf: 如果current run可用,则从中分配
if ((run = bin->runcur) != NULL && run->nfree > 0)
ptr = arena_run_reg_alloc(run, &arena_bin_info[binind]);
else // xf: current run耗尽,则从bin中查找其他run分配
ptr = arena_bin_malloc_hard(arena, bin);
if (ptr == NULL)
break;
......
// xf: 低地址region优先放入栈顶
tbin->avail[nfill - 1 - i] = ptr;
}
......
malloc_mutex_unlock(&bin->lock);
// xf: 更新ncached
tbin->ncached = i;
}

另外,如2.7节所述,tcache在每次分配和释放后都会更新ev_cnt计数器。当计数周期达到TCACHE_GC_INCR时,就会启动tcache gc。gc过程中会清理相当于low_water 3/4数量的region,并根据当前的low_water和lg_fill_div动态调整下一次refill时,tbin的充满度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
void
tcache_bin_flush_small(tcache_bin_t *tbin, size_t binind, unsigned rem,
tcache_t *tcache)
{
......
// xf: 循环scan,直到nflush为空。
// 因为avail-stack中的region可能来自不同arena,因此需要多次scan.
// 每次scan将不同arena的region移动到栈顶,留到下一轮scan时清理。
for (nflush = tbin->ncached - rem; nflush > 0; nflush = ndeferred) {
// xf: 获得栈顶region所属的arena和arena bin
arena_chunk_t *chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(
tbin->avail[0]);
arena_t *arena = chunk->arena;
arena_bin_t *bin = &arena->bins[binind];
......
// xf: 锁住栈顶region的arena bin
malloc_mutex_lock(&bin->lock);
......
// xf: ndefered代表所属不同arena的region被搬移的位置,默认从0开始。
// 本意是随着scan进行,nflush逐渐递增,nflush之前的位置空缺出来。
// 当scan到不同arena region时,将其指针移动到nflush前面的空缺中,
// 留到下一轮scan, nflush重新开始。直到ndefered和nflush重新为0.
ndeferred = 0;
for (i = 0; i < nflush; i++) {
ptr = tbin->avail[i];
chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(ptr);
// xf: 如果scan的region与栈顶region位于同一arena,则释放,
// 否则移动到ndefered标注的位置,留到后面scan.
if (chunk->arena == arena) {
size_t pageind = ((uintptr_t)ptr -
(uintptr_t)chunk) >> LG_PAGE;
arena_chunk_map_t *mapelm =
arena_mapp_get(chunk, pageind);
......
// xf: 释放多余region
arena_dalloc_bin_locked(arena, chunk, ptr,
mapelm);
} else {
tbin->avail[ndeferred] = ptr;
ndeferred++;
}
}
malloc_mutex_unlock(&bin->lock);
}
......
// xf: 将remainder regions指针移动到栈顶位置,完成gc过程
memmove(tbin->avail, &tbin->avail[tbin->ncached - rem],
rem * sizeof(void *));
// xf: 修正ncached以及low_water
tbin->ncached = rem;
if ((int)tbin->ncached < tbin->low_water)
tbin->low_water = tbin->ncached;
}

3.5 - Large allocation

Arena上的large alloc同small相比除了省去arena bin的部分之外,并无本质区别。基本算法如下,

  1. 把请求大小对齐到page size上,直接从avail-tree上寻找first-best-fit runs.如果成功,则根据请求大小切割内存。切割过程也同切割small run类似,区别在之后对chunk map的初始化不同。chunk map细节可回顾2.3.3。如果失败,则进入下一步。
  2. 没有可用runs,尝试创建new chunk,成功同样切割run,失败进入下一步。
  3. 再次尝试从avail-tree上寻找可用runs,并返回。

同上面的过程可以看出,所谓large region分配相当于small run的分配。区别仅在于chunk map信息不同。

Tcache上的large alloc同样按照先easy后hard的顺序。尽管常规arena上的分配不存在large bin,但在tcache中却存在large tbin,因此仍然是先查找avail-stack.如果tbin中找不到,就会向arena申请large runs。这里与small alloc的区别在不执行tbin refill,因为考虑到过多large region的占用量问题。large tbin仅在tcache_dalloc_large的时候才负责收集region。当tcache已满或GC周期到时执行tcache gc.

3.6 - Huge allocation

Huge alloc相对于前面就更加简单。因为对于Je而言,huge region和chunk是等同的,这在前面有过叙述。Huge alloc就是调用chunk alloc,并将extent_node记录在huge tree上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void *
huge_palloc(arena_t *arena, size_t size, size_t alignment, bool zero)
{
void *ret;
size_t csize;
extent_node_t *node;
bool is_zeroed;

// xf: huge alloc对齐到chunksize
csize = CHUNK_CEILING(size);
......
// xf: create extent node以记录huge region
node = base_node_alloc();
......
arena = choose_arena(arena);
// xf: 调用chunk alloc分配
ret = arena_chunk_alloc_huge(arena, csize, alignment, &is_zeroed);
// xf: 失败则清除extent node
if (ret == NULL) {
base_node_dalloc(node);
return (NULL);
}

node->addr = ret;
node->size = csize;
node->arena = arena;

// xf: 插入huge tree上
malloc_mutex_lock(&huge_mtx);
extent_tree_ad_insert(&huge, node);
malloc_mutex_unlock(&huge_mtx);
......
return (ret);
}

4 - Deallocation

4.1 - Overview

释放同分配过程相反,按照一个从ptr -> run -> bin -> chunk -> arena的路径。但因为涉及page合并和purge,实现更为复杂。dalloc的入口从je_free -> ifree -> iqalloc -> iqalloct -> idalloct.对dalloc的分析从idalloct开始。代码如下,

1
2
3
4
5
6
7
8
9
10
11
JEMALLOC_ALWAYS_INLINE void
idalloct(void *ptr, bool try_tcache)
{
......
// xf: 获得被释放地址所在的chunk
chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(ptr);
if (chunk != ptr)
arena_dalloc(chunk, ptr, try_tcache);
else
huge_dalloc(ptr);
}

首先会检测被释放指针ptr所在chunk的首地址与ptr是否一致,如果是,则一定为huge region,否则为small/large。从这里分为arena和huge两条线。

再看一下arena_dalloc,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
JEMALLOC_ALWAYS_INLINE void
arena_dalloc(arena_chunk_t *chunk, void *ptr, bool try_tcache)
{
......
// xf: 得到页面mapbits
mapbits = arena_mapbits_get(chunk, pageind);

if ((mapbits & CHUNK_MAP_LARGE) == 0) {
if (try_tcache && (tcache = tcache_get(false)) != NULL) {
// xf: ptr所在tcache的index
binind = arena_ptr_small_binind_get(ptr, mapbits);
tcache_dalloc_small(tcache, ptr, binind);
} else
arena_dalloc_small(chunk->arena, chunk, ptr, pageind);
} else {
size_t size = arena_mapbits_large_size_get(chunk, pageind);
if (try_tcache && size <= tcache_maxclass && (tcache =
tcache_get(false)) != NULL) {
tcache_dalloc_large(tcache, ptr, size);
} else
arena_dalloc_large(chunk->arena, chunk, ptr);
}
}

这里通过得到ptr所在page的mapbits,判断其来自于small还是large。然后再分别作处理。

因此,在dalloc一开始基本上分成了small/large/huge三条路线执行。事实上,结合前面的知识,large/huge可以看作run和chunk的特例。所以,这三条dalloc路线最终会汇到一起,只需要搞清楚其中最为复杂的small region dalloc就可以了。

无论small/large region,都会先尝试释放回tcache,不管其是否从tache中分配而来。所谓tcache dalloc只不过是将region记录在tbin中,并不算真正的释放。除非两种情况,一是如果当前线程tbin已满,会直接执行一次tbin flush,释放出部分tbin空间。二是如果tcache_event触发发了tache gc,也会执行flush。两者的区别在于,前者会回收指定tbin 1/2的空间,而后者则释放next_gc_bin相当于3/4low water数量的空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JEMALLOC_ALWAYS_INLINE void
tcache_dalloc_small(tcache_t *tcache, void *ptr, size_t binind)
{
......
tbin = &tcache->tbins[binind];
tbin_info = &tcache_bin_info[binind];
// xf: 如果当前tbin已满,则执行flush清理tbin
if (tbin->ncached == tbin_info->ncached_max) {
tcache_bin_flush_small(tbin, binind, (tbin_info->ncached_max >>
1), tcache);
}
// xf: 将被释放的ptr重新push进tbin
tbin->avail[tbin->ncached] = ptr;
tbin->ncached++;

tcache_event(tcache);
}

tcache gc和tcache flush在2.7和3.4节中已经介绍,不再赘述。

4.2 - arena_dalloc_bin

small region dalloc的第一步是尝试将region返还给所属的bin。首要的步骤就是根据用户传入的ptr推算出其所在run的地址。

1
run addr = chunk base + run page offset << LG_PAGE

而run page offset根据2.3.3小节的说明,可以通过ptr所在page的mapbits获得。

1
run page offset = ptr page index - ptr page offset

得到run后就进一步拿到所属的bin,接着对bin加锁并回收,如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
arena_dalloc_bin(arena_t *arena, arena_chunk_t *chunk, void *ptr,
size_t pageind, arena_chunk_map_t *mapelm)
{
......
// xf: 计算ptr所在run地址。
run = (arena_run_t *)((uintptr_t)chunk + (uintptr_t)((pageind -
arena_mapbits_small_runind_get(chunk, pageind)) << LG_PAGE));
bin = run->bin;

malloc_mutex_lock(&bin->lock);
arena_dalloc_bin_locked(arena, chunk, ptr, mapelm);
malloc_mutex_unlock(&bin->lock);
}

lock的内容无非是将region在run内部的bitmap上标记为可用。bitmap unset的过程此处省略,请参考3.3.1小节中分配算法的解释。与tcache dalloc类似,通常情况下region并不会真正释放。但如果run内部全部为空闲region,则会进一步触发run的释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void
arena_dalloc_bin_locked(arena_t *arena, arena_chunk_t *chunk, void *ptr,
arena_chunk_map_t *mapelm)
{
......
// xf: 通过run回收region,在bitmap上重新标记region可用。
arena_run_reg_dalloc(run, ptr);

// xf: 如果其所在run完全free,则尝试释放该run.
// 如果所在run处在将满状态(因为刚刚的释放腾出一个region的空间),
// 则根据地址高低优先将其交换到current run的位置(MRU).
if (run->nfree == bin_info->nregs) {
arena_dissociate_bin_run(chunk, run, bin);
arena_dalloc_bin_run(arena, chunk, run, bin);
} else if (run->nfree == 1 && run != bin->runcur)
arena_bin_lower_run(arena, chunk, run, bin);
......
}

此外还有一种情况是,如果原先run本来是满的,因为前面的释放多出一个空闲位置,就会尝试与current run交换位置。若当前run比current run地址更低,会替代后者并成为新的current run,这样的好处显然可以保证低地址的内存更紧实。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void
arena_bin_lower_run(arena_t *arena, arena_chunk_t *chunk, arena_run_t *run,
arena_bin_t *bin)
{
if ((uintptr_t)run < (uintptr_t)bin->runcur) {
if (bin->runcur->nfree > 0)
arena_bin_runs_insert(bin, bin->runcur);
bin->runcur = run;
if (config_stats)
bin->stats.reruns++;
} else
arena_bin_runs_insert(bin, run);
}

通常情况下,至此一个small region就释放完毕了,准确的说是回收了。但如前面所说,若整个run都为空闲region,则进入run dalloc。这是一个比较复杂的过程。

4.3 - small run dalloc

一个non-full的small run被记录在bin内的run tree上,因此要移除它,首先要移除其在run tree中的信息,即arena_dissociate_bin_run.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void
arena_dissociate_bin_run(arena_chunk_t *chunk, arena_run_t *run,
arena_bin_t *bin)
{
// xf: 如果当前run为current run,清除runcur。否则,从run tree上remove.
if (run == bin->runcur)
bin->runcur = NULL;
else {
......
if (bin_info->nregs != 1) {
arena_bin_runs_remove(bin, run);
}
}
}

接下来要通过arena_dalloc_bin_run()正式释放run,由于过程稍复杂,这里先给出整个算法的梗概,

  1. 计算nextind region所在page的index。所谓nextind是run内部clean-dirty region的边界。如果内部存在clean pages则执行下一步,否则执行3.
  2. 将原始的small run转化成large run,之后根据上一步得到的nextind将run切割成dirty和clean两部分,且单独释放掉clean部分。
  3. 将待remove的run pages标记为unalloc。且根据传入的dirty和cleaned两个hint决定标记后的page mapbits的dirty flag.
  4. 检查unalloc后的run pages是否可以前后合并。合并的标准是,
    1. 不超过chunk范围
    2. 前后毗邻的page同样为unalloc
    3. 前后毗邻page的dirty flag与run pages相同。
  5. 将合并后(也可能没合并)的unalloc run插入avail-tree.
  6. 检查如果unalloc run的大小等于chunk size,则将chunk释放掉。
  7. 如果之前释放run pages为dirty,则检查当前arena内部的dirty-active pages比例。若dirty数量超过了active的1/8(Android这里的标准有所不同),则启动arena purge.否则直接返回。
  8. 计算当前arena可以清理的dirty pages数量npurgatory.
  9. 从dirty tree上依次取出dirty chunk,并检查内部的unalloc dirty pages,将其重新分配为large pages,并插入到临时的queue中。
  10. 对临时队列中的dirty pages执行purge,返回值为unzeroed标记。再将purged pages的unzeroed标记设置一遍。
  11. 最后对所有purged pages重新执行一遍dalloc run操作,将其重新释放回avail-tree.

可以看到,释放run本质上是将其回收至avail-tree。但额外的dirty page机制却增加了整个算法的复杂程度。原因就在于,Je使用了不同以往的内存释放方式。

在Dl这样的经典分配器中,系统内存回收方式更加”古板”。比如在heap区需要top-most space存在大于某个threshold的连续free空间时才能进行auto-trimming。而mmap区则更要等到某个segment全部空闲才能执行munmap。这对于回收系统内存是极为不利的,因为条件过于严格。

而Je使用了更为聪明的方式,并不会直接交还系统内存,而是通过madvise暂时释放掉页面与物理页面之间的映射。本质上这同sbrk/munmap之类的调用要达到的目的是类似的,只不过从进程内部的角度看,该地址仍然被占用。但Je对这些使用过的地址都详细做了记录,因此再分配时可以recycle,并不会导致对线性地址无休止的开采。

另外,为了提高对已释放page的利用率,Je将unalloc pages用dirty flag(注意,这里同page replacement中的含义不同)做了标记。所有pages被分成active, dirty和clean三种。dirty pages表示曾经使用过,且仍可能关联着物理页面,recycle速度较快。而clean则代表尚未使用,或已经通过purge释放了物理页面,较前者速度慢。显然,需要一种内置算法来保持三种page的动态平衡,以兼顾分配速度和内存占用量。如果当前dirty pages数量超过了active pages数量的1/2^opt_lg_dirty_mult,就会启动arena_purge()。这个值默认是1/8,如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static inline void
arena_maybe_purge(arena_t *arena)
{
......
// xf: 如果当前dirty pages全部在执行purging,则直接返回。
if (arena->ndirty <= arena->npurgatory)
return;

// xf: 检查purageable pages是否超出active-dirty比率,超出则
// 执行purge。google在这里增加了ANDROID_ALWAYS_PURGE开关,
// 打开则总会执行arena_purge(默认是打开的).
#if !defined(ANDROID_ALWAYS_PURGE)
npurgeable = arena->ndirty - arena->npurgatory;
threshold = (arena->nactive >> opt_lg_dirty_mult);
if (npurgeable <= threshold)
return;
#endif

// xf: 执行purge
arena_purge(arena, false);
}

但google显然希望对dirty pages管理更严格一些,以适应移动设备上内存偏小的问题。这里增加了一个ALWAYS_PURGE的开关,打开后会强制每次释放时都执行arena_purge.

arena_run_dalloc代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
static void
arena_run_dalloc(arena_t *arena, arena_run_t *run, bool dirty, bool cleaned)
{
......
// xf: 如果run pages的dirty flag实际读取为true,且cleaned不为true,
// 则同样认为该pages在dalloc后是dirty的,否则被视为clean(该情况适用于
// chunk purge后,重新dalloc时,此时的run pages虽然dirty flag可能为ture,
// 但经过purge后应该修改为clean).
if (cleaned == false && arena_mapbits_dirty_get(chunk, run_ind) != 0)
dirty = true;
flag_dirty = dirty ? CHUNK_MAP_DIRTY : 0;

// xf: 将被remove的run标记为unalloc pages。前面的判断如果是dirty,则pages
// mapbits将带有dirty flag,否则将不带有dirty flag.
if (dirty) {
arena_mapbits_unallocated_set(chunk, run_ind, size,
CHUNK_MAP_DIRTY);
arena_mapbits_unallocated_set(chunk, run_ind+run_pages-1, size,
CHUNK_MAP_DIRTY);
} else {
arena_mapbits_unallocated_set(chunk, run_ind, size,
arena_mapbits_unzeroed_get(chunk, run_ind));
arena_mapbits_unallocated_set(chunk, run_ind+run_pages-1, size,
arena_mapbits_unzeroed_get(chunk, run_ind+run_pages-1));
}

// xf: 尝试将被remove run与前后unalloc pages 合并。
arena_run_coalesce(arena, chunk, &size, &run_ind, &run_pages,
flag_dirty);
......

// xf: 将执行过合并后的run重新insert到avail-tree
arena_avail_insert(arena, chunk, run_ind, run_pages, true, true);

// xf: 检查如果合并后的size已经完全unallocated,则dalloc整个chunk
if (size == arena_maxclass) {
......
arena_chunk_dalloc(arena, chunk);
}
if (dirty)
arena_maybe_purge(arena);
}

coalesce代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static void
arena_run_coalesce(arena_t *arena, arena_chunk_t *chunk, size_t *p_size,
size_t *p_run_ind, size_t *p_run_pages, size_t flag_dirty)
{
......
// xf: 尝试与后面的pages合并
if (run_ind + run_pages < chunk_npages &&
arena_mapbits_allocated_get(chunk, run_ind+run_pages) == 0 &&
arena_mapbits_dirty_get(chunk, run_ind+run_pages) == flag_dirty) {
size_t nrun_size = arena_mapbits_unallocated_size_get(chunk,
run_ind+run_pages);
size_t nrun_pages = nrun_size >> LG_PAGE;
......
// xf: 如果与后面的unalloc pages合并,remove page时后方的adjacent
// hint应为true
arena_avail_remove(arena, chunk, run_ind+run_pages, nrun_pages,
false, true);

size += nrun_size;
run_pages += nrun_pages;

arena_mapbits_unallocated_size_set(chunk, run_ind, size);
arena_mapbits_unallocated_size_set(chunk, run_ind+run_pages-1, size);
}

// xf: 尝试与前面的pages合并
if (run_ind > map_bias && arena_mapbits_allocated_get(chunk,
run_ind-1) == 0 && arena_mapbits_dirty_get(chunk, run_ind-1) ==
flag_dirty) {
......
}

*p_size = size;
*p_run_ind = run_ind;
*p_run_pages = run_pages;
}

avail-tree remove代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static void
arena_avail_remove(arena_t *arena, arena_chunk_t *chunk, size_t pageind,
size_t npages, bool maybe_adjac_pred, bool maybe_adjac_succ)
{
......
// xf: 该调用可能将导致chunk内部的碎片化率改变,从而影响其在dirty tree
// 中的排序。因此,在正式remove之前需要将chunk首先从dirty tree中remove,
// 待更新内部ndirty后,再将其重新insert回dirty tree.
if (chunk->ndirty != 0)
arena_chunk_dirty_remove(&arena->chunks_dirty, chunk);

// xf: maybe_adjac_pred/succ是外界传入的hint,根据该值检查前后是否存在
// clean-dirty边界。若存在边界,则remove avail pages后边界将减1.
if (maybe_adjac_pred && arena_avail_adjac_pred(chunk, pageind))
chunk->nruns_adjac--;
if (maybe_adjac_succ && arena_avail_adjac_succ(chunk, pageind, npages))
chunk->nruns_adjac--;
chunk->nruns_avail--;
......

// xf: 更新arena及chunk中dirty pages统计。
if (arena_mapbits_dirty_get(chunk, pageind) != 0) {
arena->ndirty -= npages;
chunk->ndirty -= npages;
}
// xf: 如果chunk内部dirty不为0,将其重新insert到arena dirty tree.
if (chunk->ndirty != 0)
arena_chunk_dirty_insert(&arena->chunks_dirty, chunk);

// xf: 从chunk avail-tree中remove掉unalloc pages.
arena_avail_tree_remove(&arena->runs_avail, arena_mapp_get(chunk,
pageind));
}

从avail-tree上remove pages可能会改变当前chunk内部clean-dirty碎片率,因此一开始要将其所在chunk从dirty tree上remove,再从avail-tree上remove pages。另外,arena_avail_insert()的算法同remove是一样的,只是方向相反,不再赘述。

4.4 - arena purge

清理arena的方式是按照从小到大的顺序遍历一棵dirty tree,直到将dirty pages降低到threshold以下。dirty tree挂载所有dirty chunks,同其他tree的区别在于它的cmp函数较特殊,决定了最终的purging order,如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static inline int
arena_chunk_dirty_comp(arena_chunk_t *a, arena_chunk_t *b)
{
......
if (a == b)
return (0);

{
size_t a_val = (a->nruns_avail - a->nruns_adjac) *
b->nruns_avail;
size_t b_val = (b->nruns_avail - b->nruns_adjac) *
a->nruns_avail;

if (a_val < b_val)
return (1);
if (a_val > b_val)
return (-1);
}
{
uintptr_t a_chunk = (uintptr_t)a;
uintptr_t b_chunk = (uintptr_t)b;
int ret = ((a_chunk > b_chunk) - (a_chunk < b_chunk));
if (a->nruns_adjac == 0) {
assert(b->nruns_adjac == 0);
ret = -ret;
}
return (ret);
}
}

Je在这里给出的算法是这样的,

  1. 首先排除short cut,即a和b相同的特例。
  2. 计算a, b的fragmentation,该数值越高,相应的在dirty tree上就越靠前。
    其计算方法为,
1
2
3
当前平均avail run大小    所有avail run数量 - 边界数量
--------------------- = -----------------------------
去碎片后的平均大小 所有avail run数量

注意,这个fragment不是通常意义理解的碎片。这里指由于clean-dirty边界形成的所谓碎片,并且是可以通过purge清除掉的,如图,

1
2
3
4
5
6
7
nruns_adjac = 2    
+--------+----------+--------+-------+---------+----------+--------+-----
| dirty | clean | | clean | dirty | | dirty | ...
+--------+----------+--------+-------+---------+----------+--------+-----
^ ^
| |
+--adjac #0 +--adjac #1
  1. 当a, b的fragmentation相同时,同通常的方法类似,按地址大小排序。但若nruns_adjac为0,即不存在clean-dirty边界时,反而会将低地址chunk排到后面。因为adjac为0的chunk再利用价值是比较高的,所以放到后面可以增加其在purge中的幸存几率,从而提升recycle效率。

purge代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static void
arena_purge(arena_t *arena, bool all)
{
......
// xf: 计算purgeable pages,结果加入到npurgatory信息中。
npurgatory = arena_compute_npurgatory(arena, all);
arena->npurgatory += npurgatory;

// xf: 从dirty chunk tree上逐chunk执行purge,直到期望值npurgatory为0
while (npurgatory > 0) {
......
chunk = arena_chunk_dirty_first(&arena->chunks_dirty);
// xf: traversal结束,当前线程无法完成purge任务,返回。
if (chunk == NULL) {
arena->npurgatory -= npurgatory;
return;
}
npurgeable = chunk->ndirty;
......
// xf: 如果当前chunk中purgeable大于前期计算的purgatory,
// 且其clean-dirty碎片为0,则让当前线程负责purge所有prgeable pages.
// 原因是为了尽可能避免避免多个线程对该chunk的purge竞争。
if (npurgeable > npurgatory && chunk->nruns_adjac == 0) {
arena->npurgatory += npurgeable - npurgatory;
npurgatory = npurgeable;
}
arena->npurgatory -= npurgeable;
npurgatory -= npurgeable;
npurged = arena_chunk_purge(arena, chunk, all);
// xf: 计算purge期望值npurgatory和实际purge值npurged差值
nunpurged = npurgeable - npurged;
arena->npurgatory += nunpurged;
npurgatory += nunpurged;
}
}

chunk purge如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static inline size_t
arena_chunk_purge(arena_t *arena, arena_chunk_t *chunk, bool all)
{
......
if (chunk == arena->spare) {
......
arena_chunk_alloc(arena);
}
......
// xf: 为了减小arena purge时arena lock的暂停时间,先将所有满足
// 需求的unalloc dirty pages重新"alloc"并保存,待purge结束再重新
// 释放回avail-tree.
arena_chunk_stash_dirty(arena, chunk, all, &mapelms);
npurged = arena_chunk_purge_stashed(arena, chunk, &mapelms);
arena_chunk_unstash_purged(arena, chunk, &mapelms);

return (npurged);
}

chunk purge重点在于这是一个线性查找dirty pages过程,Je在这里会导致性能下降。更糟糕的是,之前和之后都是在arena lock被锁定的条件下被执行,绑定同一arena的线程不得不停下工作。因此,在正式purge前需要先把unalloc dirtypages全部临时分配出来,当purging时解锁arena lock,而结束后再一次将它们全部释放。

stash dirty代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void
arena_chunk_stash_dirty(arena_t *arena, arena_chunk_t *chunk, bool all,
arena_chunk_mapelms_t *mapelms)
{
......
for (pageind = map_bias; pageind < chunk_npages; pageind += npages) {
arena_chunk_map_t *mapelm = arena_mapp_get(chunk, pageind);
if (arena_mapbits_allocated_get(chunk, pageind) == 0) {
......
if (arena_mapbits_dirty_get(chunk, pageind) != 0 &&
(all || arena_avail_adjac(chunk, pageind,
npages))) {
arena_run_t *run = (arena_run_t *)((uintptr_t)
chunk + (uintptr_t)(pageind << LG_PAGE));
// xf: 暂时将这些unalloc dirty pages通过split large
// 重新分配出来。
arena_run_split_large(arena, run, run_size,
false);
// 加入临时列表,留待后用。
ql_elm_new(mapelm, u.ql_link);
ql_tail_insert(mapelms, mapelm, u.ql_link);
}
} else {
//xf: 跳过allocated pages
......
}
}
......
}

stash时会根据传入的hint all判断,如果为false,只会stash存在clean-dirty adjac的pages,否则会全部加入列表。

purge stashed pages代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static size_t
arena_chunk_purge_stashed(arena_t *arena, arena_chunk_t *chunk,
arena_chunk_mapelms_t *mapelms)
{
......
// xf: 暂时解锁arena lock,前面已经realloc过,这里不考虑contention问题。
malloc_mutex_unlock(&arena->lock);
......
ql_foreach(mapelm, mapelms, u.ql_link) {
......
// xf: 逐个purge dirty page,返回pages是否unzeroed.
unzeroed = pages_purge((void *)((uintptr_t)chunk + (pageind <<
LG_PAGE)), (npages << LG_PAGE));
flag_unzeroed = unzeroed ? CHUNK_MAP_UNZEROED : 0;

// xf: 逐pages设置unzeroed标志。
for (i = 0; i < npages; i++) {
arena_mapbits_unzeroed_set(chunk, pageind+i,
flag_unzeroed);
}
......
}
// xf: purging结束重新lock arena
malloc_mutex_lock(&arena->lock);
......
return (npurged);
}

这里要注意的是,在page purge过后,会逐一设置unzero flag。这是因为有些操作系统在demand page后会有一步zero-fill-on-demand。因此,被purge过的clean page当再一次申请到物理页面时会全部填充为0.

unstash代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void
arena_chunk_unstash_purged(arena_t *arena, arena_chunk_t *chunk,
arena_chunk_mapelms_t *mapelms)
{
......
for (mapelm = ql_first(mapelms); mapelm != NULL;
mapelm = ql_first(mapelms)) {
......
run = (arena_run_t *)((uintptr_t)chunk + (uintptr_t)(pageind <<
LG_PAGE));
ql_remove(mapelms, mapelm, u.ql_link);
arena_run_dalloc(arena, run, false, true);
}
}

unstash需要再一次调用arena_run_dalloc()以释放临时分配的pages。要注意此时我们已经位于arena_run_dalloc调用栈中,而避免无限递归重入依靠参数cleaned flag.

4.5 - arena chunk dalloc

当free chunk被Je释放时,根据局部性原理,会成为下一个spare chunk而保存起来,其真身并未消散。而原先的spare则会根据内部dalloc方法被处理掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void
arena_chunk_dalloc(arena_t *arena, arena_chunk_t *chunk)
{
......
// xf: 将chunk从avail-tree上remove
arena_avail_remove(arena, chunk, map_bias, chunk_npages-map_bias,
false, false);

// xf: 如果spare不为空,则将被释放的chunk替换原spare chunk.
if (arena->spare != NULL) {
arena_chunk_t *spare = arena->spare;

arena->spare = chunk;
arena_chunk_dalloc_internal(arena, spare);
} else
arena->spare = chunk;
}

同chunk alloc一样,chunk dalloc算法也是可定制的。Je提供的默认算法chunk_dalloc_default最终会调用chunk_unmap,如下,

1
2
3
4
5
6
7
8
9
10
11
void
chunk_unmap(void *chunk, size_t size)
{
......
// xf: 如果启用dss,且当前chunk在dss内,将其record在dss tree上。
// 否则如果就记录在mmap tree上,或者直接munmap释放掉。
if (have_dss && chunk_in_dss(chunk))
chunk_record(&chunks_szad_dss, &chunks_ad_dss, chunk, size);
else if (chunk_dalloc_mmap(chunk, size))
chunk_record(&chunks_szad_mmap, &chunks_ad_mmap, chunk, size);
}

在3.3.5小节中alloc时会根据dss和mmap优先执行recycle。源自在dalloc时record在四棵chunk tree上的记录。但同spare记录的不同,这里的记录仅仅只剩下躯壳,record时会强行释放物理页面,因此recycle速度相比spare较慢。

chunk record算法如下,

  1. 先purge chunk内部所有pages
  2. 预分配base node,以记录释放后的chunk。这里分配的node到后面可能没有用,提前分配是因为接下来要加锁chunks_mtx。而如果在临界段内再分配base node,则可能因为base pages不足而申请新的chunk,这样一来就会导致dead lock.
  3. 寻找与要插入chunk的毗邻地址。首先尝试与后面的地址合并,成功则用后者的base node记录,之后执行5.
  4. 合并失败,用预分配的base node记录chunk.
  5. 尝试与前面的地址合并。
  6. 如果预分配的base node没有使用,释放掉。

代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
static void
chunk_record(extent_tree_t *chunks_szad, extent_tree_t *chunks_ad, void *chunk,
size_t size)
{
......
// xf: purge all chunk pages
unzeroed = pages_purge(chunk, size);

// xf: 预先分配extent_node以记录chunk。如果该chunk可以进行合并,该node
// 可能并不会使用。这里预先分配主要是避免dead lock。因为某些情况
// base_node_alloc同样可能会alloc base chunk,由于后面chunk mutex被lock,
// 那样将导致dead lock.
xnode = base_node_alloc();
xprev = NULL;

malloc_mutex_lock(&chunks_mtx);
// xf: 首先尝试与后面的chunk合并。
key.addr = (void *)((uintptr_t)chunk + size);
node = extent_tree_ad_nsearch(chunks_ad, &key);

if (node != NULL && node->addr == key.addr) {
extent_tree_szad_remove(chunks_szad, node);
node->addr = chunk;
node->size += size;
node->zeroed = (node->zeroed && (unzeroed == false));
extent_tree_szad_insert(chunks_szad, node);
} else {
// xf: 合并失败,用提前分配好的xnode保存当前chunk信息。
if (xnode == NULL) {
goto label_return;
}
node = xnode;
xnode = NULL;
node->addr = chunk;
node->size = size;
node->zeroed = (unzeroed == false);
extent_tree_ad_insert(chunks_ad, node);
extent_tree_szad_insert(chunks_szad, node);
}

// xf: 再尝试与前面的chunk合并
prev = extent_tree_ad_prev(chunks_ad, node);
if (prev != NULL && (void *)((uintptr_t)prev->addr + prev->size) ==
chunk) {
......
}

label_return:
malloc_mutex_unlock(&chunks_mtx);
// xf: 如果预先分配的node没有使用,则在此将之销毁
if (xnode != NULL)
base_node_dalloc(xnode);
if (xprev != NULL)
base_node_dalloc(xprev);
}

最后顺带一提,对于mmap区的pages, Je也可以直接munmap,前提是需要在jemalloc_internal_defs.h中开启JEMALLOC_MUNMAP,这样就不会执行pages purge.默认该选项是不开启的。但源自dss区中的分配则不存在反向释放一说,默认Je也不会优先选择dss就是了。

1
2
3
4
5
6
7
8
9
bool
chunk_dalloc_mmap(void *chunk, size_t size)
{

if (config_munmap)
pages_unmap(chunk, size);

return (config_munmap == false);
}

4.6 - large/huge dalloc

前面说过large/huge相当于以run和chunk为粒度的特例。因此对于arena dalloc large来说,最终就是arena_run_dalloc。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void
arena_dalloc_large_locked(arena_t *arena, arena_chunk_t *chunk, void *ptr)
{

if (config_fill || config_stats) {
size_t pageind = ((uintptr_t)ptr - (uintptr_t)chunk) >> LG_PAGE;
size_t usize = arena_mapbits_large_size_get(chunk, pageind);

arena_dalloc_junk_large(ptr, usize);
if (config_stats) {
arena->stats.ndalloc_large++;
arena->stats.allocated_large -= usize;
arena->stats.lstats[(usize >> LG_PAGE) - 1].ndalloc++;
arena->stats.lstats[(usize >> LG_PAGE) - 1].curruns--;
}
}

arena_run_dalloc(arena, (arena_run_t *)ptr, true, false);
}

而huge dalloc,则是在huge tree上搜寻,最终执行chunk_dalloc,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void
huge_dalloc(void *ptr)
{
......
malloc_mutex_lock(&huge_mtx);

key.addr = ptr;
node = extent_tree_ad_search(&huge, &key);
assert(node != NULL);
assert(node->addr == ptr);
extent_tree_ad_remove(&huge, node);

malloc_mutex_unlock(&huge_mtx);

huge_dalloc_junk(node->addr, node->size);
arena_chunk_dalloc_huge(node->arena, node->addr, node->size);
base_node_dalloc(node);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
arena_chunk_dalloc_huge(arena_t *arena, void *chunk, size_t size)
{
chunk_dalloc_t *chunk_dalloc;

malloc_mutex_lock(&arena->lock);
chunk_dalloc = arena->chunk_dalloc;
if (config_stats) {
arena->stats.mapped -= size;
arena->stats.allocated_huge -= size;
arena->stats.ndalloc_huge++;
stats_cactive_sub(size);
}
arena->nactive -= (size >> LG_PAGE);
malloc_mutex_unlock(&arena->lock);
chunk_dalloc(chunk, size, arena->ind);
}

5 - 总结: 与Dl的对比

  1. 单核单线程分配能力上两者不相上下,甚至小块内存分配速度理论上Dl还略占优势。原因是Dl利用双向链表组织free chunk可以做到O(1),而尽管Je在bitmap上做了一定优化,但不能做到常数时间。
  2. 多核多线程下,Je可以秒杀Dl。arena的加入既可以避免false sharing,又可以减少线程间lock contention。另外,tcache也是可以大幅加快多线程分配速度的技术。这些Dl完全不具备竞争力。
  3. 系统内存交换效率上也是Je占明显优势。Je使用mmap/madvise的组合要比Dl使用sbrk/mmap/munmap灵活的多。实际对系统的压力也更小。另外,Dl使用dss->mmap,追求的是速度,而Je相反mmap->dss,为的是灵活性。
  4. 小块内存的碎片抑制上双方做的都不错,但总体上个人觉得Je更好一些。首先dalloc时,两者对空闲内存都可以实时coalesce。alloc时Dl依靠dv约束外部碎片,Je更简单暴力,直接在固定的small runs里分配。
    1. 两相比较,dv的候选者是随机的,大小不固定,如果选择比较小的chunk,效果其实有限。更甚者,当找不到dv时,Dl会随意切割top-most space,通常这不利于heap trim.
    2. 而small runs则是固定大小,同时是页面的整数倍,对外部碎片的约束力和规整度上都更好。
    3. 但Dl的优势在算法更简单,速度更快。无论是coalesce还是split代价都很低。在Je中有可能因为分配8byte的内存而实际去分配并初始化4k甚至4M的空间。
  5. 大块内存分配能力上,Dl使用dst管理,而Je采用rb tree。原理上,据说rb tree的cache亲和力较差,不适合memory allocator。我没有仔细研究Je的rb tree实现有何过人之处,暂且认为各有千秋吧。可以肯定的是Je的large/huge region具有比Dl更高的内部碎片,皆因为其更规整的size class划分导致的。
  6. 说到size class,可以看到Je的划分明显比Dl更细致,tiny/small/large/huge四种分类能兼顾更多的内存使用模型。且根据不同架构和配置,可以灵活改变划分方式,具有更好的兼容性。Dl划分的相对粗糙很多且比较固定。一方面可能在当时256byte以上就可以算作大块的分配了吧。另一方面某种程度是碍于算法的限制。比如在boundary tag中为了容纳更多的信息,就不能小于8byte(实际有效的最小chunk是16byte), bin数量不得多余31个也是基于位运算的方式。
  7. bookkeeping占用上Dl因为算法简单,本应该占用更少内存。但由于boundary tag本身导致的占用,chunk数量越多,bookkeeping就越大。再考虑到系统回收效率上的劣势,应该说,应用内存占用越大,尤其是小内存使用量越多,运行时间越长,Dl相对于Je内存使用量倾向越大。
  8. 安全健壮性。只说一点,boundary tag是原罪,其他的可以免谈了。

常量时间获取 metadata

有了上面的铺垫,现在可以来看一下jemalloc中的地址运算操作及如何在常量时间获取metadata

从 run 到 region

arena_run_t中只记录了run的分配情况,并没有地址,需要快速的获取到需要分配的region的地址:

先获取到misc的地址:

1
2
arena_chunk_map_misc_t *miscelm = (arena_chunk_map_misc_t
*)((uintptr_t)run - offsetof(arena_chunk_map_misc_t, run));

获取包含该miscchunk起始地址:

1
2
3
4
/* 因为内存申请以chunk为单位,且按照chunk size对齐,所以只要将低位置零即可获取chunk起始地址 */
#define CHUNK_ADDR2BASE(a) \
((void *)((uintptr_t)(a) & ~chunksize_mask))
arena_chunk_t *chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(miscelm);

获取该miscpage id:

1
2
3
4
arena_chunk_t *chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(miscelm);
/* (该 misc 在数组中的地址偏移 / misc 大小) 即可获取是数组中第几个元素 */
size_t pageind = ((uintptr_t)miscelm - ((uintptr_t)chunk +
map_misc_offset)) / sizeof(arena_chunk_map_misc_t) + map_bias;

获取misc对应的page地址:

1
return ((void *)((uintptr_t)chunk + (pageind << LG_PAGE)));

获取对应的region:

1
2
3
/* page 起始地址 + region0 的偏移 + (region id * region size) */
ret = (void *)((uintptr_t)rpages + (uintptr_t)bin_info->reg0_offset +
(uintptr_t)(bin_info->reg_interval * regind));

从 region 到 run

当释放region时,需要快速查找region对应的runregion id:

先获取到chunk起始地址:

1
chunk = (arena_chunk_t *)CHUNK_ADDR2BASE(ptr);

获取regionpage id:

1
pageind = ((uintptr_t)ptr - (uintptr_t)chunk) >> LG_PAGE;

获取page对应的map_bits:

1
return (&chunk->map_bits[pageind-map_bias]);

根据map_bits中设置的run page offset获取run的起始page id

1
2
3
4
5
6
7
8
9
10
11
rpages_ind = pageind - arena_mapbits_small_runind_get(chunk, pageind);

JEMALLOC_ALWAYS_INLINE size_t
arena_mapbits_small_runind_get(arena_chunk_t *chunk, size_t pageind)
{
size_t mapbits;
mapbits = arena_mapbits_get(chunk, pageind);
assert((mapbits & (CHUNK_MAP_LARGE|CHUNK_MAP_ALLOCATED)) ==
CHUNK_MAP_ALLOCATED);
return (mapbits >> CHUNK_MAP_RUNIND_SHIFT);
}

获取管理该regionrun:

1
run = &arena_miscelm_get(chunk, rpages_ind)->run;

run中得到bin_info,再根据bin_info获取region id:

1
2
3
4
5
6
7
8
9
/* diff 为 region 在 run 中的偏移 */
diff = (unsigned)((uintptr_t)ptr - (uintptr_t)rpages -
bin_info->reg0_offset);
/* region id 可以通过 diff / bin_info->reg_interval 得到,但是 jemalloc 使用了复杂的运算为了提高性能,下面是它的注释 */

/*
* Avoid doing division with a variable divisor if possible. Using
* actual division here can reduce allocator throughput by over 20%!
*/

之后就可以设置region对应的bitmap进行释放了

run 的合并

前面看到run释放时会前后进行合并:

  1. 查看run相邻的后面的page是不是空闲的:
    1
    2
    /* 根据后面 page 的 map_bits 获取分配状态
    arena_mapbits_allocated_get(chunk, run_ind+run_pages) == 0

根据map_bits获取空闲page的大小:

1
2
size_t nrun_size = arena_mapbits_unallocated_size_get(chunk, run_ind+run_pages);
size_t nrun_pages = nrun_size >> LG_PAGE;

然后将大小合并,在设置首尾pagemap_bits:

1
2
3
4
5
size += nrun_size;
run_pages += nrun_pages;

arena_mapbits_unallocated_size_set(chunk, run_ind, size);
arena_mapbits_unallocated_size_set(chunk, run_ind+run_pages-1, size);

查看run相邻的前面的page是不是空闲的:

1
arena_mapbits_allocated_get(chunk, run_ind-1) == 0

根据map_bits获取空闲page的大小:

1
2
size_t prun_size = arena_mapbits_unallocated_size_get(chunk, run_ind-1);
size_t prun_pages = prun_size >> LG_PAGE;

然后将大小合并,再设置首尾pagemap_bits:

1
2
3
4
5
size += prun_size;
run_pages += prun_pages;

arena_mapbits_unallocated_size_set(chunk, run_ind, size);
arena_mapbits_unallocated_size_set(chunk, run_ind+run_pages-1, size);

由此得知,因为前后都需要进行合并,所以首尾page对应的map_bits都会设置大小。

size classes

jemalloc将对象按大小分为3类,不同大小类别的分配算法不同:

  • small:从对应bin管理的run中返回一个region
  • large:大小比chunk小,比page大,会单独返回一个run
  • huge:大小为chunk倍数,会分配chunk

在 2MiB chunk,4KiB page 的64位系统上,size classes如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
+---------+---------+--------------------------------------+
|Category | Spacing | Size |
+---------+---------+--------------------------------------+
| | lg | [8] |
| +---------+--------------------------------------+
| | 16 | [16, 32, 48, 64, 80, 96, 112, 128] |
| +---------+--------------------------------------+
| | 32 | [160, 192, 224, 256] |
| +---------+--------------------------------------+
| | 64 | [320, 384, 448, 512] |
| +---------+--------------------------------------+
|Small | 128 | [640, 768, 896, 1024] |
| +---------+--------------------------------------+
| | 256 | [1280, 1536, 1792, 2048] |
| +---------+--------------------------------------+
| | 512 | [2560, 3072, 3584, 4096] |
| +---------+--------------------------------------+
| | 1 KiB | [5 KiB, 6 KiB, 7 KiB, 8 KiB] |
| +---------+--------------------------------------+
| | 2 KiB | [10 KiB, 12 KiB, 14 KiB] |
+---------+---------+--------------------------------------+
| | 2 KiB | [16 KiB] |
| +---------+--------------------------------------+
| | 4 KiB | [20 KiB, 24 KiB, 28 KiB, 32 KiB] |
| +---------+--------------------------------------+
| | 8 KiB | [40 KiB, 48 KiB, 54 KiB, 64 KiB] |
| +---------+--------------------------------------+
| | 16 KiB | [80 KiB, 96 KiB, 112 KiB, 128 KiB] |
|Large +---------+--------------------------------------+
| | 32 KiB | [160 KiB, 192 KiB, 224 KiB, 256 KiB] |
| +---------+--------------------------------------+
| | 64 KiB | [320 KiB, 384 KiB, 448 KiB, 512 KiB] |
| +---------+--------------------------------------+
| | 128 KiB | [640 KiB, 768 KiB, 896 KiB, 1 MiB] |
| +---------+--------------------------------------+
| | 256 KiB | [1280 KiB, 1536 KiB, 1792 KiB] |
+---------+---------+--------------------------------------+
| | 256 KiB | [2 MiB] |
| +---------+--------------------------------------+
| | 512 KiB | [2560 KiB, 3 MiB, 3584 KiB, 4 MiB] |
| +---------+--------------------------------------+
| | 1 MiB | [5 MiB, 6 MiB, 7 MiB, 8 MiB] |
| +---------+--------------------------------------+
|Huge | 2 MiB | [10 MiB, 12 MiB, 14 MiB, 16 MiB] |
| +---------+--------------------------------------+
| | 4 MiB | [20 MiB, 24 MiB, 28 MiB, 32 MiB] |
| +---------+--------------------------------------+
| | 8 MiB | [40 MiB, 48 MiB, 56 MiB, 64 MiB] |
| +---------+--------------------------------------+
| | ... | ... |
+---------+---------+--------------------------------------+

small

small的分配流程如下:

  • 查找对应size classesbin
  • bin中获取run:
    • bin->runcur
    • bin->runs查找未满的run
  • arena中获取run:
    • arena->avail_runs中查找空闲run
    • 当没有合适run时,从chunk中分配run:
      • arena->spare
      • arena->cached_tree
      • arena->retained_tree
      • 调用mmap()新分配一块chunk
  • run中返回一个空闲region

small的释放流程如下:

  • 将该region返回给对应的run,即设置bitmap为空闲,增加nfree
  • run还给bin:
    • 如果run->nfree == 1,则设置为bin->runcur或者插入到bin->runs
  • 如果run->nfree == bin_info->nregs,则将该runbin分离,再将run还给arena:
    • 尝试与相同chunk中前后相邻的空闲run进行合并,然后插入到arena->avail_runs
    • 若合并完后,整个chunk为空,则尝试与连续地址空间的空闲chunk进行合并,然后插入到arena->cached_tree

large

分配large和分配small类似:

  • 先从arena->avail_runs中查找,因为large object不由bin管理,所以与small object相比,少了从bin->runs中查找的一步
  • 分配chunk,步骤和small一样,然后从chunk中分配需要的run大小,此时run的大小为单个object的大小,而small run的大小会从bin_info[]中获取

因为large大小是page的倍数,且会按照page size地址对齐,有可能造成cache line颠簸, 所以会根据配置多分配一个page,用于内存着色,防止cache line的颠簸

1
2
3
4
5
6
7
8
9
10
11
12
13
if (config_cache_oblivious) {
uint64_t r;

/*
* Compute a uniformly distributed offset within the first page
* that is a multiple of the cacheline size, e.g. [0 .. 63) * 64
* for 4 KiB pages and 64-byte cachelines.
*/
prng64(r, LG_PAGE - LG_CACHELINE, arena->offset_state,
UINT64_C(6364136223846793009),
UINT64_C(1442695040888963409));
random_offset = ((uintptr_t)r) << LG_CACHELINE;
}

largesmallarena_chunk_map_misc_t格式也不同,large只在首个page设置run的大小。释放流程和small一样,只是缺少了runbin中的处理,直接将run还给arena

huge

huge object大小比chunk大。分配策略和上面分配chunk一样:

  • arena中分配extent_node_t
  • arena中分配chunk:
    • arena->cached_tree中分配chunk
    • arena->retained_tree中分配
    • 调用mmap()新分配一块chunk
  • chunknode插入到chunks_rtree
  • 插入到arena->huge链表中

释放和分配过程相反:

  • chunks_rtree中获取chunk对应的node,从而获取对应的arena
  • 移出arena->huge
  • 释放chunk,插入到arena->cached_tree
  • 释放node

huge使用了线程间共享的chunks_rtree来保存信息,这会导致锁的竞争,但是应用程序很少会分配如此大的内存,所以带来的影响很小。

purge

前面的释放只是将之前分配的缓存起来,备用,现在来看一下真正的释放操作。

arena中会统计dirtyactive的数目:

  • nactive:已经分配出去的page数目
  • ndirty:分配出去又被释放的page数目
  • arena中会保存最多nactive >> lg_dirty_multdirty pages暂存使用,当超出时,就会释放掉多余的部分。

purge按照page维度进行回收。arenaruns_dirtychunks_cache存放着dirty pages,当runchunk被释放时,会插入到这里(chunk也会插入到runs_dirty中,同时也插入到chunks_cache):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
*
* Unused dirty memory this arena manages. Dirty memory is conceptually
* tracked as an arbitrarily interleaved LRU of dirty runs and cached
* chunks, but the list linkage is actually semi-duplicated in order to
* avoid extra arena_chunk_map_misc_t space overhead.
*
* LRU-----------------------------------------------------------MRU
*
* /-- arena ---\
* | |
* | |
* |------------| /- chunk -\
* ...->|chunks_cache|<--------------------------->| /----\ |<--...
* |------------| | |node| |
* | | | | | |
* | | /- run -\ /- run -\ | | | |
* | | | | | | | | | |
* | | | | | | | | | |
* |------------| |-------| |-------| | |----| |
* ...->|runs_dirty |<-->|rd |<-->|rd |<---->|rd |<----...
* |------------| |-------| |-------| | |----| |
* | | | | | | | | | |
* | | | | | | | \----/ |
* | | \-------/ \-------/ | |
* | | | |
* | | | |
* \------------/ \---------/
*

在每次dalloc run/chunk时都会调用arena_maybe_purge()尝试purgearena根据lg_dirty_mult判断是否需要purge,当(nactive >> lg_dirty_mult) <= ndirty时进行purge,默认配置为8 : 1

purge分为4步:

  • arena_compute_npurge():返回需要purgepage数目,为超出nactive >> lg_dirty_multpage数。
  • arena_stash_dirty():将需要purge的部分从arena->cached_treearena->avail_runs中移除,防止purge过程中被其他线程分配出去,并插入到需要purge的循环链表中。
  • arena_purge_stashed():将循环链表中的run进行purge
  • arena_unstash_purged():将chunk进行purge。将purged插入到arena->cached_treearena->avail_runs,留待后面分配。

chunkrun采取不同的purge

  • 对于run而言,并不是真正的释放,根据操作系统的不同,会使用不同的方式,在linux中会调用madvise(addr, size, MADV_DONTNEED)
  • jemallocchunk为单位向操作系统申请内存,在释放chunk时,会尽量调用munmap()(因为根据操作系统和配置的不同,chunk的来源也不同),否则会类似run,调用madvise()然后再插入到chunk_retained_tree中,留待后续分配。

jemalloc中单线程的部分就到此结束了,下面开始看jemalloc是如何提升多线程性能的。

多线程

jemalloc的一个目标就是提高多线程的性能,多线程的分配思路和单线程是一样的,每个线程还是从arena中分配内存,不过会多了线程间的同步和竞争。想要提高多线程性能,主要通过下面 2 个方式:

  • 减少锁的竞争:缩小临界区,更细粒度的锁
  • 避免锁的竞争:线程间不共享数据,使用局部变量、线程特有数据(tsd)、线程局部存储(tls)等

arena

jemalloc会创建多个arena,每个线程由一个arena负责。在malloc_init_hard_finish()中会设置arena的相关配置,narenas_autonarenas_total都设置为cpu核数*4,默认最多创建那么多arenaarena->nthreads记录负责的线程数量。

每个线程分配时会首先调用arena_choose()选择一个arena来负责该线程的分配。选择arena的逻辑如下:

  1. 若有空闲的(nthreads==0)已创建的arena,则选择该arena
  2. 若还有未创建的arena,则选择新创建一个arena
  3. 选择负载最低的arena(nthreads最小)

mutex尽量使用spinlock,减少线程间的上下文切换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
JEMALLOC_INLINE void
malloc_mutex_lock(malloc_mutex_t *mutex)
{

if (isthreaded) {
#ifdef _WIN32
# if _WIN32_WINNT >= 0x0600
AcquireSRWLockExclusive(&mutex->lock);
# else
EnterCriticalSection(&mutex->lock);
# endif
#elif (defined(JEMALLOC_OSSPIN))
OSSpinLockLock(&mutex->lock);
#else
pthread_mutex_lock(&mutex->lock);
#endif
}
}

为了缩小临界区,arena中有多个锁管理不同的部分:

  • arenas_lockarena的初始化、分配等
  • arena->lockrunchunk的管理
  • arena->huge_mtxhuge object的管理
  • bin->lockbin中的操作

tsd

当选择完arena后,会将arena绑定到tsd中,之后会直接从tsd中获取arena

tsd用于保存每个线程特有的数据,主要是arenatcache,避免锁的竞争。tsd_t中的数据会在第一次访问时延迟初始化(调用相应的get_hard()),tsd中各元素使用宏生成对应的get/set函数来获取/设置,在线程退出时,会调用相应的cleanup函数清理。下面只介绍linux平台中的实现。

linux中会使用tls(__thread)tsd(pthread_key_create(), pthread_setspecific())来实现:

1
2
3
4
5
6
#elif (defined(JEMALLOC_TLS))
#define malloc_tsd_data(a_attr, a_name, a_type, a_initializer) \
a_attr __thread a_type JEMALLOC_TLS_MODEL \
a_name##tsd_tls = a_initializer; \
a_attr pthread_key_t a_name##tsd_tsd; \
a_attr bool a_name##tsd_booted = false;

  • __thread保存需要线程局部存储的数据tsd_t
  • pthread_key_tkey__thread联系起来,用于注册destructor,在线程退出时清理tsd_t

其实可以只用pthread_key_t来实现,但使用__thread可以直接获取数据,不用再调用pthread_getspecific()

tcache

tcache用于smalllarge的分配,避免多线程的同步。

1
2
3
4
5
6
7
"opt.tcache" (bool) r- [--enable-tcache]
Thread-specific caching (tcache) enabled/disabled. When there are multiple threads, each thread uses a tcache for objects up to a certain size. Thread-specific caching allows many allocations to be satisfied without performing any
thread synchronization, at the cost of increased memory use. See the "opt.lg_tcache_max" option for related tuning information. This option is enabled by default unless running inside Valgrind[2], in which case it is forcefully
disabled.

"opt.lg_tcache_max" (size_t) r- [--enable-tcache]
Maximum size class (log base 2) to cache in the thread-specific cache (tcache). At a minimum, all small size classes are cached, and at a maximum all large size classes are cached. The default maximum is 32 KiB (2^15).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct tcache_s {
ql_elm(tcache_t) link; /* Used for aggregating stats. */
uint64_t prof_accumbytes;/* Cleared after arena_prof_accum(). */
unsigned ev_cnt; /* Event count since incremental GC. */
szind_t next_gc_bin; /* Next bin to GC. */
tcache_bin_t tbins[1]; /* Dynamically sized. */
/*
* The pointer stacks associated with tbins follow as a contiguous
* array. During tcache initialization, the avail pointer in each
* element of tbins is initialized to point to the proper offset within
* this array.
*/
};

struct tcache_bin_s {
tcache_bin_stats_t tstats;
int low_water; /* Min # cached since last GC. */
unsigned lg_fill_div; /* Fill (ncached_max >> lg_fill_div). */
unsigned ncached; /* # of cached objects. */
void **avail; /* Stack of available objects. */
};

tcache同样使用slab算法分配:

  • tcache中有多种bin,每个bin管理一个size class
  • 当分配时,从对应bin中返回一个cache slot
  • 当释放时,将cache slot返回给对应的bin

tcacheavail是指针数组,每个数组元素指向对应的cache slotcache slot是从arena中分配的,缓存在tcache中。

tcache_boot():根据配置opt.lg_tcache_max设置tcachebin的范围(nhbins)。设置tcache_bin_info,保存每种bincache slots个数(类似arena_bin_infonregs),smallTCACHE_NSLOTS_SMALL_MINTCACHE_NSLOTS_SMALL_MAX间,large固定为TCACHE_NSLOTS_LARGE

tcache_create()tcache_t中保存着tbins[]信息,tcache_bin_tavail指向每一个cache slot(类似arena->binregion),tcache_create()根据tcache_boot()设置的配置分配tcache_ttcache_bin_t的内存,tcache_ttbins[]为连续内存,tbins[]avail使用后面连续空间的内存。

small

small分配流程如下:

  1. tcache_alloc_small():先获取对应的tbin,调用tcache_alloc_easy(),若tbin中还有剩余的元素,返回tbin->avail[tbin->ncached](从后往前分配,ncached既是剩余数量也是索引),tbin->low_water保存着tbin->ncached的最小值。
  2. tcache_alloc_small_hard()tbin已空,先调用arena_tcache_fill_small()重新装载tbin,再调用tcache_alloc_easy()分配。
  3. arena_tcache_fill_small():从arena中对应的bin分配region保存在tbin->avail中,只会填充ncached_max >> lg_fill_div个。

small释放流程如下:

  1. tcache_dalloc_small():通过ptr对应的map_bits获取binind,然后将ptr释放(保存在tbin->avail[tbin->ncached],同时tbin->ncached++)。若该tbin已满(tbin->ncached == tbin_info->ncached_max),会调用tcache_bin_flush_small(),释放一半cache slotsarena
  2. tcache_bin_flush_small():会释放tbin中部分avail返回给arena中对应的bin,这里为了减少锁的调用,会在一次加锁中,释放所有对应该锁(bin)的cache slot

large

分配和small类似,先调用tcache_alloc_easy(),不过若tbin为空时,不会像small一样分配所有的avail,而是调用arena_malloc_large()arena中分配一个run。因为创建多个large object太过昂贵,并且有可能会用不到,浪费空间。

释放和small类似,先释放到tbin->avail[tbin->ncached]中,备用。若该tbin已满,调用arena_bin_flush_large()释放一半到arena中。

gc

前面注意到,当从arena分配small时, 会分配ncached_max >> lg_fill_div个,若每次均分配固定数目,有可能会造成内存浪费,jemalloctcache中的bin采用渐进式GC,动态的调整分配数目。有 2 个宏控制着GC的进行:

  • TCACHE_GC_SWEEP:可以近似认为每发生该数量的分配或释放操作,所有的bin都被GC
  • TCACHE_GC_INCR:每发生该数量的分配或释放操作,单个bin进行一次GC

tcache中每个bin会有如下2个字段:

  • low_water:保存着一次GC时间间隔内,ncached的最小值,也就意味着在这之下的avail都没被分配
  • lg_fill_div:用于控制每次分配的数量(ncached_max >> lg_fill_div),初始为 1

在每次分配和释放时,都会调用tcache_event(),增加tcache->ev_cnt,若和TCACHE_GC_INCR相等,则调用tcache_event_hard()对单个bin进行GC(只对small object有效)。tcache_event_hard():对单个bin(next_gc_bin)进行GC:

  1. tbin->low_water > 0:说明tbin->avail中有些未被用到,可以尝试减少分配。对应的操作就是释放掉3/4 low_waterlg_fill_div++(下次分配时会减少一半)
  2. tbin->low_water < 0:只有在该tbin->avail全部分配完才会置low_water = -1,说明不够用,所以会lg_fill_div--(下次分配时加倍)

tcache中的tbin分配数量就会一直动态调整。

线程退出

线程退出时,会调用tsd_cleanup()tsd中数据进行清理:

  • arena:降低arena负载(arena->nthreads--)
  • tcache:调用tcache_bin_flush_small/large()释放tcache->tbins[]所有元素,释放tcache本身

当从一个线程分配的内存由另一个线程释放时,该内存还是由原先的arena来管理,通过chunkextent_node_t来获取对应的arena

总结

jemalloc中大量使用了宏生成代码,比较晦涩,不过其他部分还是比较清楚的,只要理解了它的思路就容易看懂,一层一层的。现在来总结一下jemalloc的思路:

  • 通过避免false cache line sharing,使用内存着色等,提高cache line效率
  • 使用slab分配不同大小的对象,精心选择size classes,减少内存碎片
  • 使用多层缓存,内存的释放和分配会经历很多阶段,提升速度
  • metadata存放在连续内存,降低metadataoverhead,同时能减少active pages
  • 地址对齐从而在常量时间内获取metadata
  • 首先复用低地址的内存,减少active pages
  • 使用多个arena管理、更细粒度的锁、tsdtcache等,最小化锁竞争

JeMalloc-5.1.0 版本

这篇文章介绍JeMalloc-5.1.0 版本(release 日期:2018年5月9日)的实现细节。

对于对老版本比较熟悉的人来说,有几点需要说明:

  • chunk这一概念被替换成了 extent
  • dirty pagedecay(或者说 gc) 变成了两阶段,dirty -> muzzy -> retained
  • huge class这一概念不再存在
  • 红黑树不再使用,取而代之的是 pairing heap

基础知识

以下内容介绍 JeMalloc 中比较重要的概念以及数据结构。

size_class

每个size_class代表jemalloc分配的内存大小,共有NSIZES(232)个小类(如果用户申请的大小位于两个小类之间,会取较大的,比如申请14字节,位于8和16字节之间,按16字节分配),分为2大类:

  • small_class(小内存) : 对于64位机器来说,通常区间是 [8, 14kb],常见的有 8, 16, 32, 48, 64, …, 2kb, 4kb, 8kb,注意为了减少内存碎片并不都是2的次幂,比如如果没有48字节,那当申请33字节时,分配64字节显然会造成约50%的外部碎片
  • large_class(大内存): 对于64位机器来说,通常区间是 [16kb, 7EiB],从 4 * page_size 开始,常见的比如 16kb, 32kb, …, 1mb, 2mb, 4mb,最大是2^63 + 3^60
  • size_index: size 位于 size_class 中的索引号,区间为 [0,231],比如8字节则为0,14字节(按16计算)为1,4kb字节为28,当sizesmall_class时,size_index也称作binind

base

用于分配 jemalloc 元数据内存的结构,通常一个 base 大小为 2mb, 所有 base 组成一个链表。

  • base.extents[NSIZES]: 存放每个size_classextent元数据

bin

管理正在使用中的slab(即用于小内存分配的 extent) 的集合,每个bin对应一个size_class

  • bin.slabcur: 当前使用中的 slab
  • bin.slabs_nonfull: 有空闲内存块的 slab

extent

管理 jemalloc 内存块(即用于用户分配的内存)的结构,每一个内存块大小可以是N * page_size(4kb)(N >= 1)。每个extent有一个序列号(serial number)。

一个extent可以用来分配一次large_class的内存申请,但可以用来分配多次small_class的内存申请。

  • extent.e_bits: 8字节长,记录多种信息
  • extent.e_addr: 管理的内存块的起始地址
  • extent.e_slab_data: 位图,当此extent用于分配small_class内存时,用来记录这个extent的分配情况,此时每个extent的内的小内存称为region

slab

extent用于分配small_class内存时,称其为slab。一个extent可以被用来处理多个同一size_class的内存申请。

extents

管理extent的集合。

  • extents.heaps[NPSIZES+1]: 各种 page(4kb) 倍数大小的 extent
  • extents.lru: 存放所有extent的双向链表
  • extents.delay_coalesce: 是否延迟extent的合并

arena

用于分配&回收extent的结构,每个用户线程会被绑定到一个arena上,默认每个逻辑 CPU 会有 4 个arena来减少锁的竞争,各个arena所管理的内存相互独立。

  • arena.extents_dirty:刚被释放后空闲extent位于的地方
  • arena.extents_muzzyextents_dirty进行lazy purge后位于的地方,dirty -> muzzy
  • arena.extents_retainedextents_muzzy进行decommitforce purgeextent位于的地方,muzzy -> retained
  • arena.large:存放large extent的 extents
  • arena.extent_availheap,存放可用的extent元数据
  • arena.bins[NBINS]:所以用于分配小内存的bin
  • arena.base:用于分配元数据的base

rtree

全局唯一的存放每个extent信息的 Radix Tree,以extent->e_addruintptr_tkey,以我的机器为例,uintptr_t为64位(8字节),rtree的高度为3,由于extent->e_addrpage(1 << 12)对齐的,也就是说需要 64 - 12 = 52 位即可确定在树中的位置,每一层分别通过第0-16位,17-33位,34-51位来进行访问。

cache_bin

每个线程独有的用于分配小内存的缓存

  • low_water:上一次 gc 后剩余的缓存数量
  • cache_bin.ncached:当前 cache_bin 存放的缓存数量
  • cache_bin.avail:可直接用于分配的内存,从左往右依次分配(注意这里的寻址方式)

tcache

每个线程独有的缓存(Thread Cache),大多数内存申请都可以在 tcache 中直接得到,从而避免加锁

  • tcache.bins_small[NBINS]:小内存的cache_bin

tsd

Thread Specific Data,每个线程独有,用于存放与这个线程相关的结构

  • tsd.rtree_ctx:当前线程的rtree context,用于快速访问extent信息
  • tsd.arena:当前线程绑定的arena
  • tsd.tcache:当前线程的tcache

内存分配(malloc)

小内存(small_class)分配

首先从tsd->tcache->bins_small[binind]中获取对应size_class的内存,有的话将内存直接返回给用户,如果bins_small[binind]中没有的话,需要通过slab(extent)tsd->tcache->bins_small[binind]进行填充,一次填充多个以备后续分配,填充方式如下(当前步骤无法成功则进行下一步):

  1. 通过bin->slabcur分配
  2. bin->slabs_nonfull中获取可使用的extent
  3. arena->extents_dirty中回收extent,回收方式为best-fit,即满足大小要求的最小extent,在arena->extents_dirty->bitmap中找到满足大小要求并且第一个非空heap的索引i,然后从extents->heaps[i]中获取第一个extent。由于extent可能较大,为了防止产生内存碎片,需要对extent进行分裂(伙伴算法),然后将分裂后不使用的extent放回extents_dirty
  4. arena->extents_muzzy中回收extent,回收方式为first-fit,即满足大小要求且序列号最小地址最低(最旧)的extent,遍历每个满足大小要求并且非空的arena->extents_dirty->bitmap,获取其对应extents->heaps中第一个extent,然后进行比较,找到最旧的extent,之后仍然需要分裂
  5. arena->extents_retained中回收extent,回收方式与extents_muzzy相同
  6. 尝试通过mmap向内核获取所需的extent内存,并且在rtree中注册新extent的信息
  7. 再次尝试从bin->slabs_nonfull中获取可使用的extent

简单来说,这个流程是这样的,cache_bin -> slab -> slabs_nonfull -> extents_dirty -> extents_muzzy -> extents_retained -> kernal

大内存(large_class)分配

大内存不存放在tsd->tcache中,因为这样可能会浪费内存,所以每次申请都需要重新分配一个extent,申请的流程和小内存申请extent流程中的3, 4, 5, 6是一样的。

内存释放(free)

小内存释放

rtree中找到需要被释放内存所属的extent信息,将要被释放的内存还给tsd->tcache->bins_small[binind],如果tsd->tcache->bins_small[binind]已满,需要对其进行flush,流程如下:

  1. 将这块内存返还给所属extent,如果这个extent中空闲的内存块变成了最大(即没有一份内存被分配),跳到2;如果这个extent中的空闲块变成了1并且这个extent不是arena->bins[binind]->slabcur,跳到3
  2. 将这个extent释放,即插入arena->extents_dirty
  3. arena->bins[binind]->slabcur切换为这个extent,前提是这个extent“更旧”(序列号更小地址更低),并且将替换后的extent移入arena->bins[binind]->slabs_nonfull

大内存释放

因为大内存不存放在tsd->tcache中,所以大内存释放只进行小内存释放的步骤2,即将extent插入arena->extents_dirty中。

内存再分配(realloc)

小内存再分配

  1. 尝试进行 no move 分配,如果之前的实际分配满足条件的话,可以不做任何事情,直接返回。比如第一次申请了12字节,但实际上 jemalloc 会实际分配16字节,然后第二次申请将12扩大到15字节或者缩小到9字节,那这时候16字节就已经满足需求了,所以不做任何事情,如果无法满足,跳到2
  2. 重新分配,申请新内存大小(参考内存分配),然后将旧内存内容拷贝到新地址,之后释放旧内存(参考内存释放),最后返回新内存

大内存再分配

  1. 尝试进行 no move 分配,如果两次申请位于同一 size class 的话就可以不做任何事情,直接返回。
  2. 尝试进行 no move resize 分配,如果第二次申请的大小大于第一次,则尝试对当前地址所属extent的下一地址查看是否可以分配,比如当前extent地址是 0x1000,大小是 0x1000,那么我们查看地址 0x2000 开始的extent是否存在(通过 rtree)并且是否满足要求,如果满足要求那两个extent可以进行合并,成为一个新的extent而不需要重新分配;如果第二次申请的大小小于第一次,那么尝试对当前extent进行split,移除不需要的后半部分,以减少内存碎片;如果无法进行 no move resize 分配,跳到3
  3. 重新分配,申请新内存大小(参考内存分配),然后将旧内存内容拷贝到新地址,之后释放旧内存(参考内存释放),最后返回新内存

内存 GC

分为2种, tcache 和 extent GC。其实更准确来说是 decay,为了方便还是用 gc 吧。

tcache GC

针对 small_class,防止某个线程预先分配了内存但是却没有实际分配给用户,会定期将缓存 flush 到 extent。

GC 策略:每次对于 tcache 进行 malloc 或者 free 操作都会执行一次计数,默认情况下达到228次就会触发 gc,每次 gc 一个cache_bin

如何 GC:

  1. cache_bin.low_water > 0: gc 掉 low_water 的 3/4,同时,将cache_bin能缓存的最大数量缩小一倍
  2. cache_bin.low_water < 0: 将cache_bin能缓存的最大数量增大一倍

总的来说保证当前cache_bin分配越频繁,则会缓存更多的内存,否则则会减少。

extent GC

调用free时,内存并没有归还给内核。jemalloc内部会不定期地将没有用于分配的extent逐步GC,流程和内存申请是反向的,free -> extents_dirty -> extents_muzzy -> extents_retained -> kernal

GC 策略:默认10s为extents_dirtyextents_muzzy的一个 gc 周期,每次对于arena进行malloc或者free操作都会执行一次计数,达到1000次会检测有没有达到gcdeadline,如果是的话进行 gc。

注意并不是每隔10s一次性 gc,实际上 jemalloc 会将10s划分成200份,即每隔0.05s进行一次 gc,这样一来如果t时刻有Npage需要gc,那么jemalloc尽力保证在t+10时刻这Npage会被gc完成。

对于N个需要gcpage来说,并不是简单地每0.05s处理N/200pagejemalloc引入了Smoothstep(主要用于计算机图形学)来获得更加平滑的gc机制,这也是 jemalloc 非常有意思的一个点。

jemalloc 内部维护了一个长度为200的数组,用来计算在10s的 gc 周期内每个时间点应该对多少 page 进行 gc。这样保证两次 gc 的时间段内产生的需要 gc 的 page 都会以图中绿色线条(默认使用 smootherstep)的变化曲线在10s的周期内从 N 减为 0(从右往左)。

如何 GC:先进行extents_dirty的 gc,后进行extents_muzzy

  1. extents_dirty中的extent移入extents_muzzy
    1. extents_dirty中的LRU链表中,获得要进行gcextent,尝试对extent进行前后合并(前提是两个extent位于同一arena并且位于同一extents中),获得新的extent,然后将其移除
    2. 对当前extent管理的地址进行lazy purge,即通过madvise使用MADV_FREE参数告诉内核当前extent管理的内存可能不会再被访问
    3. extents_muzzy中尝试对当前extent进行前后合并,获得新的extent,最后将其插入extents_muzzy
  2. extents_muzzy中的extent移入extents_retained:
    1. extents_muzzy中的LRU链表中,获得要进行gcextent,尝试对extent进行前后合并,获得新的extent,然后将其移除
    2. 对当前extent管理的地址进行decommit,即调用mmap带上PROT_NONE告诉内核当前extent管理的地址可能不会再被访问,如果decommit失败,会进行force purge,即通过madvise使用MADV_DONTNEED参数告诉内核当前extent管理的内存可能不会再被访问
    3. extents_retained中尝试对当前extent进行前后合并,获得新的extent,最后将其插入extents_retained
  3. jemalloc 默认不会将内存归还给内核,只有进程结束时,所有内存才会munmap,从而归还给内核。不过可以手动进行arena的销毁,从而将extents_retained中的内存进行munmap

附: 快速调试Jemalloc

一个简单的调试Je的方法是以静态库的方式将其编译到你的应用程序中。先编译Je的静态库,在源码目录下执行,

1
2
3
./configure
make
make install

就可以编译并安装Je到系统路径。调试还必须打开一些选项,例如,

1
./configure --enable-debug  --with-jemalloc-prefix=<prefix>

这些选项的意义可以参考INSTALL文档。比如,

  • --disable-tcache 是否禁用tcache,对调试非tcache流程有用。
  • --disable-prof 是否禁用heap profile.
  • --enable-debug 打开调试模式,启动assert并关闭优化。
  • --with-jemalloc-prefix 将编译出的malloc加上设定的前缀,以区别c库的调用。

之后就可以将其编译到你的代码中,如,

1
gcc main.c /usr/local/lib/libjemalloc.a -std=c99 -O0 -g3 -pthread -o jhello

基础知识

X86平台Linux进程内存布局

Linux系统在装载elf格式的程序文件时,会调用loader把可执行文件中的各个段依次载入到从某一地址开始的空间中(载入地址取决link editor(ld)和机器地址位数,在32位机器上是0x8048000,即128M处)。如下图所示,以32位机器为例,首先被载入的是.text段,然后是.data段,最后是.bss段。这可以看作是程序的开始空间。程序所能访问的最后的地址是0xbfffffff,也就是到3G地址处,3G以上的1G空间是内核使用的,应用程序不可以直接访问。应用程序的堆栈从最高地址处开始向下生长,.bss段与堆栈之间的空间是空闲的,空闲空间被分成两部分,一部分为heap,一部分为mmap映射区域,mmap映射区域一般从TASK_SIZE/3的地方开始。

heapmmap区域都可以供用户自由使用,但是它在刚开始的时候并没有映射到内存空间内,是不可访问的。在向内核请求分配该空间之前,对这个空间的访问会导致segmentation fault。用户程序可以直接使用系统调用来管理heapmmap映射区域,但更多的时候程序都是使用C语言提供的malloc()free()函数来动态的分配和释放内存。 Stack`区域是唯一不需要映射,用户却可以访问的内存区域,这也是利用堆栈溢出进行攻击的基础。

32位模式下进程内存经典布局


这种布局是Linux内核2.6.7以前的默认进程内存布局形式,mmap区域与栈区域相对增长,这意味着堆只有1GB的虚拟地址空间可以使用,继续增长就会进入mmap映射区域,这显然不是我们想要的。这是由于32模式地址空间限制造成的,所以内核引入了另一种虚拟地址空间的布局形式。但对于64位系统,提供了巨大的虚拟地址空间,这种布局就相当好。

32位模式下进程默认内存布局


从上图可以看到,栈至顶向下扩展,并且栈是有界的。堆至底向上扩展,mmap映射区域至顶向下扩展,mmap映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域,这种结构便于C运行时库使用mmap映射区域和堆进行内存分配。上图的布局形式是在内核2.6.7以后才引入的,这是32位模式下进程的默认内存布局形式。

64位模式下进程内存布局

在64位模式下各个区域的起始位置是什么呢?对于AMD64系统,内存布局采用经典内存布局,text的起始地址为0x0000000000400000,堆紧接着BSS段向上增长,mmap映射区域开始位置一般设为TASK_SIZE/3

1
2
3
4
5
#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE)
#define TASK_SIZE (test_thread_flag(TIF_IA32) ? \
IA32_PAGE_OFFSET : TASK_SIZE_MAX)
#define STACK_TOP TASK_SIZE
#define TASK_UNMAPPED_BASE (PAGE_ALIGN(TASK_SIZE / 3))

计算一下可知,mmap的开始区域地址为0x00002AAAAAAAA000,栈顶地址为0x00007FFFFFFFF0006


上图是X86_64Linux进程的默认内存布局形式,这只是一个示意图,当前内核默认配置下,进程的栈和mmap映射区域并不是从一个固定地址开始,并且每次启动时的值都不一样,这是程序在启动时随机改变这些值的设置,使得使用缓冲区溢出进行攻击更加困难。当然也可以让进程的栈和mmap映射区域从一个固定位置开始,只需要设置全局变量randomize_va_space值为0,这个变量默认值为1。用户可以通过设置/proc/sys/kernel/randomize_va_space来停用该特性,也可以用如下命令:

1
sudo sysctl -w kernel.randomize_va_space=0

操作系统内存分配的相关函数

上节提到heapmmap映射区域是可以提供给用户程序使用的虚拟内存空间,如何获得该区域的内存呢?操作系统提供了相关的系统调用来完成相关工作。对heap的操作,操作系统提供了brk()函数,C运行时库提供了sbrk()函数;对mmap映射区域的操作,操作系统提供了mmap()munmap()函数。sbrk()brk()或者mmap()都可以用来向我们的进程添加额外的虚拟内存。Glibc同样是使用这些函数向操作系统申请虚拟内存。

这里要提到一个很重要的概念,内存的延迟分配,只有在真正访问一个地址的时候才建立这个地址的物理映射,这是Linux内存管理的基本思想之一。 Linux`内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。内核释放物理页面是通过释放线性区,找到其所对应的物理页面,将其全部释放的过程。

heap操作相关函数

heap操作函数主要有两个,brk()为系统调用,sbrk()C库函数。系统调用通常提供一种最小功能,而库函数通常提供比较复杂的功能。Glibcmalloc函数族(realloccalloc等)就调用sbrk()函数将数据段的下界移动,sbrk()函数在内核的管理下将虚拟地址空间映射到内存,供malloc()函数使用。

内核数据结构mm_struct中的成员变量start_codeend_code是进程代码段的起始和终止地址,start_dataend_data是进程数据段的起始和终止地址,start_stack是进程堆栈段起始地址,start_brk是进程动态内存分配起始地址(堆的起始地址),还有一个brk(堆的当前最后地址),就是动态内存分配当前的终止地址。C语言的动态内存分配基本函数是malloc(),在Linux上的实现是通过内核的brk系统调用。brk()是一个非常简单的系统调用,只是简单地改变mm_struct结构的成员变量brk的值。

这两个函数的定义如下:

1
2
3
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);

需要说明的是,sbrk()的参数increment为0时,sbrk()返回的是进程的当前brk值,increment为正数时扩展brk值,当increment为负值时收缩brk值。

mmap映射区域操作相关函数

mmap()函数将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap执行相反的操作,删除特定地址区域的对象映射。函数的定义如下:

1
2
3
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

参数:

  • start:映射区的开始地址。
  • length:映射区的长度。
  • prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起。ptmalloc中主要使用了如下的几个标志:
    • PROT_EXEC//页内容可以被执行,ptmalloc中没有使用
    • PROT_READ//页内容可以被读取,ptmalloc直接用mmap分配内存并立即返回给用户时设置该标志
    • PROT_WRITE//页可以被写入,ptmalloc直接用mmap分配内存并立即返回给用户时设置该标志
    • PROT_NONE//页不可访问,ptmallocmmap向系统“批发”一块内存进行管理时设置该标志
  • flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
    • MAP_FIXED //使用指定的映射起始地址,如果由startlen参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。ptmalloc在回收从系统中“批发”的内存时设置该标志。
    • MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。ptmalloc每次调用mmap都设置该标志。
    • MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。 ptmalloc`向系统“批发”内存块时设置该标志。
    • MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。ptmalloc每次调用mmap都设置该标志。
  • fd:有效的文件描述词。如果MAP_ANONYMOUS被设定,为了兼容问题,其值应为-1。
  • offset:被映射对象内容的起点。

概述

内存管理一般性描述

当不知道程序的每个部分将需要多少内存时,系统内存空间有限,而内存需求又是变化的,这时就需要内存管理程序来负责分配和回收内存。程序的动态性越强,内存管理就越重要,内存分配程序的选择也就更重要。

内存管理的方法

可用于内存管理的方法有许多种,它们各有好处与不足,不同的内存管理方法有最适用的情形。

C风格的内存管理程序

C风格的内存管理程序主要实现malloc()free()函数。内存管理程序主要通过调用brk()或者mmap()进程添加额外的虚拟内存。Doug Lea MallocptmallocBSD mallocHoardTCMalloc都属于这一类内存管理程序。

基于malloc()的内存管理器仍然有很多缺点,不管使用的是哪个分配程序。对于那些需要保持长期存储的程序使用malloc()来管理内存可能会非常令人失望。如果有大量的不固定的内存引用,经常难以知道它们何时被释放。生存期局限于当前函数的内存非常容易管理,但是对于生存期超出该范围的内存来说,管理内存则困难得多。因为管理内存的问题,很多程序倾向于使用它们自己的内存管理规则。

池式内存管理

内存池是一种半内存管理方法。内存池帮助某些程序进行自动内存管理,这些程序会经历一些特定的阶段,而且每个阶段中都有分配给进程的特定阶段的内存。在池式内存管理中,每次内存分配都会指定内存池,从中分配内存。每个内存池都有不同的生存期限。另外,有一些实现允许注册清除函数(cleanup functions),在清除内存池之前,恰好可以调用它,来完成在内存被清理前需要完成的其他所有任务(类似于面向对象中的析构函数)。

使用池式内存分配的优点如下所示:

  • 应用程序可以简单地管理内存。
  • 内存分配和回收更快,因为每次都是在一个池中完成的。分配可以在O(1)时间内完成,释放内存池所需时间也差不多(实际上是O(n)时间,不过在大部分情况下会除以一个大的因数,使其变成O(1))。
  • 可以预先分配错误处理池(Error-handling pools),以便程序在常规内存被耗尽时仍可以恢复。
  • 有非常易于使用的标准实现。

池式内存的缺点是:

  • 内存池只适用于操作可以分阶段的程序。
  • 内存池通常不能与第三方库很好地合作。
  • 如果程序的结构发生变化,则不得不修改内存池,这可能会导致内存管理系统的重新设计。
  • 您必须记住需要从哪个池进行分配。另外,如果在这里出错,就很难捕获该内存池。

引用计数

在引用计数中,所有共享的数据结构都有一个域来包含当前活动“引用”结构的次数。当向一个程序传递一个指向某个数据结构指针时,该程序会将引用计数增加1。实质上,是在告诉数据结构,它正在被存储在多少个位置上。然后,当进程完成对它的使用后,该程序就会将引用计数减少1。结束这个动作之后,它还会检查计数是否已经减到零。如果是,那么它将释放内存。

JavaPerl等高级语言中,进行内存管理时使用引用计数非常广泛。在这些语言中,引用计数由语言自动地处理,所以您根本不必担心它,除非要编写扩展模块。由于所有内容都必须进行引用计数,所以这会对速度产生一些影响,但它极大地提高了编程的安全性和方便性。

以下是引用计数的好处:

  • 实现简单。
  • 易于使用。
  • 由于引用是数据结构的一部分,所以它有一个好的缓存位置。

不过,它也有其不足之处:

  • 要求您永远不要忘记调用引用计数函数。
  • 无法释放作为循环数据结构的一部分的结构。
  • 减缓几乎每一个指针的分配。
  • 尽管所使用的对象采用了引用计数,但是当使用异常处理(比如trysetjmp()/longjmp())时,您必须采取其他方法。
  • 需要额外的内存来处理引用。
  • 引用计数占用了结构中的第一个位置,在大部分机器中最快可以访问到的就是这个位置。
  • 在多线程环境中更慢也更难以使用。

垃圾收集

垃圾收集(Garbage collection)是全自动地检测并移除不再使用的数据对象。垃圾收集器通常会在当可用内存减少到少于一个具体的阈值时运行。通常,它们以程序所知的可用的一组“基本”数据——栈数据、全局变量、寄存器——作为出发点。然后它们尝试去追踪通过这些数据连接到每一块数据。收集器找到的都是有用的数据;它没有找到的就是垃圾,可以被销毁并重新使用这些无用的数据。为了有效地管理内存,很多类型的垃圾收集器都需要知道数据结构内部指针的规划,所以,为了正确运行垃圾收集器,它们必须是语言本身的一部分。

垃圾收集的一些优点:
-永远不必担心内存的双重释放或者对象的生命周期。
-使用某些收集器,您可以使用与常规分配相同的`API。

其缺点包括:

  • 使用大部分收集器时,您都无法干涉何时释放内存。
  • 在多数情况下,垃圾收集比其他形式的内存管理更慢。
  • 垃圾收集错误引发的缺陷难于调试。
  • 如果您忘记将不再使用的指针设置为null,那么仍然会有内存泄漏。

内存管理器的设计目标

分析内存管理算法之前,我们先看看对内存管理算法的质量需求有哪些:

  1. 最大化兼容性:要实现内存管理器时,先要定义出分配器的接口函数。接口函数没有必要标新立异,而是要遵循现有标准(如POSIX),让使用者可以平滑的过度到新的内存管理器上。
  2. 最大化可移植性:通常情况下,内存管理器要向OS申请内存,然后进行二次分配。所以,在适当的时候要扩展内存或释放多余的内存,这要调用OS提供的函数才行。OS提供的函数则是因平台而异,尽量抽象出平台相关的代码,保证内存管理器的可移植性。
  3. 浪费最小的空间:内存管理器要管理内存,必然要使用自己一些数据结构,这些数据结构本身也要占内存空间。在用户眼中,这些内存空间毫无疑问是浪费掉了,如果浪费在内存管理器身的内存太多,显然是不可以接受的。内存碎片也是浪费空间的罪魁祸首,若内存管理器中有大量的内存碎片,它们是一些不连续的小块内存,它们总量可能很大,但无法使用,这也是不可以接受的。
  4. 最快的速度:内存分配/释放是常用的操作。按着2/8原则,常用的操作就是性能热点,热点函数的性能对系统的整体性能尤为重要。
  5. 最大化可调性(以适应于不同的情况):内存管理算法设计的难点就在于要适应用不同的情况。事实上,如果缺乏应用的上下文,是无法评估内存管理算法的好坏的。可以说在任何情况下,专用算法都比通用算法在时/空性能上的表现更优。设计一套通用内存管理算法,通过一些参数对它进行配置,可以让它在特定情况也有相当出色的表现,这就是可调性。
  6. 最大化局部性(Locality):大家都知道,使用cache可以提高程度的速度,但很多人未必知道cache使程序速度提高的真正原因。拿CPU内部的cacheRAM的访问速度相比,速度可能相差一个数量级。
    两者的速度上的差异固然重要,但这并不是提高速度的充分条件,只是必要条件。另外一个条件是程序访问内存的局部性(Locality)。大多数情况下,程序总访问一块内存附近的内存,把附近的内存先加入到cache中,下次访问cache中的数据,速度就会提高。否则,如果程序一会儿访问这里,一会儿访问另外一块相隔十万八千里的内存,这只会使数据在内存与cache之间来回搬运,不但于提高速度无益,反而会大大降低程序的速度。因此,内存管理算法要考虑这一因素,减少cache misspage fault
  7. 最大化调试功能:内存管理器提供的调试功能,强大易用,特别对于嵌入式环境来说,内存错误检测工具缺乏,内存管理器提供的调试功能就更是不可或缺了。
  8. 最大化适应性:对于不同情况都要去调设置,无疑太麻烦,是非用户友好的。要尽量让内存管理器适用于很广的情况,只有极少情况下才去调设置。

为了提高分配、释放的速度,多核计算机上,主要做的工作是避免所有核同时在竞争内存,常用的做法是内存池,简单来说就是批量申请内存,然后切割成各种长度,各种长度都有一个链表,申请、释放都只要在链表上操作,可以认为是O(1)的。不可能所有的长度都对应一个链表。很多内存池是假设,A释放掉一块内存后,B会申请类似大小的内存,但是A释放的内存跟B需要的内存不一定完全相等,可能有一个小的误差,如果严格按大小分配,会导致复用率很低,这样各个链表上都会有很多释放了,但是没有复用的内存,导致利用率很低。这个问题也是可以解决的,可以回收这些空闲的内存,这就是传统的内存管理,不停地对内存块作切割和合并,会导致效率低下。所以通常的做法是只分配有限种类的长度。一般的内存池只提供几十种选择。

常见C内存管理程序

比较著名的几个C内存管理程序包括:

  • Doug Lea Malloc:
    • Doug Lea Malloc实际上是完整的一组分配程序,其中包括Doug Lea的原始分配程序,GNU libc分配程序和ptmalloc
    • Doug Lea的分配程序加入了索引,这使得搜索速度更快,并且可以将多个没有被使用的块组合为一个大的块。
    • 它还支持缓存,以便更快地再次使用最近释放的内存。
    • ptmallocDoug Lea Malloc的一个扩展版本,支持多线程。在本文后面的部分详细分析ptamlloc2的源代码实现。
  • BSD Malloc:
    • BSD Malloc是随4.2 BSD发行的实现,包含在FreeBSD之中,这个分配程序可以从预先确实大小的对象构成的池中分配对象。
    • 它有一些用于对象大小的size类,这些对象的大小为2的若干次幂减去某一常数。所以,如果您请求给定大小的一个对象,它就简单地分配一个与之匹配的size类。这样就提供了一个快速的实现,但是可能会浪费内存。
  • Hoard:
    • 编写Hoard的目标是使内存分配在多线程环境中进行得非常快。因此,它的构造以锁的使用为中心,从而使所有进程不必等待分配内存。它可以显著地加快那些进行很多分配和回收的多线程进程的速度。
  • TCMalloc(Thread-Caching Malloc):
    • google开发的开源工具──“google-perftools”中的成员。与标准的Glibc库的malloc相比,TCMalloc在内存的分配上效率和速度要高得多。TCMalloc是一种通用内存管理程序,集成了内存池和垃圾回收的优点,对于小内存,按8的整数次倍分配,对于大内存,按4K的整数次倍分配。这样做有两个好处:
      • 一是分配的时候比较快,那种提供几十种选择的内存池,往往要遍历一遍各种长度,才能选出合适的种类,而TCMalloc则可以简单地做几个运算就行了。
      • 二是短期的收益比较大,分配的小内存至多浪费7个字节,大内存则4K。
      • 但是长远来说,TCMalloc分配的种类还是比别的内存池要多很多的,可能会导致复用率很低。
    • TCMalloc还有一套高效的机制回收这些空闲的内存。当一个线程的空闲内存比较多的时候,会交还给进程,进程可以把它调配给其他线程使用;如果某种长度交还给进程后,其他线程并没有需求,进程则把这些长度合并成内存页,然后切割成其他长度。如果进程占据的资源比较多,不会交回给操作系统。周期性的内存回收,避免可能出现的内存爆炸式增长的问题。
    • TCMalloc有比较高的空间利用率,只额外花费1%的空间。尽量避免加锁(一次加锁解锁约浪费100ns),使用更高效的spinlock,采用更合理的粒度。
    • 小块内存和打开内存分配采取不同的策略:小于32K的被定义为小块内存,小块内存按大小被分为8Bytes16Bytes,。。。,236Bytes进行分级。不是某个级别整数倍的大小都会被分配向上取整。如13Bytes的会按16Bytes分配,分配时,首先在本线程相应大小级别的空闲链表里面找,如果找到的话可以避免加锁操作(本线程的cache只有本线程自己使用)。如果找不到的话,则尝试从中心内存区的相应级别的空闲链表里搬一些对象到本线程的链表。
    • 如果中心内存区相应链表也为空的话,则向中心页分配器请求内存页面,然后分割成该级别的对象存储。
    • 大块内存处理方式:按页分配,每页大小是4K`,然后内存按1页,2页,……,255页的大小分类,相同大小的内存块也用链表连接。
策略 分配速度 回收速度 局部缓存 易用性 通用性 SMP线程友好度
GNU Malloc
Hoard
TCMalloc

从上表可以看出,TCMalloc的优势还是比较大的,TCMalloc的优势体现在:

  • 分配内存页的时候,直接跟OS打交道,而常用的内存池一般是基于别的内存管理器上分配,如果完全一样的内存管理策略,明显TCMalloc在性能及内存利用率上要省掉第三方内存管理的开销。
  • 大部分的内存池只负责分配,不管回收。当然了,没有回收策略,也有别的方法解决问题。比如线程之间协调资源,模索模块一般是一写多读,也就是只有一个线程申请、释放内存,就不存在线程之间协调资源;为了避免某些块大量空闲,常用的做法是减少内存块的种类,提高复用率,这可能会造成内部碎片比较多,如果空闲的内存实在太多了,还可以直接重启。

作为一个通用的内存管理库,TCMalloc也未必能超过专用的比较粗糙的内存池。比如应用中主要用到7种长度的块,专用的内存池,可以只分配这7种长度,使得没有内部碎片。或者利用统计信息设置内存池的长度,也可以使得内部碎片比较少。所以TCMalloc的意义在于,不需要增加任何开发代价,就能使得内存的开销比较少,而且可以从理论上证明,最优的分配不会比TCMalloc的分配好很多。

对比Glibc可以发现,两者的思想其实是差不多的,差别只是在细节上,细节上的差别,对工程项目来说也是很重要的,至少在性能与内存使用率上TCMalloc是领先很多的。 Glibc在内存回收方面做得不太好,常见的一个问题,申请很多内存,然后又释放,只是有一小块没释放,这时候Glibc就必须要等待这一小块也释放了,也把整个大块释放,极端情况下,可能会造成几个G的浪费。

ptmalloc内存管理概述

简介

Linuxmalloc的早期版本是由Doug Lea实现的,它有一个重要问题就是在并行处理时多个线程共享进程的内存空间,各线程可能并发请求内存,在这种情况下应该如何保证分配和回收的正确和高效。Wolfram GlogerDoug Lea的基础上改进使得Glibcmalloc可以支持多线程——ptmalloc,在glibc-2.3.x中已经集成了ptmalloc2,这就是我们平时使用的malloc,目前ptmalloc的最新版本ptmalloc3ptmalloc2的性能略微比ptmalloc3要高一点点。

ptmalloc实现了malloc()free()以及一组其它的函数.以提供动态内存管理的支持。分配器处在用户程序和内核之间,它响应用户的分配请求,向操作系统申请内存,然后将其返回给用户程序,为了保持高效的分配,分配器一般都会预先分配一块大于用户请求的内存,并通过某种算法管理这块内存。来满足用户的内存分配要求,用户释放掉的内存也并不是立即就返回给操作系统,相反,分配器会管理这些被释放掉的空闲空间,以应对用户以后的内存分配要求。也就是说,分配器不但要管理已分配的内存块,还需要管理空闲的内存块,当响应用户分配要求时,分配器会首先在空闲空间中寻找一块合适的内存给用户,在空闲空间中找不到的情况下才分配一块新的内存。为实现一个高效的分配器,需要考虑很多的因素。比如,分配器本身管理内存块所占用的内存空间必须很小,分配算法必须要足够的快。

内存管理的设计假设

ptmalloc在设计时折中了高效率,高空间利用率,高可用性等设计目标。在其实现代码中,隐藏着内存管理中的一些设计假设,由于某些设计假设,导致了在某些情况下ptmalloc的行为很诡异。这些设计假设包括:

  1. 具有长生命周期的大内存分配使用mmap
  2. 特别大的内存分配总是使用mmap
  3. 具有短生命周期的内存分配使用brk,因为用mmap映射匿名页,当发生缺页异常时,linux内核为缺页分配一个新物理页,并将该物理页清0,一个mmap的内存块需要映射多个物理页,导致多次清0操作,很浪费系统资源,所以引入了mmap分配阈值动态调整机制,保证在必要的情况下才使用mmap分配内存。
  4. 尽量只缓存临时使用的空闲小内存块,对大内存块或是长生命周期的大内存块在释放时都直接归还给操作系统。
  5. 对空闲的小内存块只会在mallocfree的时候进行合并,free时空闲内存块可能放入pool中,不一定归还给操作系统。
  6. 收缩堆的条件是当前free的块大小加上前后能合并chunk的大小大于64KB,并且堆顶的大小达到阈值,才有可能收缩堆,把堆最顶端的空闲内存返回给操作系统。
  7. 需要保持长期存储的程序不适合用ptmalloc来管理内存。
  8. 为了支持多线程,多个线程可以从同一个分配区(arena)中分配内存,ptmalloc假设线程A释放掉一块内存后,线程B会申请类似大小的内存,但是A释放的内存跟B需要的内存不一定完全相等,可能有一个小的误差,就需要不停地对内存块作切割和合并,这个过程中可能产生内存碎片。

内存管理数据结构概述

Main_arena与non_main_arena

Doug Lea实现的内存分配器中只有一个主分配区(main arena),每次分配内存都必须对主分配区加锁,分配完成后释放锁,在SMP多线程环境下,对主分配区的锁的争用很激烈,严重影响了malloc的分配效率。于是Wolfram GlogerDoug Lea的基础上改进使得Glibcmalloc可以支持多线程,增加了非主分配区(non main arena)支持,主分配区与非主分配区用环形链表进行管理。每一个分配区利用互斥锁(mutex)使线程对于该分配区的访问互斥。

每个进程只有一个主分配区,但可能存在多个非主分配区,ptmalloc根据系统对分配区的争用情况动态增加非主分配区的数量,分配区的数量一旦增加,就不会再减少了。主分配区可以访问进程的heap区域和mmap映射区域,也就是说主分配区可以使用sbrkmmap向操作系统申请虚拟内存。而非主分配区只能访问进程的mmap映射区域,非主分配区每次使用mmap()向操作系统“批发”HEAP_MAX_SIZE(32位系统上默认为1MB,64位系统默认为64MB)大小的虚拟内存,当用户向非主分配区请求分配内存时再切割成小块“零售”出去,毕竟系统调用是相对低效的,直接从用户空间分配内存快多了。所以ptmalloc在必要的情况下才会调用mmap()函数向操作系统申请虚拟内存。

主分配区可以访问heap区域,如果用户不调用brk()或是sbrk()函数,分配程序就可以保证分配到连续的虚拟地址空间,因为每个进程只有一个主分配区使用sbrk()分配heap区域的虚拟内存。内核对brk的实现可以看着是mmap的一个精简版,相对高效一些。如果主分配区的内存是通过mmap()向系统分配的,当free该内存时,主分配区会直接调用munmap()将该内存归还给系统。

当某一线程需要调用malloc()分配内存空间时,该线程先查看线程私有变量中是否已经存在一个分配区,如果存在,尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败,该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。

申请小块内存时会产生很多内存碎片,ptmalloc在整理时也需要对分配区做加锁操作。每个加锁操作大概需要5~10个cpu指令,而且程序线程很多的情况下,锁等待的时间就会延长,导致malloc性能下降。一次加锁操作需要消耗100ns左右,正是锁的缘故,导致ptmalloc在多线程竞争情况下性能远远落后于tcmalloc。最新版的ptmalloc对锁进行了优化,加入了PER_THREADATOMIC_FASTBINS优化,但默认编译不会启用该优化,这两个对锁的优化应该能够提升多线程内存的分配的效率。

chunk的组织

不管内存是在哪里被分配的,用什么方法分配,用户请求分配的空间在ptmalloc中都使用一个chunk来表示。用户调用free()函数释放掉的内存也并不是立即就归还给操作系统,相反,它们也会被表示为一个chunkptmalloc使用特定的数据结构来管理这些空闲的chunk

chunk格式

ptmalloc在给用户分配的空间的前后加上了一些控制信息,用这样的方法来记录分配的信息,以便完成分配和释放工作。一个使用中的chunk(使用中,就是指还没有被free掉)在内存中的样子如图所示:

在图中,chunk指针指向一个chunk的开始,一个chunk中包含了用户请求的内存区域和相关的控制信息。图中的mem指针才是真正返回给用户的内存指针。chunk的第二个域的最低一位为P,它表示前一个块是否在使用中,P为0则表示前一个chunk为空闲,这时chunk的第一个域prev_size才有效,prev_size表示前一个chunksize,程序可以使用这个值来找到前一个chunk的开始地址。当P为1时,表示前一个chunk正在使用中,prev_size无效,程序也就不可以得到前一个chunk的大小。不能对前一个chunk进行任何操作。ptmalloc分配的第一个块总是将P设为1,以防止程序引用到不存在的区域。

chunk的第二个域的倒数第二个位为M,他表示当前chunk是从哪个内存区域获得的虚拟内存。M为1表示该chunk是从mmap映射区域分配的,否则是从heap区域分配的。chunk的第二个域倒数第三个位为A,表示该chunk属于主分配区或者非主分配区,如果属于非主分配区,将该位置为1,否则置为0。

空闲chunk在内存中的结构如图所示:

chunk空闲时,其M状态不存在,只有AP状态,原本是用户数据区的地方存储了四个指针,指针fd指向后一个空闲的chunk,而bk指向前一个空闲的chunkptmalloc通过这两个指针将大小相近的chunk连成一个双向链表。对于large bin中的空闲chunk,还有两个指针,fd_nextsizebk_nextsize,这两个指针用于加快在large bin中查找最近匹配的空闲chunk。不同的chunk链表又是通过bins或者fastbins来组织的。

chunk中的空间复用

为了使得chunk所占用的空间最小,ptmalloc使用了空间复用,一个chunk或者正在被使用,或者已经被free掉,所以chunk的中的一些域可以在使用状态和空闲状态表示不同的意义,来达到空间复用的效果。以32位系统为例,空闲时,一个chunk中至少需要4个size_t(4B)大小的空间,用来存储prev_sizesizefdbk,也就是16Bchunk的大小要对齐到8B。当一个chunk处于使用状态时,它的下一个chunkprev_size域肯定是无效的。所以实际上,这个空间也可以被当前chunk使用。这听起来有点不可思议,但确实是合理空间复用的例子。故而实际上,一个使用中的chunk的大小的计算公式应该是:in_use_size = (用户请求大小+ 8 - 4 ) align to 8B,这里加8是因为需要存储prev_sizesize,但又因为向下一个chunk“借”了4B,所以要减去4。最后,因为空闲的chunk和使用中的chunk使用的是同一块空间。所以肯定要取其中最大者作为实际的分配空间。即最终的分配空间chunk_size = max(in_use_size, 16)

空闲chunk容器

Bins

用户free掉的内存并不是都会马上归还给系统,ptmalloc会统一管理heapmmap映射区域中的空闲的chunk,当用户进行下一次分配请求时,ptmalloc会首先试图在空闲的chunk中挑选一块给用户,这样就避免了频繁的系统调用,降低了内存分配的开销。 ptmalloc将相似大小的chunk用双向链表链接起来,这样的一个链表被称为一个binptmalloc一共维护了128个bin,并使用一个数组来存储这些bin(如下图所示)。

数组中的第一个为unsorted bin,数组中从2开始编号的前64个bin称为small bins,同一个small bin中的chunk具有相同的大小。两个相邻的small bin中的chunk大小相差8bytes。

small bins中的chunk按照最近使用顺序进行排列,最后释放的chunk被链接到链表的头部,而申请chunk是从链表尾部开始,这样,每一个chunk都有相同的机会被ptmalloc选中。small bins后面的bin被称作large binslarge bins中的每一个bin分别包含了一个给定范围内的chunk,其中的chunk按大小序排列。相同大小的chunk同样按照最近使用顺序排列。

ptmalloc使用smallest-firstbest-fit原则在空闲large bins中查找合适的chunk。当空闲的chunk被链接到bin中的时候,ptmalloc会把表示该chunk是否处于使用中的标志P设为0(注意,这个标志实际上处在下一个chunk中),同时ptmalloc还会检查它前后的chunk是否也是空闲的,如果是的话,ptmalloc会首先把它们合并为一个大的chunk,然后将合并后的chunk放到unstored bin中。要注意的是,并不是所有的chunk被释放后就立即被放到bin中。ptmalloc为了提高分配的速度,会把一些小的的chunk先放到一个叫做fast bins的容器内。

Fast Bins

一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的chunk之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,ptmalloc中在分配过程中引入了fast bins,不大于max_fast(默认值为64B)的chunk被释放后,首先会被放到fast bins中,fast bins中的chunk并不改变它的使用标志P。这样也就无法将它们合并,当需要给用户分配的chunk小于或等于max_fast时,ptmalloc首先会在fast bins中查找相应的空闲块,然后才会去查找bins中的空闲chunk。在某个特定的时候,ptmalloc会遍历fast bins中的chunk,将相邻的空闲chunk进行合并,并将合并后的chunk加入unsorted bin中,然后再将usorted bin里的chunk加入bins中。

Unsorted Bin

unsorted bin的队列使用bins数组的第一个,如果被用户释放的chunk大于max_fast,或者fast bins中的空闲chunk合并后,这些chunk首先会被放到unsorted bin队列中,在进行malloc操作的时候,如果在fast bins中没有找到合适的chunk,则ptmalloc会先在unsorted bin中查找合适的空闲chunk,然后才查找bins。如果unsorted bin不能满足分配要求。malloc便会将unsorted bin中的chunk加入bins中。然后再从bins中继续进行查找和分配过程。从这个过程可以看出来,unsorted bin可以看做是bins的一个缓冲区,增加它只是为了加快分配的速度。

Top chunk

并不是所有的chunk都按照上面的方式来组织,实际上,有三种例外情况。top chunkmmaped chunklast remainder,下面会分别介绍这三类特殊的chunk

top chunk对于主分配区和非主分配区是不一样的。

对于非主分配区会预先从mmap区域分配一块较大的空闲内存模拟sub-heap,通过管理sub-heap来响应用户的需求,因为内存是按地址从低向高进行分配的,在空闲内存的最高处,必然存在着一块空闲chunk,叫做top chunk。当binsfast bins都不能满足分配需要的时候,ptmalloc会设法在top chunk中分出一块内存给用户,如果top chunk本身不够大,分配程序会重新分配一个sub-heap,并将top chunk迁移到新的sub-heap上,新的sub-heap与已有的sub-heap用单向链表连接起来,然后在新的top chunk上分配所需的内存以满足分配的需要,实际上,top chunk在分配时总是在fast binsbins之后被考虑,所以,不论top chunk有多大,它都不会被放到fast bins或者是bins中。top chunk的大小是随着分配和回收不停变换的,如果从top chunk分配内存会导致top chunk减小,如果回收的chunk恰好与top chunk相邻,那么这两个chunk就会合并成新的top chunk,从而使top chunk变大。如果在free时回收的内存大于某个阈值,并且top chunk的大小也超过了收缩阈值,ptmalloc会收缩sub-heap,如果top-chunk包含了整个sub-heapptmalloc会调用munmap把整个sub-heap的内存返回给操作系统。

由于主分配区是唯一能够映射进程heap区域的分配区,它可以通过sbrk()来增大或是收缩进程heap的大小,ptmalloc在开始时会预先分配一块较大的空闲内存(也就是所谓的heap),主分配区的top chunk在第一次调用malloc时会分配一块(chunk_size + 128KB) align 4KB大小的空间作为初始的heap,用户从top chunk分配内存时,可以直接取出一块内存给用户。在回收内存时,回收的内存恰好与top chunk相邻则合并成新的top chunk,当该次回收的空闲内存大小达到某个阈值,并且top chunk的大小也超过了收缩阈值,会执行内存收缩,减小top chunk的大小,但至少要保留一个页大小的空闲内存,从而把内存归还给操作系统。如果向主分配区的top chunk申请内存,而top chunk中没有空闲内存,ptmalloc会调用sbrk()将进程heap的边界brk上移,然后修改top chunk的大小。

mmaped chunk

当需要分配的chunk足够大,而且fast binsbins都不能满足要求,甚至top chunk本身也不能满足分配需求时,ptmalloc会使用mmap来直接使用内存映射来将页映射到进程空间。这样分配的chunk在被free时将直接解除映射,于是就将内存归还给了操作系统,再次对这样的内存区的引用将导致segmentation fault错误。这样的chunk也不会包含在任何bin中。

Last remainder

last remainder是另外一种特殊的chunk,就像top chunkmmaped chunk一样,不会在任何bins中找到这种chunk。当需要分配一个small chunk,但在small bins中找不到合适的chunk,如果last remainder chunk的大小大于所需的small chunk大小,last remainder chunk被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk变成新的last remainder chuk

sbrk与mmap

从进程的内存布局可知,.bss段之上的这块分配给用户程序的空间被称为heap(堆)。start_brk指向heap的开始,而brk指向heap的顶部。可以使用系统调用brk()sbrk()来增加标识heap顶部的brk值,从而线性的增加分配给用户的heap空间。在使malloc之前,brk的值等于start_brk,也就是说heap大小为0。ptmalloc在开始时,若请求的空间小于mmap分配阈值(mmap threshold,默认值为128KB)时,主分配区会调用sbrk()增加一块大小为(128KB + chunk_size) align 4KB的空间作为heap。非主分配区会调用mmap映射一块大小为HEAP_MAX_SIZE(32位系统上默认为1MB,64位系统上默认为64MB)的空间作为sub-heap

这就是前面所说的ptmalloc所维护的分配空间,当用户请求内存分配时,首先会在这个区域内找一块合适的chunk给用户。当用户释放了heap中的chunk时,ptmalloc又会使用fast binsbins来组织空闲chunk。以备用户的下一次分配。若需要分配的chunk大小小于mmap分配阈值,而heap空间又不够,则此时主分配区会通过sbrk()调用来增加heap大小,非主分配区会调用mmap映射一块新的sub-heap,也就是增加top chunk的大小,每次heap增加的值都会对齐到4KB

当用户的请求超过mmap分配阈值,并且主分配区使用sbrk()分配失败的时候,或是非主分配区在top chunk中不能分配到需要的内存时,ptmalloc会尝试使用mmap()直接映射一块内存到进程内存空间。使用mmap()直接映射的chunk在释放时直接解除映射,而不再属于进程的内存空间。任何对该内存的访问都会产生段错误。而在heap中或是sub-heap中分配的空间则可能会留在进程内存空间内,还可以再次引用(当然是很危险的)。

ptmalloc munmap chunk时,如果回收的chunk空间大小大于mmap分配阈值的当前值,并且小于DEFAULT_MMAP_THRESHOLD_MAX(32位系统默认为512KB,64位系统默认为32MB),ptmalloc会把mmap分配阈值调整为当前回收的chunk的大小,并将mmap收缩阈值(mmap trim threshold)设置为mmap分配阈值的2倍。这就是ptmalloc的对mmap分配阈值的动态调整机制,该机制是默认开启的,当然也可以用mallopt()关闭该机制。

内存分配概述

分配算法概述

以32系统为例,64位系统类似。

  • 小于等于64字节:用pool算法分配。
  • 64到512字节之间:在最佳匹配算法分配和pool算法分配中取一种合适的。
  • 大于等于512字节:用最佳匹配算法分配。
  • 大于等于mmap分配阈值(默认值128KB):根据设置的mmap的分配策略进行分配,如果没有开启mmap分配阈值的动态调整机制,大于等于128KB就直接调用mmap分配。否则,大于等于mmap分配阈值时才直接调用mmap()分配。

ptmalloc的响应用户内存分配要求的具体步骤为:

  1. 获取分配区的锁,为了防止多个线程同时访问同一个分配区,在进行分配之前需要取得分配区域的锁。线程先查看线程私有实例中是否已经存在一个分配区,如果存在尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,否则,该线程搜索分配区循环链表试图获得一个空闲(没有加锁)的分配区。如果所有的分配区都已经加锁,那么ptmalloc会开辟一个新的分配区,把该分配区加入到全局分配区循环链表和线程的私有实例中并加锁,然后使用该分配区进行分配操作。开辟出来的新分配区一定为非主分配区,因为主分配区是从父进程那里继承来的。开辟非主分配区时会调用mmap()创建一个sub-heap,并设置好top chunk
  2. 将用户的请求大小转换为实际需要分配的chunk空间大小。
  3. 判断所需分配chunk的大小是否满足chunk_size <= max_fast (max_fast默认为64B),如果是的话,则转下一步,否则跳到第5步。
  4. 首先尝试在fast bins中取一个所需大小的chunk分配给用户。如果可以找到,则分配结束。否则转到下一步。
  5. 判断所需大小是否处在small bins中,即判断chunk_size < 512B是否成立。如果chunk大小处在small bins中,则转下一步,否则转到第6步。
  6. 根据所需分配的chunk的大小,找到具体所在的某个small bin,从该bin的尾部摘取一个恰好满足大小的chunk。若成功,则分配结束,否则,转到下一步。
  7. 到了这一步,说明需要分配的是一块大的内存,或者small bins中找不到合适的chunk。于是,ptmalloc首先会遍历fast bins中的chunk,将相邻的chunk进行合并,并链接到unsorted bin中,然后遍历unsorted bin中的chunk,如果unsorted bin只有一个chunk,并且这个chunk在上次分配时被使用过,并且所需分配的chunk大小属于small bins,并且chunk的大小大于等于需要分配的大小,这种情况下就直接将该chunk进行切割,分配结束,否则将根据chunk的空间大小将其放入small bins或是large bins中,遍历完成后,转入下一步。
  8. 到了这一步,说明需要分配的是一块大的内存,或者small binsunsorted bin中都找不到合适的chunk,并且fast binsunsorted bin中所有的chunk都清除干净了。从large bins中按照smallest-firstbest-fit原则,找一个合适的chunk,从中划分一块所需大小的chunk,并将剩下的部分链接回到bins中。若操作成功,则分配结束,否则转到下一步。
  9. 如果搜索fast binsbins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了。判断top chunk大小是否满足所需chunk的大小,如果是,则从top chunk中分出一块来。否则转到下一步。
  10. 到了这一步,说明top chunk也不能满足分配要求,所以,于是就有了两个选择:如果是主分配区,调用sbrk(),增加top chunk大小;如果是非主分配区,调用mmap来分配一个新的sub-heap,增加top chunk大小;或者使用mmap()来直接分配。在这里,需要依靠chunk的大小来决定到底使用哪种方法。判断所需分配的chunk大小是否大于等于mmap分配阈值,如果是的话,则转下一步,调用mmap分配,否则跳到第12步,增加top chunk的大小。
  11. 使用mmap系统调用为程序的内存空间映射一块chunk_size align 4kB大小的空间。然后将内存指针返回给用户。
  12. 判断是否为第一次调用malloc,若是主分配区,则需要进行一次初始化工作,分配一块大小为(chunk_size + 128KB) align 4KB大小的空间作为初始的heap。若已经初始化过了,主分配区则调用sbrk()增加heap空间,非主分配区则在top chunk中切割出一个chunk,使之满足分配需求,并将内存指针返回给用户。

总结一下:根据用户请求分配的内存的大小,ptmalloc有可能会在两个地方为用户分配内存空间。在第一次分配内存时,一般情况下只存在一个主分配区,但也有可能从父进程那里继承来了多个非主分配区,在这里主要讨论主分配区的情况,brk值等于start_brk,所以实际上heap大小为0,top chunk大小也是0。这时,如果不增加heap大小,就不能满足任何分配要求。所以,若用户的请求的内存大小小于mmap分配阈值,则ptmalloc会初始heap。然后在heap中分配空间给用户,以后的分配就基于这个heap进行。若第一次用户的请求就大于mmap分配阈值,则ptmalloc直接使用mmap()分配一块内存给用户,而heap也就没有被初始化,直到用户第一次请求小于mmap分配阈值的内存分配。

第一次以后的分配就比较复杂了,简单说来,ptmalloc首先会查找fast bins,如果不能找到匹配的chunk,则查找small bins。若还是不行,合并fast bins,把chunk加入unsorted bin,在unsorted bin中查找,若还是不行,把unsorted bin中的chunk全加入large bins中,并查找large bins。在fast binssmall bins中的查找都需要精确匹配,而在large bins中查找时,则遵循smallest-firstbest-fit的原则,不需要精确匹配。

若以上方法都失败了,则ptmalloc会考虑使用top chunk。若top chunk也不能满足分配要求。而且所需chunk大小大于mmap分配阈值,则使用mmap进行分配。否则增加heap,增大top chunk。以满足分配要求。

内存回收概述

free()函数接受一个指向分配区域的指针作为参数,释放该指针所指向的chunk。而具体的释放方法则看该chunk所处的位置和该chunk的大小。free()函数的工作步骤如下:

  1. free()函数同样首先需要获取分配区的锁,来保证线程安全。
  2. 判断传入的指针是否为0,如果为0,则什么都不做,直接return。否则转下一步。
  3. 判断所需释放的chunk是否为mmaped chunk,如果是,则调用munmap()释放mmaped chunk,解除内存空间映射,该该空间不再有效。如果开启了mmap分配阈值的动态调整机制,并且当前回收的chunk大小大于mmap分配阈值,将mmap分配阈值设置为该chunk的大小,将mmap收缩阈值设定为mmap分配阈值的2倍,释放完成,否则跳到下一步。
  4. 判断chunk的大小和所处的位置,若chunk_size <= max_fast,并且chunk并不位于heap的顶部,也就是说并不与top chunk相邻,则转到下一步,否则跳到第6步。(因为与top chunk相邻的小chunk也和top chunk进行合并,所以这里不仅需要判断大小,还需要判断相邻情况)
  5. chunk放到fast bins中,chunk放入到fast bins中时,并不修改该chunk使用状态位P。也不与相邻的chunk进行合并。只是放进去,如此而已。这一步做完之后释放便结束了,程序从free()函数中返回。
  6. 判断前一个chunk是否处在使用中,如果前一个块也是空闲块,则合并。并转下一步。
  7. 判断当前释放chunk的下一个块是否为top chunk,如果是,则转第9步,否则转下一步。
  8. 判断下一个chunk是否处在使用中,如果下一个chunk也是空闲的,则合并,并将合并后的chunk放到unsorted bin中。注意,这里在合并的过程中,要更新chunk的大小,以反映合并后的chunk的大小。并转到第10步。
  9. 如果执行到这一步,说明释放了一个与top chunk相邻的chunk。则无论它有多大,都将它与top chunk合并,并更新top chunk的大小等信息。转下一步。
  10. 判断合并后的chunk的大小是否大于FASTBIN_CONSOLIDATION_THRESHOLD(默认64KB),如果是的话,则会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中。fast bins将变为空,操作完成之后转下一步。
  11. 判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。但是最先分配的128KB空间是不会归还的,ptmalloc会一直管理这部分内存,用于响应用户的分配请求;
    1. 如果为非主分配区,会进行sub-heap收缩,将top chunk的一部分返回给操作系统,
    2. 如果top chunk为整个sub-heap,会把整个sub-heap还回给操作系统。
    3. 做完这一步之后,释放结束,从free()函数退出。可以看出,收缩堆的条件是当前freechunk大小加上前后能合并chunk的大小大于64k,并且要top chunk的大小要达到mmap收缩阈值,才有可能收缩堆。

配置选项概述

ptmalloc主要提供以下几个配置选项用于调优,这些选项可以通过mallopt()进行设置:

M_MXFAST用于设置fast bins中保存的chunk的最大大小,默认值为64Bfast bins中保存的chunk在一段时间内不会被合并,分配小对象时可以首先查找fast bins,如果fast bins找到了所需大小的chunk,就直接返回该chunk,大大提高小对象的分配速度,但这个值设置得过大,会导致大量内存碎片,并且会导致ptmalloc缓存了大量空闲内存,去不能归还给操作系统,导致内存暴增。

M_MXFAST的最大值为80B,不能设置比80B更大的值,因为设置为更大的值并不能提高分配的速度。fast bins是为需要分配许多小对象的程序设计的,比如需要分配许多小struct,小对象,小的string等等。

如果设置该选项为0,就会不使用fast bins

M_TRIM_THRESHOLD用于设置mmap收缩阈值,默认值为128KB。自动收缩只会在free时才发生,如果当前freechunk大小加上前后能合并chunk的大小大于64KB,并且top chunk的大小达到mmap收缩阈值,对于主分配区,调用malloc_trim()返回一部分内存给操作系统,对于非主分配区,调用heap_trim()返回一部分内存给操作系统,在发生内存收缩时,还是从新设置mmap分配阈值和mmap收缩阈值。

这个选项一般与M_MMAP_THRESHOLD选项一起使用,M_MMAP_THRESHOLD用于设置mmap分配阈值,对于长时间运行的程序,需要对这两个选项进行调优,尽量保证在ptmalloc中缓存的空闲chunk能够得到重用,尽量少用mmap分配临时用的内存。不停地使用系统调用mmap分配内存,然后很快又free掉该内存,这样是很浪费系统资源的,并且这样分配的内存的速度比从ptmalloc的空闲chunk中分配内存慢得多,由于需要页对齐导致空间利用率降低,并且操作系统调用mmap()分配内存是串行的,在发生缺页异常时加载新的物理页,需要对新的物理页做清0操作,大大影响效率。

M_TRIM_THRESHOLD的值必须设置为页大小对齐,设置为-1会关闭内存收缩设置。

注意:试图在程序开始运行时分配一块大内存,并马上释放掉,以期望来触发内存收缩,这是不可能的,因为该内存马上就返回给操作系统了。

M_MMAP_THRESHOLD用于设置mmap分配阈值,默认值为128KBptmalloc默认开启动态调整mmap分配阈值和mmap收缩阈值。当用户需要分配的内存大于mmap分配阈值,ptmallocmalloc()函数其实相当于mmap()的简单封装,free函数相当于munmap()的简单封装。相当于直接通过系统调用分配内存,回收的内存就直接返回给操作系统了。因为这些大块内存不能被ptmalloc缓存管理,不能重用,所以ptmalloc也只有在万不得已的情况下才使用该方式分配内存。

但使用mmap分配有如下的好处:

  • mmap的空间可以独立从系统中分配和释放的系统,对于长时间运行的程序,申请长生命周期的大内存块就很适合有这种方式。
  • mmap的空间不会被ptmalloc锁在缓存的chunk中,不会导致ptmalloc内存暴增的问题。
  • 对有些系统的虚拟地址空间存在洞,只能用mmap()进行分配内存,sbrk()不能运行。

使用mmap分配内存的缺点:

  • 该内存不能被ptmalloc回收再利用。
  • 会导致更多的内存浪费,因为mmap需要按页对齐。
  • 它的分配效率跟操作系统提供的mmap()函数的效率密切相关,Linux系统强制把匿名mmap的内存物理页清0是很低效的。

所以用mmap来分配长生命周期的大内存块就是最好的选择,其他情况下都不太高效。

M_MMAP_MAX用于设置进程中用mmap分配的内存块的最大限制,默认值为64K,因为有些系统用mmap分配的内存块太多会导致系统的性能下降。如果将M_MMAP_MAX设置为0,ptmalloc将不会使用mmap分配大块内存。ptmalloc为优化锁的竞争开销,做了PER_THREAD的优化,也提供了两个选项,M_ARENA_TESTM_ARENA_MAX,由于PER_THREAD的优化默认没有开启,这里暂不对这两个选项做介绍。

另外,ptmalloc没有提供关闭mmap分配阈值动态调整机制的选项,mmap分配阈值动态调整时默认开启的,如果要关闭mmap分配阈值动态调整机制,可以设置M_TRIM_THRESHOLDM_MMAP_THRESHOLDM_TOP_PADM_MMAP_MAX中的任意一个。

但是强烈建议不要关闭该机制,该机制保证了ptmalloc尽量重用缓存中的空闲内存,不用每次对相对大一些的内存使用系统调用mmap去分配内存。

使用注意事项

为了避免Glibc内存暴增,使用时需要注意以下几点:

  1. 后分配的内存先释放,因为ptmalloc收缩内存是从top chunk开始,如果与top chunk相邻的chunk不能释放,top chunk以下的chunk都无法释放。
  2. ptmalloc不适合用于管理长生命周期的内存,特别是持续不定期分配和释放长生命周期的内存,这将导致ptmalloc内存暴增。如果要用ptmalloc分配长周期内存,在32位系统上,分配的内存块最好大于1MB,64位系统上,分配的内存块大小大于32MB。这是由于ptmalloc默认开启mmap分配阈值动态调整功能,1MB是32位系统mmap分配阈值的最大值,32MB是64位系统mmap分配阈值的最大值,这样可以保证ptmalloc分配的内存一定是从mmap映射区域分配的,当free时,ptmalloc会直接把该内存返回给操作系统,避免了被ptmalloc缓存。
  3. 不要关闭ptmallocmmap分配阈值动态调整机制,因为这种机制保证了短生命周期的内存分配尽量从ptmalloc缓存的内存chunk中分配,更高效,浪费更少的内存。如果关闭了该机制,对大于128KB的内存分配就会使用系统调用mmap向操作系统分配内存,使用系统调用分配内存一般会比从ptmalloc缓存的chunk中分配内存慢,特别是在多线程同时分配大内存块时,操作系统会串行调用mmap(),并为发生缺页异常的页加载新物理页时,默认强制清0。频繁使用mmap向操作系统分配内存是相当低效的。使用mmap分配的内存只适合长生命周期的大内存块。
  4. 多线程分阶段执行的程序不适合用ptmalloc,这种程序的内存更适合用内存池管理,就像Appach那样,每个连接请求处理分为多个阶段,每个阶段都有自己的内存池,每个阶段完成后,将相关的内存就返回给相关的内存池。ptmalloc假设了线程A释放的内存块能在线程B中得到重用,但B不一定会分配和A线程同样大小的内存块,于是就需要不断地做切割和合并,可能导致内存碎片。
  5. 尽量减少程序的线程数量和避免频繁分配/释放内存,ptmalloc在多线程竞争激烈的情况下,首先查看线程私有变量是否存在分配区,如果存在则尝试加锁,如果加锁不成功会尝试其它分配区,如果所有的分配区的锁都被占用着,就会增加一个非主分配区供当前线程使用。由于在多个线程的私有变量中可能会保存同一个分配区,所以当线程较多时,加锁的代价就会上升,ptmalloc分配和回收内存都要对分配区加锁,从而导致了多线程
    竞争环境下ptmalloc的效率降低。
  6. 防止内存泄露,ptmalloc对内存泄露是相当敏感的,根据它的内存收缩机制,如果与top chunk相邻的那个chunk没有回收,将导致top chunk一下很多的空闲内存都无法返回给操作系统。
  7. 防止程序分配过多内存,或是由于Glibc内存暴增,导致系统内存耗尽,程序因OOM被系统杀掉。预估程序可以使用的最大物理内存大小,配置系统的/proc/sys/vm/overcommit_memory/proc/sys/vm/overcommit_ratio,以及使用ulimt –v限制程序能使用虚拟内存空间大小,防止程序因OOM被杀掉。

问题分析及解决

通过前面几节对ptmalloc实现的粗略分析,尝试去分析和解决我们遇到的问题,我们系统遇到的问题是glibc内存暴增,现象是程序已经把内存返回给了Glibc库,但Glibc库却没有把内存归还给操作系统,最终导致系统内存耗尽,程序因为OOM被系统杀掉。原因有如下几点:

  1. 在64位系统上使用默认的系统配置,也就是说ptmallocmmap分配阈值动态调整机制是开启的。我们的NoSql系统经常分配内存为2MB,并且这2MB的内存很快会被释放,在ptmalloc回收2MB内存时,ptmalloc的动态调整机制会认为2MB对我们的系统来说是一个临时的内存分配,每次都用系统调用mmap()向操作系统分配内存,ptmalloc认为这太低效了,于是把mmap的阈值设置成了2MB+4K,当下次再分配2MB的内存时, 尽量从ptmalloc缓存的chunk中分配,缓存的chunk不能满足要求,才考虑调用mmap()`进行分配,提高分配的效率。
  2. 系统中分配2M内存的地方主要有两处,一处是全局的内存cache,另一处是网络模块,网络模块每次分配2MB内存用于处理网络的请求,处理完成后就释放该内存。这可以看成是一个短生命周期的内存。内存cache每次分配2MB,但不确定什么时候释放,也不确定下次会什么时候会再分配2MB内存,但有一点可以确定,每次分配的2MB内存,要经过比较长的一段时间才会释放,所以可以看成是长生命周期的内存块,对于这些cache中的多个2M内存块没有使用free list管理,每次都是先从cachefree调用一个2M内存块,再从Glibc中分配一块新的2M内存块。 ptmalloc不擅长管理长生命周期的内存块,ptmalloc设计的假设中就明确假设缓存的内存块都用于短生命周期的内存分配,因为ptmalloc的内存收缩是从top chunk开始,如果与top chunk相邻的那个chunk在我们NoSql的内存池中没有释放,top chunk以下的空闲内存都无法返回给系统,即使这些空闲内存有几十个G也不行。
  3. Glibc内存暴增的问题我们定位为全局内存池中的内存块长时间没有释放,其中还有一个原因就是全局内存池会不定期的分配内存,可能下次分配的内存是在top chunk分配的,分配以后又短时间不释放,导致top chunk升到了一个更高的虚拟地址空间,从而使ptmalloc中缓存的内存块更多,但无法返回给操作系统。
  4. 另一个原因就是进程的线程数越多,在高压力高并发环境下,频繁分配和释放内存,由于分配内存时锁争用更激烈,ptmalloc会为进程创建更多的分配区,由于我们的全局内存池的长时间不释放内存的缘故,会导致ptmalloc缓存的chunk数量增长得更快,从而更容易重现Glibc内存暴增的问题。
  5. 内存池管理内存的方式导致Glibc大量的内存碎片。我们的内存池对于小于等于64K的内存分配,则从内存池中分配64K的内存块,如果内存池中没有,则调用malloc()分配64K的内存块,释放时,该64K的内存块加入内存中,永不还回给操作系统,对于大于64K的内存分配,调用malloc()分配,释放时调用free()函数换回给Glibc。这些大量的64K的内存块长时间存在于内存池中,导致了Glibc中缓存了大量的内存碎片不能释放回
    操作系统。

比如:假如应用层分配内存的顺序是64K100K64K,然后释放100K的内存块,Glibc会缓存这个100K的内存块,其中的两个64K内存块都在mempool中,一直不释放,如果下次再分配64K的内存,就会将100K的内存块拆分成64K和36K的两个内存块,64K的内存块返回给应用层,并被mempool缓存,但剩下的36K被Glibc缓存,再也不能被应用层分配了,因为应用层分配的最小内存为64K`,这个36K的内存块就是内存碎片,这也是内存暴增的原因之一。

问题找到了,解决的办法可以参考如下几种:

  1. 禁用ptmallocmmap分配阈值动态调整机制。通过mallopt()设置M_TRIM_THRESHOLDM_MMAP_THRESHOLDM_TOP_PADM_MMAP_MAX中任意一个,关闭mmap分配阈值动态调整机制,同时需要将mmap分配阈值设置为64K,大于64K的内存分配都使用mmap向系统分配,释放大于64K的内存将调用munmap释放回系统。但强烈建议不要这么做,这会大大降低ptmalloc的分配释放效率。因为系统调用mmap是串行的,操作系统需要对mmap分配内存加锁,而且操作系统对mmap的物理页强制清0很慢。
  2. 我们系统的关键问题出在全局内存池,它分配的内存是长生命周期的大内存块,通过前面的分析可知,对长生命周期的大内存块分配最好用mmap系统调用直接向操作系统分配,回收时用munmap返回给操作系统。比如内存池每次用mmap向操作系统分配8M或是更多的虚拟内存。如果非要用ptmallocmalloc函数分配内存,就得绕过ptmallocmmap分配阈值动态调整机制,mmap分配阈值在64位系统上的最大值为32M,如果分配的内存大于32M,可以保证malloc分配的内存肯定是用mmap向操作系统分配的,回收时free一定会返回给操作系统,而不会被ptmalloc缓存用于下一次分配。但是如果这样使用malloc分配的话,其实malloc就是mmap的简单封装,还不如直接使用mmap系统调用想操作系统分配内存来得简单,并且显式调用munmap回收分配的内存,根本不依赖ptmalloc的实现。
  3. 改写内存cache,使用free list管理所分配的内存块。使用预分配优化已有的代码,尽量在每个请求过程中少分配内存。并使用线程私有内存块来存放线程所使用的私有实例。这种解决办法也是暂时的。
  4. 从长远的设计来看,我们的系统也是分阶段执行的,每次网络请求都会分配2MB为单位内存,请求完成后释放请求锁分配的内存,内存池最适合这种情景的操作。我们的线程池至少需要包含对2MB和几种系统中常用分配大小的支持,采用与TCMalloc类似的无锁设计,使用线程私用变量的形式尽量减少分配时线程对锁的争用。或者直接使用TCMalloc,免去了很多的线程池设计考虑。

源代码分析

本部分主要对源代码实现技巧的细节做分析,希望能进一步理解ptmalloc的实现,做到终极无惑。主要分析的文件包括arena.cmalloc.c,这两个文件包括了ptmalloc的核心实现,其中arena.c主要是对多线程支持的实现,malloc.c定义了公用的malloc()free()等函数,实现了基于分配区的内存管理算法。

边界标记法

ptmalloc使用chunk实现内存管理,对chunk的管理基于独特的边界标记法。

在不同的平台下,每个chunk的最小大小,地址对齐方式是不同的,ptmalloc依赖平台定义的size_t长度,对于32位平台,size_t长度为4字节,对64位平台,size_t长度可能为4字节,也可能为8字节,在Linux X86_64size_t为8字节,这里就以size_t为4字节和8字节的情况进行分析。先看一段源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef INTERNAL_SIZE_T
#define INTERNAL_SIZE_T size_t
#endif

/* The corresponding word size */
#define SIZE_SZ (sizeof(INTERNAL_SIZE_T))

/*
MALLOC_ALIGNMENT is the minimum alignment for malloc'ed chunks.
It must be a power of two at least 2 * SIZE_SZ, even on machines
for which smaller alignments would suffice. It may be defined as
larger than this though. Note however that code and data structures
are optimized for the case of 8-byte alignment.
*/

#ifndef MALLOC_ALIGNMENT
#define MALLOC_ALIGNMENT (2 * SIZE_SZ)
#endif
/* The corresponding bit mask value */
#define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1)

ptmalloc使用宏来屏蔽不同平台的差异,将INTERNAL_SIZE_T定义为size_tSIZE_SZ定义为size_t的大小,在32位平台下位4字节,在64位平台下位4字节或者8字节。另外分配chunk时必须以2*SIZE_SZ对齐,MALLOC_ALIGNMENTMALLOC_ALIGN_MASK是用来处理chunk地址对齐的宏。在32平台chunk地址按8字节对齐,64位平台按8字节或是16字节对齐。

ptmalloc采用边界标记法将内存划分成很多块,从而对内存的分配与回收进行管理。在ptmalloc的实现源码中定义结构体malloc_chunk来描述这些块,并使用宏封装了对chunk中每个域的读取,修改,校验,遍历等等。malloc_chunk定义如下:

1
2
3
4
5
6
7
8
9
10
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

chunk的定义相当简单明了,对各个域做一下简单介绍:

  • prev_size:如果前一个chunk是空闲的,该域表示前一个chunk的大小,如果前一个chunk不空闲,该域无意义。
  • size:当前chunk的大小,并且记录了当前chunk和前一个chunk的一些属性,包括前一个chunk是否在使用中,当前chunk是否是通过mmap获得的内存,当前chunk是否属于非主分配区。
  • fdbk:指针fdbk只有当该chunk块空闲时才存在,其作用是用于将对应的空闲chunk块加入到空闲chunk块链表中统一管理,如果该chunk块被分配给应用程序使用,那么这两个指针也就没有用(该chunk块已经从空闲链中拆出)了,所以也当作应用程序的使用空间,而不至于浪费。
  • fd_nextsizebk_nextsize:当当前的chunk存在于large bins中时,large bins中的空闲chunk是按照大小排序的,但同一个大小的chunk可能有多个,增加了这两个字段可以加快遍历空闲chunk,并查找满足需要的空闲chunkfd_nextsize指向下一个比当前chunk大小大的第一个空闲chunkbk_nextszie指向前一个比当前chunk大小小的第一个空闲chunk。如果该chunk块被分配给应用程序使用,那么这两个指针也就没有用(该chunk块已经从size链中拆出)了,所以也当作应用程序的使用空间,而不至于浪费。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/*
malloc_chunk details:
Chunks of memory are maintained using a `boundary tag' method as
described in e.g., Knuth or Standish. Sizes of free chunks are stored both
in the front of each chunk and at the end. This makes
consolidating fragmented chunks into bigger chunks very fast. The
size fields also hold bits representing whether chunks are free or
in use.
An allocated chunk looks like this:
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if allocated | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
. |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Where "chunk" is the front of the chunk for the purpose of most of
the malloc code, but "mem" is the pointer that is returned to the
user. "Nextchunk" is the beginning of the next contiguous chunk.
Chunks always begin on even word boundries, so the mem portion
(which is returned to the user) is also on an even word boundary, and
thus at least double-word aligned.

Free chunks are stored in circular doubly-linked lists, and look like this:
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
`head:' | Size of chunk, in bytes |P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Forward pointer to next chunk in list |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Back pointer to previous chunk in list |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Unused space (may be 0 bytes long) .
. .
. |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
`foot:' | Size of chunk, in bytes |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

The P (PREV_INUSE) bit, stored in the unused low-order bit of the
chunk size (which is always a multiple of two words), is an in-use
bit for the *previous* chunk. If that bit is *clear*, then the
word before the current chunk size contains the previous chunk
size, and can be used to find the front of the previous chunk.
The very first chunk allocated always has this bit set,
preventing access to non-existent (or non-owned) memory. If
prev_inuse is set for any given chunk, then you CANNOT determine
the size of the previous chunk, and might even get a memory
addressing fault when trying to do so.
Note that the `foot' of the current chunk is actually represented
as the prev_size of the NEXT chunk. This makes it easier to
deal with alignments etc but can be very confusing when trying
to extend or adapt this code.
The two exceptions to all this are
1. The special chunk `top' doesn't bother using the
trailing size field since there is no next contiguous chunk
that would have to index off it. After initialization, `top'30
is forced to always exist. If it would become less than
MINSIZE bytes long, it is replenished.
2. Chunks allocated via mmap, which have the second-lowest-order
bit M (IS_MMAPPED) set in their size fields. Because they are
allocated one-by-one, each must contain its own trailing size field.
*/

上面这段注释详细描述了chunk的细节,已分配的chunk和空闲的chunk形式不一样,充分利用空间复用,设计相当的巧妙。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* conversion from malloc headers to user pointers, and back */
#define chunk2mem(p) ((void_t*)((char*)(p) + 2*SIZE_SZ))

#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - 2*SIZE_SZ))

/* The smallest possible chunk */
#define MIN_CHUNK_SIZE (offsetof(struct malloc_chunk, fd_nextsize))

/* The smallest size we can malloc is an aligned minimal chunk */
#define MINSIZE \
(unsigned long)(((MIN_CHUNK_SIZE+MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK))
/* Check if m has acceptable alignment */
#define aligned_OK(m) (((unsigned long)(m) & MALLOC_ALIGN_MASK) == 0)

#define misaligned_chunk(p) \
((uintptr_t)(MALLOC_ALIGNMENT == 2 * SIZE_SZ ? (p) : chunk2mem (p)) \
& MALLOC_ALIGN_MASK)

对于已经分配的chunk,通过chunk2mem宏根据chunk地址获得返回给用户的内存地址,反过来通过mem2chunk宏根据mem地址得到chunk地址,chunk的地址是按2*SIZE_SZ对齐的,而chunk结构体的前两个域刚好也是2*SIZE_SZ大小,所以,mem地址也是2*SIZE_SZ对齐的。宏aligned_OKmisaligned_chunk(p)用于校验地址是否是按2*SIZE_SZ对齐的。MIN_CHUNK_SIZE定义了最小的chunk的大小,32位平台上位16字节,64位平台为24字节或是32字节。MINSIZE定义了最小的分配的内存大小,是对MIN_CHUNK_SIZE进行了
2*SIZE_SZ对齐,地址对齐后与MIN_CHUNK_SIZE的大小仍然是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
Check if a request is so large that it would wrap around zero when
padded and aligned. To simplify some other code, the bound is made
low enough so that adding MINSIZE will also not wrap around zero.
*/
#define REQUEST_OUT_OF_RANGE(req) \
((unsigned long)(req) >= \
(unsigned long)(INTERNAL_SIZE_T)(-2 * MINSIZE))
/* pad request bytes into a usable size -- internal version */
#define request2size(req) \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) ? \
MINSIZE : \
((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
/* Same, except also perform argument check */
#define checked_request2size(req, sz) \
if (REQUEST_OUT_OF_RANGE(req)) { \
MALLOC_FAILURE_ACTION; \
return 0; \
} \
(sz) = request2size(req);

这几个宏用于将用户请求的分配大小转换成内部需要分配的chunk大小,这里需要注意的在转换时不但考虑的地址对齐,还额外加上了SIZE_SZ,这意味着ptmalloc分配内存需要一个额外的overhead,为SIZE_SZ字节,通过chunk的空间复用,我们很容易得出这个overheadSIZE_SZ

Linux X86_64平台为例,假设SIZE_SZ为8字节,空闲时,一个chunk中至少要4个size_t(8B)大小的空间,用来存储prev_sizesizefdbk,也就是MINSIZE(32B),chunk的大小要对齐到2*SIZE_SZ(16B)。当一个chunk处于使用状态时,它的下一个chunkprev_size域肯定是无效的。所以实际上,这个空间也可以被当前chunk使用。这听起来有点不可思议,但确实是合理空间复用的例子。

故而实际上,一个使用中的chunk的大小的计算公式应该是:in_use_size = (用户请求大小+ 16 - 8 ) align to 8B,这里加16是因为需要存储prev_sizesize,但又因为向下一个chunk“借”了8B,所以要减去8,每分配一个chunkoverhead8B,即SIZE_SZ的大小。最后,因为空闲的chunk和使用中的chunk使用的是同一块空间。所以肯定要取其中最大者作为实际的分配空间。即最终的分配空间chunk_size = max(in_use_size, 32)。这就是当用户请求内存分配时,ptmalloc实际需要分配的内存大小。

注意:如果chunk是由mmap()直接分配的,则该chunk不会有前一个chunk和后一个chunk,所有本chunk没有下一个chunkprev_size的空间可以“借”,所以对于直接mmap()分配内存的overhead2*SIZE_SZ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* size field is or'ed with PREV_INUSE when previous adjacent chunk in use */
#define PREV_INUSE 0x1

/* extract inuse bit of previous chunk */
#define prev_inuse(p) ((p)->size & PREV_INUSE)

/* size field is or'ed with IS_MMAPPED if the chunk was obtained with mmap() */
#define IS_MMAPPED 0x2

/* check for mmap()'ed chunk */
#define chunk_is_mmapped(p) ((p)->size & IS_MMAPPED)

/* size field is or'ed with NON_MAIN_ARENA if the chunk was obtained
from a non-main arena. This is only set immediately before handing
the chunk to the user, if necessary. */
#define NON_MAIN_ARENA 0x432

/* check for chunk from non-main arena */
#define chunk_non_main_arena(p) ((p)->size & NON_MAIN_ARENA)

chunk在分割时总是以地址对齐(默认是8字节,可以自由设置,但是8字节是最小值并且设置的值必须是2为底的幂函数值,即是alignment = 2^nn为整数且n>=3)的方式来进行的,所以用chunk->size来存储本chunk块大小字节数的话,其末3bit位总是0,因此这三位可以用来存储其它信息,比如:

  • 以第0位作为P状态位,标记前一chunk块是否在使用中,为1表示使用,为0表示空闲。
  • 以第1位作为M状态位,标记本chunk块是否是使用mmap()直接从进程的mmap映射区域分配的,为1表示是,为0表示否。
  • 以第2位作为A状态位,标记本chunk是否属于非主分配区,为1表示是,为0表示否。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
Bits to mask off when extracting size
Note: IS_MMAPPED is intentionally not masked off from size field in
macros for which mmapped chunks should never be seen. This should
cause helpful core dumps to occur if it is tried by accident by
people extending or adapting this malloc.
*/
#define SIZE_BITS (PREV_INUSE|IS_MMAPPED|NON_MAIN_ARENA)

/* Get size, ignoring use bits */
#define chunksize(p) ((p)->size & ~(SIZE_BITS))

/* Ptr to next physical malloc_chunk. */
#define next_chunk(p) ((mchunkptr)( ((char*)(p)) + ((p)->size & ~SIZE_BITS) ))

/* Ptr to previous physical malloc_chunk */
#define prev_chunk(p) ((mchunkptr)( ((char*)(p)) - ((p)->prev_size) ))

/* Treat space at ptr + offset as a chunk */
#define chunk_at_offset(p, s) ((mchunkptr)(((char*)(p)) + (s)))

prev_size字段虽然在当前chunk块结构体内,记录的却是前一个邻接chunk块的信息,这样做的好处就是我们通过本块chunk结构体就可以直接获取到前一chunk块的信息,从而方便做进一步的处理操作。相对的,当前chunk块的foot信息就存在于下一个邻接chunk块的结构体内。字段prev_size记录的什么信息呢?有两种情况:

  1. 如果前一个邻接chunk块空闲,那么当前chunk块结构体内的prev_size字段记录的是前一个邻接chunk块的大小。这就是由当前chunk指针获得前一个空闲chunk地址的依据。宏prev_chunk(p)就是依赖这个假设实现的。
  2. 如果前一个邻接chunk在使用中,则当前chunkprev_size的空间被前一个chunk借用中,其中的值是前一个chunk的内存内容,对当前chunk没有任何意义。字段size记录了本chunk的大小,无论下一个chunk是空闲状态或是被使用状态,都可以通过本chunk的地址加上本chunk的大小,得到下一个chunk的地址,由于size的低3个bit记录了控制信息,需要屏蔽掉这些控制信息,取出实际的size在进行计算下一个chunk地址,这是next_chunk(p)的实现原理。

chunksize(p)用于获得chunk的实际大小,需要屏蔽掉size中的控制信息。宏chunk_at_offset(p, s)p+s的地址强制看作一个chunk

注意:按照边界标记法,可以有多个连续的并且正在被使用中的chunk块,但是不会有多个连续的空闲chunk块,因为连续的多个空闲chunk块一定会合并成一个大的空闲chunk块。

1
2
3
4
5
6
7
8
9
10
11
/* extract p's inuse bit */
#define inuse(p)\
((((mchunkptr)(((char*)(p))+((p)->size & ~SIZE_BITS)))->size) & PREV_INUSE)

/* set/clear chunk as being inuse without otherwise disturbing */
#define set_inuse(p)\
((mchunkptr)(((char*)(p)) + ((p)->size & ~SIZE_BITS)))->size |= PREV_INUSE

#define clear_inuse(p)\
((mchunkptr)(((char*)(p)) + ((p)->size & ~SIZE_BITS)))->size &= ~(PREV_INUSE)

上面的这一组宏用于check/set/clear当前chunk使用标志位,有当前chunk的使用标志位存储在下一个chunksize的第0 bit (P状态位),所以首先要获得下一个chunk的地址,然后check/set/clear下一个chunksize域的第0 bit。

1
2
3
4
5
6
7
/* check/set/clear inuse bits in known places */
#define inuse_bit_at_offset(p, s)\
(((mchunkptr)(((char*)(p)) + (s)))->size & PREV_INUSE)
#define set_inuse_bit_at_offset(p, s)\
(((mchunkptr)(((char*)(p)) + (s)))->size |= PREV_INUSE)
#define clear_inuse_bit_at_offset(p, s)\
(((mchunkptr)(((char*)(p)) + (s)))->size &= ~(PREV_INUSE))

上面的三个宏用于check/set/clear指定chunksize域中的使用标志位。

1
2
3
4
5
6
7
8
/* Set size at head, without disturbing its use bit */
#define set_head_size(p, s) ((p)->size = (((p)->size & SIZE_BITS) | (s)))

/* Set size/use field */
#define set_head(p, s) ((p)->size = (s))

/* Set size at footer (only when chunk is not in use) */
#define set_foot(p, s) (((mchunkptr)((char*)(p) + (s)))->prev_size = (s))

set_head_size(p, s)用于设置当前chunk psize域并保留size域的控制信息。宏set_head(p, s)用于设置当前chunk psize域并忽略已有的size域控制信息。宏set_foot(p, s)用于设置当前chunk p的下一个chunkprev_sizess为当前chunksize,只有当chunk p为空闲时才能使用这个宏,当前chunkfoot的内存空间存在于下一个chunk,即下一个chunkprev_size

分箱式内存管理

对于空闲的chunkptmalloc采用分箱式内存管理方式,根据空闲chunk的大小和处于的状态将其放在四个不同的bin中,这四个空闲chunk的容器包括fast binsunsorted binsmall binslarge binsfast bins是小内存块的高速缓存,当一些大小小于64字节的chunk被回收时,首先会放入fast bins中,在分配小内存时,首先会查看fast bins中是否有合适的内存块,如果存在,则直接返回fast bins中的内存块,以加快分配速度。

usorted bin只有一个,回收的chunk块必须先放到unsorted bin中,分配内存时会查看unsorted bin中是否有合适的chunk,如果找到满足条件的chunk,则直接返回给用户,否则将unsorted bin的所有chunk放入small bins或是large bins中。

small bins用于存放固定大小的chunk,共64个bin,最小的chunk大小为16字节或32字节,每个bin的大小相差8字节或是16字节,当
分配小内存块时,采用精确匹配的方式从small bins中查找合适的chunk

large bins用于存储大于等于512B或1024B的空闲chunk,这些chunk使用双向链表的形式按大小顺序排序,分配内存时按最近匹配方式从large bins中分配chunk

small bins

ptmalloc使用small bins管理空闲小chunk,每个small bin中的chunk的大小与binindex有如下关系:

1
chunk_size=2 * SIZE_SZ * index

SIZE_SZ为4B的平台上,small bins中的chunk大小是以8B为公差的等差数列,最大的chunk大小为504B,最小的chunk大小为16B,所以实际共62个bin。分别为16B24B32B,„„,504B。在SIZE_SZ为8B的平台上,small bins中的chunk大小是以16B为公差的等差数列,最大的chunk大小为1008B,最小的chunk大小为32B,所以实际共62个bin。分别为32B48B64B,„„,1008B

ptmalloc维护了62个双向环形链表(每个链表都具有链表头节点,加头节点的最大作用就是便于对链表内节点的统一处理,即简化编程),每一个链表内的各空闲chunk的大小一致,因此当应用程序需要分配某个字节大小的内存空间时直接在对应的链表内取就可以了,这样既可以很好的满足应用程序的内存空间申请请求而又不会出现太多的内存碎片。我们可以用如下图来表示在SIZE_SZ为4B的平台上ptmalloc对512B字节以下的空闲chunk组织方式(所谓的分箱机制)。

large bins

SIZE_SZ为4B的平台上,大于等于512B的空闲chunk,或者,在SIZE_SZ为8B的平台上,大小大于等于1024B的空闲chunk,由sorted bins管理。large bins一共包括63个bin,每个bin中的chunk大小不是一个固定公差的等差数列,而是分成6组bin,每组bin是一个
固定公差的等差数列,每组的bin数量依次为32、 16、 8、 4、 2、 1,公差依次为64B、 512B、4096B、 32768B、 262144B等。

SIZE_SZ为4B的平台为例,第一个large bin的起始chunk大小为512B,共32个bin,公差为64B,等差数列满足如下关系:

1
chunk_size=512 + 64 * index

第二个large bin的起始chunk大小为第一组bin的结束chunk大小,满足如下关系:

1
chunk_size=512 + 64 * 32 + 512 * index

同理,我们可计算出每个bin的起始chunk大小和结束chunk大小。这些bin都是很有规律的,其实small bins也是满足类似规律,small bins可以看着是公差为8的等差数列,一共有64个bin(第0和1bin不存在),所以我们可以将small binslarge bins存放在同一个包含128个chunk的数组上,数组的前一部分位small bins,后一部分为large bins,每个binindexchunk数组的下标,于是,我们可以根据数组下标计算出该binchunk大小(small bins)或是chunk大小范围(large bins),也可以根据需要分配内存块大小计算出所需chunk所属binindexptmalloc使用了一组宏巧妙的实现了这种计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#define NBINS 128
#define NSMALLBINS 64
#define SMALLBIN_WIDTH MALLOC_ALIGNMENT
#define MIN_LARGE_SIZE (NSMALLBINS * SMALLBIN_WIDTH)

#define in_smallbin_range(sz) \
((unsigned long)(sz) < (unsigned long)MIN_LARGE_SIZE)

#define smallbin_index(sz) \
(SMALLBIN_WIDTH == 16 ? (((unsigned)(sz)) >> 4) : (((unsigned)(sz)) >> 3))

#define largebin_index_32(sz) \
(((((unsigned long)(sz)) >> 6) <= 38)? 56 + (((unsigned long)(sz)) >> 6): \
((((unsigned long)(sz)) >> 9) <= 20)? 91 + (((unsigned long)(sz)) >> 9): \
((((unsigned long)(sz)) >> 12) <= 10)? 110 + (((unsigned long)(sz)) >> 12): \
((((unsigned long)(sz)) >> 15) <= 4)? 119 + (((unsigned long)(sz)) >> 15): \
((((unsigned long)(sz)) >> 18) <= 2)? 124 + (((unsigned long)(sz)) >> 18): \
126)

// XXX It remains to be seen whether it is good to keep the widths of
// XXX the buckets the same or whether it should be scaled by a factor
// XXX of two as well.
#define largebin_index_64(sz) \
(((((unsigned long)(sz)) >> 6) <= 48)? 48 + (((unsigned long)(sz)) >> 6): \
((((unsigned long)(sz)) >> 9) <= 20)? 91 + (((unsigned long)(sz)) >> 9): \
((((unsigned long)(sz)) >> 12) <= 10)? 110 + (((unsigned long)(sz)) >> 12): \
((((unsigned long)(sz)) >> 15) <= 4)? 119 + (((unsigned long)(sz)) >> 15): \
((((unsigned long)(sz)) >> 18) <= 2)? 124 + (((unsigned long)(sz)) >> 18): \
126)

#define largebin_index(sz) \
(SIZE_SZ == 8 ? largebin_index_64 (sz) : largebin_index_32 (sz))

#define bin_index(sz) \
((in_smallbin_range(sz)) ? smallbin_index(sz) : largebin_index(sz))

bin_index(sz)根据所需内存大小计算出所需binindex,如果所需内存大小属于small bins的大小范围,调用smallbin_index(sz),否则调用largebin_index(sz))smallbin_index(sz)的计算相当简单,如果SIZE_SZ4B,则将sz除以8,如果SIZE_SZ8B,则将sz除以16,也就是除以small bins中等差数列的公差。largebin_index(sz)的计算相对复杂一些,可以用如下的表格直观的显示chunk的大小范围与bin index的关系。以SIZE_SZ为4B的平台为例,chunk大小与bin index的对应关系如下表所示:

开始(字节) 结束(字节) Bin index
0 7 不存在
8 15 不存在
16 23 2
24 31 3
32 39 4
40 47 5
48 55 6
56 63 7
64 71 8
72 79 9
80 87 10
88 95 11
96 103 12
104 111 13
112 119 14
120 127 15
128 135 16
136 143 17
144 151 18
152 159 19
160 167 20
168 175 21
176 183 22
184 191 23
192 199 24
200 207 25
208 215 26
216 223 27
224 231 28
232 239 29
240 247 30
248 255 31
256 263 32
264 271 33
272 279 34
280 287 35
288 295 36
296 303 37
304 311 38
312 319 39
320 327 40
328 335 41
336 343 42
344 351 43
352 359 44
360 367 45
368 375 46
376 383 47
384 391 48
392 399 49
400 407 50
408 415 51
416 423 52
424 431 53
432 439 54
440 447 55
448 455 56
456 463 57
464 471 58
472 479 59
480 487 60
488 495 61
496 503 62
504 511 63
512 575 64
576 639 65
640 703 66
704 767 67
768 831 68
832 895 69
896 959 70
960 1023 71
1024 1087 72
1088 1151 73
1152 1215 74
1216 1279 75
1280 1343 76
1344 1407 77
1408 1471 78
1472 1535 79
1536 1599 80
1600 1663 81
1664 1727 82
1728 1791 83
1792 1855 84
1856 1919 85
1920 1983 86
1984 2047 87
2048 2111 88
2112 2175 89
2176 2239 90
2240 2303 91
2304 2367 92
2368 2431 93
2432 2495 94
2496 2559 95
2560 3071 96
3072 3583 97
3584 4095 98
4096 4607 99
4608 5119 100
5120 5631 101
5632 6143 102
6144 6655 103
6656 7167 104
7168 7679 105
7680 8191 106
8192 8703 107
8704 9215 108
9216 9727 109
9728 10239 110
10240 10751 111
10752 14847 112
14848 18943 113
18944 23039 114
23040 27135 115
27136 31231 116
31232 35327 117
35328 39423 118
39424 43519 119
43520 76287 120
76288 109055 121
109056 141823 122
141824 174591 123
174592 436735 124
436736 698879 125
698880 2^32或2^64 126

注意:上表是chunk大小与bin index的对应关系,如果对于用户要分配的内存大小size,必须先使用checked_request2size(req, sz)计算出chunk的大小,再使用bin_index(sz)计算出chunk所属的bin index

对于SIZE_SZ为4B的平台,bin[0]bin[1]是不存在的,因为最小的chunk16Bsmall bins一共62个,large bins一共63个,加起来一共125个bin。而NBINS定义为128,其实bin[0]bin[127]都不存在,bin[1]unsorted binchunk链表头。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
typedef struct malloc_chunk* mbinptr;

/* addressing -- note that bin_at(0) does not exist */
#define bin_at(m, i) \
(mbinptr) (((char *) &((m)->bins[((i) - 1) * 2])) \
- offsetof (struct malloc_chunk, fd))

/* analog of ++bin */
#define next_bin(b) ((mbinptr)((char*)(b) + (sizeof(mchunkptr)<<1)))

/* Reminders about list directionality within bins */
#define first(b) ((b)->fd)
#define last(b) ((b)->bk)

/* Take a chunk off a bin list */
#define unlink(P, BK, FD) { \
FD = P->fd; \
BK = P->bk; \
if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \
malloc_printerr (check_action, "corrupted double-linked list", P); \
else { \
FD->bk = BK; \
BK->fd = FD; \
if (!in_smallbin_range (P->size) \
&& __builtin_expect (P->fd_nextsize != NULL, 0)) { \
assert (P->fd_nextsize->bk_nextsize == P); \
assert (P->bk_nextsize->fd_nextsize == P); \
if (FD->fd_nextsize == NULL) { \
if (P->fd_nextsize == P) \
FD->fd_nextsize = FD->bk_nextsize = FD; \
else { \
FD->fd_nextsize = P->fd_nextsize; \
FD->bk_nextsize = P->bk_nextsize; \
P->fd_nextsize->bk_nextsize = FD; \
P->bk_nextsize->fd_nextsize = FD; \
} \
} else { \
P->fd_nextsize->bk_nextsize = P->bk_nextsize; \
P->bk_nextsize->fd_nextsize = P->fd_nextsize; \
} \
} \
} \
}

bin_at(m, i)通过bin index获得bin的链表头,chunk中的fbbk用于将空闲chunk链入链表中,而对于每个bin的链表头,只需要这两个域就可以了,prev_sizesize对链表都来说都没有意义,浪费空间,ptmalloc为了节约这点内存空间,增大cpu高速缓存的命中率,在bins数组中只为每个bin预留了两个指针的内存空间用于存放bin的链表头的fbbk指针。

bin_at(m, i)的定义可以看出,bin[0]不存在,以SIZE_SZ为4B的平台为例,bin[1]的前4B存储的是指针fb,后4B存储的是指针bk,而bin_at返回的是malloc_chunk的指针,由于fbmalloc_chunk的偏移地址为offsetof (struct malloc_chunk, fd))=8,所以用fb的地址减去8就得到malloc_chunk的地址。但切记,对bin的链表头的chunk,一定不能修改prev_sizesize域,这两个域是与其他bin的链表头的fbbk内存复用的。

next_bin(b)用于获得下一个bin的地址,根据前面的分析,我们知道只需要将当前bin的地址向后移动两个指针的长度就得到下一个bin的链表头地址。每个bin使用双向循环链表管理空闲chunkbin的链表头的指针fb指向第一个可用的chunk,指针bk指向最后一个可用的chunk

first(b)用于获得bin的第一个可用chunk,宏last(b)用于获得bin的最后一个可用的chunk,这两个宏便于遍历bin,而跳过bin的链表头。

unlink(P, BK, FD)用于将chunk从所在的空闲链表中取出来,注意large bins中的空闲chunk可能处于两个双向循环链表中,unlink时需要从两个链表中都删除。

unsorted bin

unsorted bin可以看作是small binslarge binscache,只有一个unsorted bin,以双向链表管理空闲chunk,空闲chunk不排序,所有的chunk在回收时都要先放到unsorted bin中,分配时,如果在unsorted bin中没有合适的chunk,就会把unsorted bin中的所有chunk分别加入到所属的bin中,然后再在bin中分配合适的chunkbins数组中的元素bin[1]用于存储unsorted binchunk链表头。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
Unsorted chunks
All remainders from chunk splits, as well as all returned chunks,
are first placed in the "unsorted" bin. They are then placed
in regular bins after malloc gives them ONE chance to be used before
binning. So, basically, the unsorted_chunks list acts as a queue,
with chunks being placed on it in free (and malloc_consolidate),
and taken off (to be either used or placed in bins) in malloc.
The NON_MAIN_ARENA flag is never set for unsorted chunks, so it
does not have to be taken into account in size comparisons.
*/
/* The otherwise unindexable 1-bin is used to hold unsorted chunks. */
#define unsorted_chunks(M) (bin_at(M, 1))

/*
Top
The top-most available chunk (i.e., the one bordering the end of
available memory) is treated specially. It is never included in
any bin, is used only if no other chunk is available, and is
released back to the system if it is very large (see
M_TRIM_THRESHOLD). Because top initially
points to its own bin with initial zero size, thus forcing
extension on the first malloc request, we avoid having any special
code in malloc to check whether it even exists yet. But we still
need to do so when getting memory from system, so we make
initial_top treat the bin as a legal but unusable chunk during the
interval between initialization and the first call to
sYSMALLOc. (This is somewhat delicate, since it relies on
the 2 preceding words to be zero during this interval as well.)
*/

/* Conveniently, the unsorted bin can be used as dummy top on first call */
#define initial_top(M) (unsorted_chunks(M))

上面的宏的定义比较明显,把bin[1]设置为unsorted binchunk链表头,对top chunk的初始化,也暂时把top chunk初始化为unsorted chunk,仅仅是初始化一个值而已,这个chunk的内容肯定不能用于top chunk来分配内存,主要原因是top chunk不属于任何bin,但ptmalloc中的一些check代码,可能需要top chunk属于一个合法的bin

fast bins

fast bins主要是用于提高小内存的分配效率,默认情况下,对于SIZE_SZ为4B的平台,小于64Bchunk分配请求,对于SIZE_SZ为8B的平台,小于128B的chunk分配请求,首先会查找fast bins中是否有所需大小的chunk存在(精确匹配),如果存在,就直接返回。fast bins可以看着是small bins的一小部分cache,默认情况下,fast binscachesmall bins的前7个大小的空闲chunk,也就是说,对于SIZE_SZ为4B的平台,fast bins有7个chunk空闲链表(bin),每个binchunk大小依次为16B24B32B40B48B56B64B;对于SIZE_SZ为8B的平台,fast bins有7个chunk空闲链表(bin),每个binchunk大小依次为32B48B64B80B96B112B128B。以32为系统为例,分配的内存大小与chunk大小和fast bins的对应关系如下表所示:

fast bins可以看着是LIFO的栈,使用单向链表实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
Fastbins
An array of lists holding recently freed small chunks. Fastbins
are not doubly linked. It is faster to single-link them, and
since chunks are never removed from the middles of these lists,
double linking is not necessary. Also, unlike regular bins, they
are not even processed in FIFO order (they use faster LIFO) since
ordering doesn't much matter in the transient contexts in which
fastbins are normally used.
Chunks in fastbins keep their inuse bit set, so they cannot
be consolidated with other free chunks. malloc_consolidate43
releases all chunks in fastbins and consolidates them with
other free chunks.
*/
typedef struct malloc_chunk* mfastbinptr;
#define fastbin(ar_ptr, idx) ((ar_ptr)->fastbinsY[idx])

根据fast binindex,获得fast bin的地址。fast bins的数字定义在malloc_state中。

1
2
3
/* offset 2 to use otherwise unindexable first 2 bins */
#define fastbin_index(sz) \
((((unsigned int)(sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)

fastbin_index(sz)用于获得fast binfast bins数组中的index,由于bin[0]bin[1]中的chunk不存在,所以需要减2,对于SIZE_SZ为4B的平台,将sz除以8减2得到fast bin index,对于SIZE_SZ为8B的平台,将sz除以16减去2得到fast bin index

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* The maximum fastbin request size we support */
#define MAX_FAST_SIZE (80 * SIZE_SZ / 4)

#define NFASTBINS (fastbin_index(request2size(MAX_FAST_SIZE))+1)

/*
FASTBIN_CONSOLIDATION_THRESHOLD is the size of a chunk in free()
that triggers automatic consolidation of possibly-surrounding
fastbin chunks. This is a heuristic, so the exact value should not
matter too much. It is defined at half the default trim threshold as a
compromise heuristic to only attempt consolidation if it is likely
to lead to trimming. However, it is not dynamically tunable, since
consolidation reduces fragmentation surrounding large chunks even
if trimming is not used.
*/
#define FASTBIN_CONSOLIDATION_THRESHOLD (65536UL)

根据SIZE_SZ的不同大小,定义MAX_FAST_SIZE80B或是160Bfast bins数组的大小NFASTBINS为10,FASTBIN_CONSOLIDATION_THRESHOLD64k,当每次释放的chunk与该chunk相邻的空闲chunk合并后的大小大于64K时,就认为内存碎片可能比较多了,就需要把fast bins中的所有chunk都进行合并,以减少内存碎片对系统的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef DEFAULT_MXFAST
#define DEFAULT_MXFAST (64 * SIZE_SZ / 4)
#endif
/*
Set value of max_fast.
Use impossibly small value if 0.
Precondition: there are no existing fastbin chunks.
Setting the value clears fastchunk bit but preserves noncontiguous bit.
*/
#define set_max_fast(s) \
global_max_fast = (((s) == 0) \
? SMALLBIN_WIDTH: ((s + SIZE_SZ) & ~MALLOC_ALIGN_MASK))

#define get_max_fast() global_max_fast

上面的宏DEFAULT_MXFAST定义了默认的fast bins中最大的chunk大小,对于SIZE_SZ4B的平台,最大chunk64B,对于SIZE_SZ为8B的平台,最大chunk为128B。ptmalloc默认情况下调用set_max_fast(s)将全局变量global_max_fast设置为DEFAULT_MXFAST,也就是设置fast binschunk的最大值,get_max_fast()用于获得这个全局变量global_max_fast的值。

核心结构体分析

每个分配区是struct malloc_state的一个实例,ptmalloc使用malloc_state来管理分配区,而参数管理使用struct malloc_par,全局拥有一个唯一的malloc_par实例。

malloc_state

struct malloc_state的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct malloc_state {
/* Serialize access. */
mutex_t mutex;

/* Flags (formerly in max_fast). */
int flags;
#if THREAD_STATS
/* Statistics for locking. Only used if THREAD_STATS is defined. */
long stat_lock_direct, stat_lock_loop, stat_lock_wait;
#endif
/* Fastbins */
mfastbinptr fastbinsY[NFASTBINS];
/* Base of the topmost chunk -- not otherwise kept in a bin */
mchunkptr top;
/* The remainder from the most recent split of a small request */
mchunkptr last_remainder;
/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2];
/* Bitmap of bins */
unsigned int binmap[BINMAPSIZE];
/* Linked list */
struct malloc_state *next;
#ifdef PER_THREAD
/* Linked list for free arenas. */
struct malloc_state *next_free;
#endif
/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

mutex用于串行化访问分配区,当有多个线程访问同一个分配区时,第一个获得这个mutex的线程将使用该分配区分配内存,分配完成后,释放该分配区的mutex,以便其它线程使用该分配区。

flags记录了分配区的一些标志,bit0用于标识分配区是否包含至少一个fast bin chunkbit1用于标识分配区是否能返回连续的虚拟地址空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
FASTCHUNKS_BIT held in max_fast indicates that there are probably
some fastbin chunks. It is set true on entering a chunk into any
fastbin, and cleared only in malloc_consolidate.
The truth value is inverted so that have_fastchunks will be true
upon startup (since statics are zero-filled), simplifying
initialization checks.
*/
#define FASTCHUNKS_BIT (1U)

#define have_fastchunks(M) (((M)->flags & FASTCHUNKS_BIT) == 0)

#ifdef ATOMIC_FASTBINS
#define clear_fastchunks(M) catomic_or (&(M)->flags, FASTCHUNKS_BIT)
#define set_fastchunks(M) catomic_and (&(M)->flags, ~FASTCHUNKS_BIT)
#else
#define clear_fastchunks(M) ((M)->flags |= FASTCHUNKS_BIT)
#define set_fastchunks(M) ((M)->flags &= ~FASTCHUNKS_BIT)
#endif

上面的宏用于设置或是置位flagsfast chunk的标志位bit0,如果bit0为0,表示分配区中有fast chunk,如果为1表示没有fast chunk,初始化完成后的malloc_state实例中,flags值为0,表示该分配区中有fast chunk,但实际上没有,试图从fast bins中分配chunk都会返回NULL,在第一次调用函数malloc_consolidate()fast bins进行chunk合并时,如果max_fast大于0,会调用clear_fastchunks宏,标志该分配区中已经没有fast chunk,因为函数malloc_consolidate()会合并所有的fast bins中的chunkclear_fastchunks宏只会在函数malloc_consolidate()中调用。当有fast chunk加入fast bins时,就是调用set_fastchunks宏标46识分配区的fast bins中存在fast chunk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
NONCONTIGUOUS_BIT indicates that MORECORE does not return contiguous
regions. Otherwise, contiguity is exploited in merging together,
when possible, results from consecutive MORECORE calls.
The initial value comes from MORECORE_CONTIGUOUS, but is
changed dynamically if`mmap`is ever used as an sbrk substitute.
*/
#define NONCONTIGUOUS_BIT (2U)

#define contiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) == 0)

#define noncontiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) != 0)

#define set_noncontiguous(M) ((M)->flags |= NONCONTIGUOUS_BIT)

#define set_contiguous(M) ((M)->flags &= ~NONCONTIGUOUS_BIT)

flagsbit1如果为0,表示MORCORE返回连续虚拟地址空间,bit1为1,表示MORCORE返回非连续虚拟地址空间,对于主分配区,MORECORE其实为sbr(),默认返回连续虚拟地址空间,对于非主分配区,使用mmap()分配大块虚拟内存,然后进行切分来模拟主分配区的行为,而默认情况下mmap映射区域是不保证虚拟地址空间连续的,所以非住分配区默认分配非连续虚拟地址空间。

malloc_state中声明了几个对锁的统计变量,默认没有定义THREAD_STATS,所以不会对锁的争用情况做统计。

fastbinsY拥有10(NFASTBINS)个元素的数组,用于存放每个fast chunk链表头指针,所以fast bins最多包含10个fast chunk的单向链表。

top是一个chunk指针,指向分配区的top chunk

last_remainder是一个chunk指针,分配区上次分配small chunk时,从一个chunk中分裂出一个small chunk返回给用户,分裂后的剩余部分形成一个chunklast_remainder就是指向的这个chunk

bins用于存储unstored binsmall binslarge binschunk链表头,small bins一共62个,large bins一共63个,加起来一共125个bin。而NBINS定义为128,其实bin[0]bin[127]都不存在,bin[1]unsorted binchunk链表头,所以实际只有126个binsbins数组能存放了254(NBINS*2 – 2)个mchunkptr指针,而我们实现需要存储chunk的实例,一般情况下,chunk实例的大小为6个mchunkptr大小,这254个指针的大小怎么能存下126个chunk呢?

这里使用了一个技巧,如果按照我们的常规想法,也许会申请126个malloc_chunk结构体指针元素的数组,然后再给链表申请一个头节点(即126个),再让每个指针元素正确指向而形成126个具有头节点的链表。事实上,对于malloc_chunk类型的链表“头节点”,其内的prev_sizesize字段是没有任何实际作用的,fd_nextsizebk_nextsize字段只有large bins中的空闲chunk才会用到,而对于large bins的空闲chunk链表头不需要这两个字段,因此这四个字段所占空间如果不合理使用的话那就是白白的浪费。

我们再来看一看128个malloc_chunk结构体指针元素的数组占了多少内存空间呢?假设SIZE_SZ的大小为8B,则指针的大小也为8B,结果为126*2*8=2016字节。而126个malloc_chunk类型的链表“头节点”需要多少内存呢? 126*6*8=6048,真的是6048B么?不是,刚才不是说了,prev_sizesizefd_nextsizebk_nextsize这四个字段是没有任何实际作用的,因此完全可以被重用(覆盖),因此实际需要内存为126*2*8=2016bins指针数组的大小为,(128*2-2) *8=2032,2032大于2016(事实上最后16个字节都被浪费掉了),那么这254个malloc_chunk结构体指针元素数组所占内存空间就可以存储这126个头节点了。

binmap字段是一个int数组,ptmalloc用一个bit来标识该bit对应的bin中是否包含空闲chunk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
Binmap
To help compensate for the large number of bins, a one-level index
structure is used for bin-by-bin searching. `binmap' is a
bitvector recording whether bins are definitely empty so they can
be skipped over during during traversals. The bits are NOT always
cleared as soon as bins are empty, but instead only
when they are noticed to be empty during traversal in malloc.
*/
/* Conservatively use 32 bits per map word, even if on 64bit system */
#define BINMAPSHIFT 5
#define BITSPERMAP (1U << BINMAPSHIFT)
#define BINMAPSIZE (NBINS / BITSPERMAP)
#define idx2block(i) ((i) >> BINMAPSHIFT)
#define idx2bit(i) ((1U << ((i) & ((1U << BINMAPSHIFT)-1))))
#define mark_bin(m,i) ((m)->binmap[idx2block(i)] |= idx2bit(i))
#define unmark_bin(m,i) ((m)->binmap[idx2block(i)] &= ~(idx2bit(i)))
#define get_binmap(m,i) ((m)->binmap[idx2block(i)] & idx2bit(i))

binmap一共128bit,16字节,4个int大小,binmapint分成4个block,每个block有32个bit,根据bin indx可以使用宏idx2block计算出该binbinmap对应的bit属于哪个blockidx2bit宏取第i位为1,其它位都为0的掩码,举个例子:idx2bit(3)为“0000 1000”(只显示8位)。mark_bin设置第ibinbinmap中对应的bit位为1; unmark_bin设置第ibinbinmap中对应的bit位为0;get_binmap获取第ibinbinmap中对应的bit

next字段用于将分配区以单向链表链接起来。

next_free字段空闲的分配区链接在单向链表中,只有在定义了PER_THREAD的情况下才定义该字段。

system_mem字段记录了当前分配区已经分配的内存大小。

max_system_mem记录了当前分配区最大能分配的内存大小。

malloc_par

struct malloc_par的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct malloc_par {
/* Tunable parameters */
unsigned long trim_threshold;48
INTERNAL_SIZE_T top_pad;
INTERNAL_SIZE_T mmap_threshold;
#ifdef PER_THREAD
INTERNAL_SIZE_T arena_test;
INTERNAL_SIZE_T arena_max;
#endif
/* Memory map support */
int n_mmaps;
int n_mmaps_max;
int max_n_mmaps;
/* the mmap_threshold is dynamic, until the user sets
it manually, at which point we need to disable any
dynamic behavior. */
int no_dyn_threshold;
/* Cache malloc_getpagesize */
unsigned int pagesize;
/* Statistics */
INTERNAL_SIZE_T mmapped_mem;
INTERNAL_SIZE_T max_mmapped_mem;
INTERNAL_SIZE_T max_total_mem; /* only kept for NO_THREADS */
/* First address handed out by MORECORE/sbrk. */
char* sbrk_base;
};

trim_threshold字段表示收缩阈值,默认为128KB,当每个分配区的top chunk大小大于这个阈值时,在一定的条件下,调用free时会收缩内存,减小top chunk的大小。由于mmap分配阈值的动态调整,在free时可能将收缩阈值修改为mmap分配阈值的2倍,在64位系统上,mmap分配阈值最大值为32MB,所以收缩阈值的最大值为64MB,在32位系统上,mmap分配阈值最大值为512KB,所以收缩阈值的最大值为1MB。收缩阈值可以通过函数mallopt()进行设置。

top_pad字段表示在分配内存时是否添加额外的pad,默认该字段为0。

mmap_threshold字段表示mmap分配阈值,默认值为128KB,在32位系统上最大值为512KB,64位系统上的最大值为32MB,由于默认开启mmap分配阈值动态调整,该字段的值会动态修改,但不会超过最大值。

arena_testarena_max用于PER_THREAD优化,在32位系统上arena_test默认值为2,64位系统上的默认值为8,当每个进程的分配区数量小于等于arena_test时,不会重用已有的分配区。为了限制分配区的总数,用arena_max来保存分配区的最大数量,当系统中的分配区数量达到arena_max,就不会再创建新的分配区,只会重用已有的分配区。这两个字段都可以使用mallopt()函数设置。

n_mmaps字段表示当前进程使用mmap()函数分配的内存块的个数。

n_mmaps_max字段表示进程使用mmap()函数分配的内存块的最大数量,默认值为4965536,可以使用mallopt()函数修改。

max_n_mmaps字段表示当前进程使用mmap()函数分配的内存块的数量的最大值,有关系n_mmaps <= max_n_mmaps成立。这个字段是由于mstats()函数输出统计需要这个字段。

no_dyn_threshold字段表示是否开启mmap分配阈值动态调整机制,默认值为0,也就是默认开启mmap分配阈值动态调整机制。

pagesize字段表示系统的页大小,默认为4KB。mmapped_memmax_mmapped_mem都用于统计mmap分配的内存大小,一般情况下两个字段的值相等,max_mmapped_mem用于mstats()函数。

max_total_mem字段在单线程情况下用于统计进程分配的内存总数。sbrk_base字段表示堆的起始地址。

分配区的初始化

ptmalloc定义了如下几个全局变量:

1
2
3
4
5
6
7
8
9
10
/* There are several instances of this struct ("arenas") in this
malloc. If you are adapting this malloc in a way that does NOT use
a static or mmapped malloc_state, you MUST explicitly zero-fill it
before using. This malloc relies on the property that malloc_state
is initialized to all zeroes (as is true of C statics). */
static struct malloc_state main_arena;
/* There is only one instance of the malloc parameters. */
static struct malloc_par mp_;
/* Maximum size of memory handled in fastbins. */
static INTERNAL_SIZE_T global_max_fast;

main_arena表示主分配区,任何进程有且仅有一个全局的主分配区,mp_是全局唯一的一个malloc_par实例,用于管理参数和统计信息,global_max_fast全局变量表示fast bins中最大的chunk大小。

分配区main_arena初始化函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*
Initialize a malloc_state struct.
This is called only from within malloc_consolidate, which needs
be called in the same contexts anyway. It is never called directly
outside of malloc_consolidate because some optimizing compilers try
to inline it at all call points, which turns out not to be an
optimization at all. (Inlining it in malloc_consolidate is fine though.)
*/
#if __STD_C
static void malloc_init_state(mstate av)
#else
static void malloc_init_state(av) mstate av;
#endif
{
int i;
mbinptr bin;
/* Establish circular links for normal bins */
for (i = 1; i < NBINS; ++i) {
bin = bin_at(av,i);
bin->fd = bin->bk = bin;
}
#if MORECORE_CONTIGUOUS
if (av != &main_arena)
#endif
set_noncontiguous(av);
if (av == &main_arena)
set_max_fast(DEFAULT_MXFAST);
av->flags |= FASTCHUNKS_BIT;
av->top = initial_top(av);
}

分配区的初始化函数默认分配区的实例av是全局静态变量或是已经将av中的所有字段都清0了。初始化函数做的工作比较简单,首先遍历所有的bins,初始化每个bin的空闲链表为空,即将binfbbk都指向bin本身。由于av中所有字段默认为0,即默认分配连续的虚拟地址空间,但只有主分配区才能分配连续的虚拟地址空间,所以对于非主分配区,需要设置为分配非连续虚拟地址空间。如果初始化的是主分配区,需要设置fast bins中最大chunk大小,由于主分配区只有一个,并且一定是最先初始化,这就保证了对全局变量global_max_fast只初始化了一次,只要该全局变量的值非0,也就意味着主分配区初始化了。最后初始化top chunk

ptmalloc参数初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Set up basic state so that _int_malloc et al can work. */
static void
ptmalloc_init_minimal (void)
{
#if DEFAULT_TOP_PAD != 0
mp_.top_pad = DEFAULT_TOP_PAD;
#endif
mp_.n_mmaps_max = DEFAULT_MMAP_MAX;
mp_.mmap_threshold = DEFAULT_MMAP_THRESHOLD;
mp_.trim_threshold = DEFAULT_TRIM_THRESHOLD;
mp_.pagesize = malloc_getpagesize;
#ifdef PER_THREAD
#define NARENAS_FROM_NCORES(n) ((n) * (sizeof(long) == 4 ? 2 : 8))
mp_.arena_test = NARENAS_FROM_NCORES (1);
narenas = 1;
#endif
}

主要是将全局变量mp_的字段初始化为默认值,值得一提的是,如果定义了编译选项PER_THREAD,会根据系统cpu的个数设置arena_test的值,默认32位系统是双核,64位系统为8核,arena_test也就设置为相应的值。

配置选项

ptmalloc的配置选项不多,在3.2.6节已经做过概要描述,这里给出mallopt()函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#if __STD_C
int mallopt(int param_number, int value)
#else
int mallopt(param_number, value) int param_number; int value;
#endif
{
mstate av = &main_arena;
int res = 1;
if(__malloc_initialized < 0)
ptmalloc_init ();
(void)mutex_lock(&av->mutex);
/* Ensure initialization/consolidation */
malloc_consolidate(av);
switch(param_number) {
case M_MXFAST:
if (value >= 0 && value <= MAX_FAST_SIZE) {
set_max_fast(value);
}
else
res = 0;
break;
case M_TRIM_THRESHOLD:
mp_.trim_threshold = value;
mp_.no_dyn_threshold = 1;
break;
case M_TOP_PAD:
mp_.top_pad = value;
mp_.no_dyn_threshold = 1;
break;
case M_MMAP_THRESHOLD:
#if USE_ARENAS
/* Forbid setting the threshold too high. */
if((unsigned long)value > HEAP_MAX_SIZE/2)
res = 0;
else
#endif
mp_.mmap_threshold = value;
mp_.no_dyn_threshold = 1;
break;
case M_MMAP_MAX:
#if !HAVE_MMAP
if (value != 0)
res = 0;
else
#endif
mp_.n_mmaps_max = value;
mp_.no_dyn_threshold = 1;
break;
case M_CHECK_ACTION:
check_action = value;
break;
case M_PERTURB:
perturb_byte = value;
break;
#ifdef PER_THREAD
case M_ARENA_TEST:
if (value > 0)
mp_.arena_test = value;
break;
case M_ARENA_MAX:
if (value > 0)
mp_.arena_max = value;
break;
#endif
}
(void)mutex_unlock(&av->mutex);
return res;
}

mallopt()函数配置前,需要检查主分配区是否初始化了,如果没有初始化,调用ptmalloc_init()函数初始化ptmalloc,然后获得主分配区的锁,调用malloc_consolidate()函数,malloc_consolidate()函数会判断主分配区是否已经初始化,如果没有,则初始化主分配区。同时我们也看到,mp_都没有锁,对mp_中参数字段的修改,是通过主分配区的锁来同步的。

ptmalloc的初始化

ptmalloc的初始化发生在进程的第一个内存分配请求,当ptmalloc的初始化一般都在用户的第一次调用malloc()remalloc()之前,因为操作系统和Glibc库为进程的初始化做了不少工作,在用户分配内存以前,Glibc已经分配了多次内存。在ptmallocmalloc()函数的实际接口函数为public_malloc(),这个函数最开始会执行如下的一段代码:

1
2
3
__malloc_ptr_t (*hook) (size_t, __const __malloc_ptr_t) = force_reg (__malloc_hook);
if (__builtin_expect (hook != NULL, 0))
return (*hook)(bytes, RETURN_ADDRESS (0));

在定义了__malloc_hook()全局函数的情况下,只是执行__malloc_hook()函数,在进程初始化时__malloc_hook指向的函数为malloc_hook_ini()

1
2
__malloc_ptr_t weak_variable (*__malloc_hook)
(size_t __size, const __malloc_ptr_t) = malloc_hook_ini;

malloc_hook_ini()函数定义在hooks.c中,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void_t*
#if __STD_C
malloc_hook_ini(size_t sz, const __malloc_ptr_t caller)
#else
malloc_hook_ini(sz, caller)
size_t sz;
const __malloc_ptr_t caller;
#endif
{
__malloc_hook = NULL;
ptmalloc_init();
return public_malloc(sz);
}

malloc_hook_ini()函数处理很简单,就是调用ptmalloc的初始化函数ptmalloc_init(),然后再重新调用pbulit_malloc()函数分配内存。ptmalloc_init()函数在初始化ptmalloc完成后,将全局变量__malloc_initialized设置为1,当pbulit_malloc()函数再次执行时,先执行malloc_hook_ini()函数,malloc_hook_ini()函数调用ptmalloc_init()ptmalloc_init()函数首先判断__malloc_initialized是否为1,如果是,则退出ptmalloc_init(),不再执行ptmalloc初始化。

ptmalloc未初始化时分配/释放内存

ptmalloc的初始化函数ptmalloc_init()还没有调用之前,Glibc中可能需要分配内存,比如线程私有实例的初始化需要分配内存,为了解决这一问题,ptmalloc封装了内部的分配释放函数供在这种情况下使用。ptmalloc提供了三个函数,malloc_starter()memalign_starter()free_starter(),但没有提供realloc_starter()函数。这几个函数的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static void_t*
#if __STD_C
malloc_starter(size_t sz, const void_t *caller)
#else
malloc_starter(sz, caller) size_t sz; const void_t *caller;
#endif
{
void_t* victim;
victim = _int_malloc(&main_arena, sz);
return victim ? BOUNDED_N(victim, sz) : 0;
}

static void_t*
#if __STD_C
memalign_starter(size_t align, size_t sz, const void_t *caller)
#else
memalign_starter(align, sz, caller) size_t align, sz; const void_t *caller;
#endif
{
void_t* victim;
victim = _int_memalign(&main_arena, align, sz);
return victim ? BOUNDED_N(victim, sz) : 0;
}
static void
#if __STD_C
free_starter(void_t* mem, const void_t *caller)
#else
free_starter(mem, caller) void_t* mem; const void_t *caller;
#endif
{
mchunkptr p;
if(!mem) return;
p = mem2chunk(mem);
#if HAVE_MMAP
if (chunk_is_mmapped(p)) {
munmap_chunk(p);
return;
}
#endif
#ifdef ATOMIC_FASTBINS
_int_free(&main_arena, p, 1);
#else
_int_free(&main_arena, p);
#endif
}

这个函数的实现都很简单,只是调用ptmalloc的内部实现函数。

ptmalloc_init()函数

ptmalloc_init()函数比较长,将分段对这个函数做介绍。

1
2
3
4
5
6
7
8
9
10
11
static void
ptmalloc_init (void)
{
#if __STD_C
const char* s;
#else
char* s;
#endif
int secure = 0;
if(__malloc_initialized >= 0) return;
__malloc_initialized = 0;

首先检查全局变量__malloc_initialized是否大于等于0,如果该值大于0,表示ptmalloc已经初始化,如果改值为0,表示ptmalloc正在初始化,全局变量__malloc_initialized用来保证全局只初始化ptmalloc一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#ifdef _LIBC
#if defined SHARED && !USE___THREAD
/* ptmalloc_init_minimal may already have been called via
__libc_malloc_pthread_startup, above. */
if (mp_.pagesize == 0)
#endif
#endif
ptmalloc_init_minimal();
#ifndef NO_THREADS
# if defined _LIBC
/* We know __pthread_initialize_minimal has already been called, and that is enough. */
# define NO_STARTER
# endif
# ifndef NO_STARTER
/* With some threads implementations, creating thread-specific data
or initializing a mutex may call malloc() itself. Provide a simple starter version (realloc() wont work). */
save_malloc_hook = __malloc_hook;
save_memalign_hook = __memalign_hook;
save_free_hook = __free_hook;
__malloc_hook = malloc_starter;
__memalign_hook = memalign_starter;
__free_hook = free_starter;

# ifdef _LIBC
/* Initialize the pthreads interface. */
if (__pthread_initialize != NULL)
__pthread_initialize();
# endif /* !defined _LIBC */
# endif /* !defined NO_STARTER */
#endif /* !defined NO_THREADS */

为多线程版本的ptmallocpthread初始化做准备,保存当前的hooks函数,并把ptmalloc为初始化时所有使用的分配/释放函数赋给hooks函数,因为在线程初始化一些私有实例时,ptmalloc还没有初始化,所以需要做特殊处理。从这些hooks函数可以看出,在ptmalloc未初始化时,不能使用remalloc函数。在相关的hooks函数赋值以后,执行pthread_initilaize()初始化pthread

1
2
mutex_init(&main_arena.mutex);
main_arena.next = &main_arena;

初始化主分配区的mutex,并将主分配区的next指针指向自身组成环形链表。

1
2
3
4
5
6
7
8
9
10
#if defined _LIBC && defined SHARED
/* In case this libc copy is in a non-default namespace, never use brk.
Likewise if dlopened from statically linked program. */
Dl_info di;
struct link_map *l;
if (_dl_open_hook != NULL
|| (_dl_addr (ptmalloc_init, &di, &l, NULL) != 0
&& l->l_ns != LM_ID_BASE))
__morecore = __failing_morecore;
#endif

ptmalloc需要保证只有主分配区才能使用sbrk()分配连续虚拟内存空间,如果有多个分配区使用sbrk()就不能获得连续的虚拟地址空间,大多数情况下Glibc库都是以动态链接库的形式加载的,处于默认命名空间,多个进程共用Glibc库,Glibc库代码段在内存中只有一份拷贝,数据段在每个用户进程都有一份拷贝。但如果Glibc库不在默认名字空间,或是用户程序是静态编译的并调用了dlopen函数加载Glibc库中的ptamalloc_init(),这种情况下的ptmalloc不允许使用sbrk()分配内存,只需修改__morecore函数指针指向__failing_morecore就可以禁止使用sbrk()了,__morecore默认指向sbrk()

1
2
3
4
mutex_init(&list_lock);
tsd_key_create(&arena_key, NULL);
tsd_setspecific(arena_key, (void_t *)&main_arena);
thread_atfork(ptmalloc_lock_all, ptmalloc_unlock_all, ptmalloc_unlock_all2);

初始化全局锁list_locklist_lock主要用于同步分配区的单向循环链表。然后创建线程私有实例arena_key,该私有实例保存的是分配区(arena)的malloc_state实例指针。arena_key指向的可能是主分配区的指针,也可能是非主分配区的指针,这里将调用ptmalloc_init()的线程的arena_key绑定到主分配区上。意味着本线程首选从主分配区分配内存。

然后调用thread_atfork()设置当前进程在fork子线程(linux下线程是轻量级进程,使用类似fork进程的机制创建)时处理mutex的回调函数,在本进程fork子线程时,调用ptmalloc_lock_all()获得所有分配区的锁,禁止所有分配区分配内存,当子线程创建完毕,父进程调用ptmalloc_unlock_all()重新unlock每个分配区的锁mutex,子线程调用ptmalloc_unlock_all2()重新初始化每个分配区的锁mutex

1
2
3
4
5
6
7
8
9
#ifndef NO_THREADS
# ifndef NO_STARTER
__malloc_hook = save_malloc_hook;
__memalign_hook = save_memalign_hook;
__free_hook = save_free_hook;
# else
# undef NO_STARTER
# endif
#endif

pthread初始化完成后,将相应的hooks函数还原为原值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#ifdef _LIBC
secure = __libc_enable_secure;
s = NULL;
if (__builtin_expect (_environ != NULL, 1))
{
char **runp = _environ;
char *envline;
while (__builtin_expect ((envline = next_env_entry (&runp)) != NULL,
0))
{
size_t len = strcspn (envline, "=");
if (envline[len] != '=')
/* This is a "MALLOC_" variable at the end of the string58
without a '=' character. Ignore it since otherwise we
will access invalid memory below. */
continue;
switch (len)
{
case 6:
if (memcmp (envline, "CHECK_", 6) == 0)
s = &envline[7];
break;
case 8:
if (! secure)
{
if (memcmp (envline, "TOP_PAD_", 8) == 0)
mALLOPt(M_TOP_PAD, atoi(&envline[9]));
else if (memcmp (envline, "PERTURB_", 8) == 0)
mALLOPt(M_PERTURB, atoi(&envline[9]));
}
break;
case 9:
if (! secure)
{
if (memcmp (envline, "MMAP_MAX_", 9) == 0)
mALLOPt(M_MMAP_MAX, atoi(&envline[10]));
#ifdef PER_THREAD
else if (memcmp (envline, "ARENA_MAX", 9) == 0)
mALLOPt(M_ARENA_MAX, atoi(&envline[10]));
#endif
}
break;
#ifdef PER_THREAD
case 10:
if (! secure)
{
if (memcmp (envline, "ARENA_TEST", 10) == 0)
mALLOPt(M_ARENA_TEST, atoi(&envline[11]));
}
break;
#endif
case 15:
if (! secure)
{
if (memcmp (envline, "TRIM_THRESHOLD_", 15) == 0)
mALLOPt(M_TRIM_THRESHOLD, atoi(&envline[16]));59
else if (memcmp (envline, "MMAP_THRESHOLD_", 15) == 0)
mALLOPt(M_MMAP_THRESHOLD, atoi(&envline[16]));
}
break;
default:
break;
}
}
}
#else
if (! secure)
{
if((s = getenv("MALLOC_TRIM_THRESHOLD_")))
mALLOPt(M_TRIM_THRESHOLD, atoi(s));
if((s = getenv("MALLOC_TOP_PAD_")))
mALLOPt(M_TOP_PAD, atoi(s));
if((s = getenv("MALLOC_PERTURB_")))
mALLOPt(M_PERTURB, atoi(s));
if((s = getenv("MALLOC_MMAP_THRESHOLD_")))
mALLOPt(M_MMAP_THRESHOLD, atoi(s));
if((s = getenv("MALLOC_MMAP_MAX_")))
mALLOPt(M_MMAP_MAX, atoi(s));
}
s = getenv("MALLOC_CHECK_");
#endif
if(s && s[0]) {
mALLOPt(M_CHECK_ACTION, (int)(s[0] - '0'));
if (check_action != 0)
__malloc_check_init();
}

从环境变量中读取相应的配置参数值,这些参数包括MALLOC_TRIM_THRESHOLD_MALLOC_TOP_PAD_MALLOC_PERTURB_MALLOC_MMAP_THRESHOLD_MALLOC_CHECK_MALLOC_MMAP_MAX_MALLOC_ARENA_MAXMALLOC_ARENA_TEST,如果这些选项中的某些项存在,调用mallopt()函数设置相应的选项。如果这段程序是在Glibc库初始化中执行的,会做更多的安全检查工作。

1
2
3
4
void (*hook) (void) = force_reg (__malloc_initialize_hook);
if (hook != NULL)
(*hook)();
__malloc_initialized = 1;

ptmalloc_init()函数结束处,查看是否存在__malloc_initialize_hook函数,如果存在,执行该hook函数。最后将全局变量__malloc_initialized设置为1,表示ptmalloc_init()已经初始化完成。

ptmalloc_lock_all(),ptmalloc_unlock_all(),ptmalloc_unlock_all2()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Magic value for the thread-specific arena pointer when
malloc_atfork() is in use. */
#define ATFORK_ARENA_PTR ((void_t*)-1)
/* The following hooks are used while the `atfork' handling mechanism is active. */

static void_t*
malloc_atfork(size_t sz, const void_t *caller)
{
void_t *vptr = NULL;
void_t *victim;
tsd_getspecific(arena_key, vptr);
if(vptr == ATFORK_ARENA_PTR) {
/* We are the only thread that may allocate at all. */
if(save_malloc_hook != malloc_check) {
return _int_malloc(&main_arena, sz);
} else {
if(top_check()<0)
return 0;
victim = _int_malloc(&main_arena, sz+1);
return mem2mem_check(victim, sz);
}
} else {
/* Suspend the thread until the `atfork' handlers have completed.
By that time, the hooks will have been reset as well, so that
malloc() can be used again. */
(void)mutex_lock(&list_lock);
(void)mutex_unlock(&list_lock);
return public_malloc(sz);
}
}

当父进程中的某个线程使用fork的机制创建子线程时,如果进程中的线程需要分配内存,将使用malloc_atfork()函数分配内存。malloc_atfork()函数首先查看自己的线程私有实例中的分配区指针,如果该指针为ATFORK_ARENA_PTR,意味着本线程正在fork新线程,并锁住了全局锁list_lock和每个分配区,当前只有本线程可以分配内存,如果在fork线程前的分配函数不是处于check模式,直接调用内部分配函数_int_malloc()。否则在分配内存的同时做检查。如果线程私有实例中的指针不是ATFORK_ARENA_PTR,意味着当前线程只是常规线程,有另外的线程在fork子线程,当前线程只能等待fork子线程的线程完成分配,于是等待获得全局锁list_lock,如果获得全局锁成功,表示fork子线程的线程已经完成fork操作,当前线程可以分配内存了,于是是释放全局所list_lock,并调用public_malloc()分配内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void
free_atfork(void_t* mem, const void_t *caller)
{
void_t *vptr = NULL;
mstate ar_ptr;
mchunkptr p; /* chunk corresponding to mem */
if (mem == 0) /* free(0) has no effect */
return;
p = mem2chunk(mem); /* do not bother to replicate free_check here */
#if HAVE_MMAP
if (chunk_is_mmapped(p)) /* release mmapped memory. */
{
munmap_chunk(p);
return;
}
#endif
#ifdef ATOMIC_FASTBINS
ar_ptr = arena_for_chunk(p);
tsd_getspecific(arena_key, vptr);
_int_free(ar_ptr, p, vptr == ATFORK_ARENA_PTR);
#else
ar_ptr = arena_for_chunk(p);
tsd_getspecific(arena_key, vptr);
if(vptr != ATFORK_ARENA_PTR)
(void)mutex_lock(&ar_ptr->mutex);
_int_free(ar_ptr, p);
if(vptr != ATFORK_ARENA_PTR)
(void)mutex_unlock(&ar_ptr->mutex);
#endif
}

当父进程中的某个线程使用fork的机制创建子线程时,如果进程中的线程需要释放内存,将使用free_atfork()函数释放内存。 free_atfork()函数首先通过需free的内存块指针获得chunk的指针,如果该chunk是通过mmap分配的,调用munmap()释放该chunk,否则调用_int_free()函数释放内存。在调用_int_free()函数前,先根据chunk指针获得分配区指针,并读取本线程私用实例的指针,如果开启了ATOMIC_FASTBINS优化,这个优化使用了lock-free的技术优化fastbins中单向链表操作。如果没有开启了ATOMIC_FASTBINS优化,并且当前线程没有正在fork新子线程,则对分配区加锁,然后调用_int_free()函数,然后对分配区解锁。而对于正在fork子线程的线程来说,是不需要对分配区加锁的,因为该线程已经对所有的分配区加锁了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/* Counter for number of times the list is locked by the same thread. */
static unsigned int atfork_recursive_cntr;
/* The following two functions are registered via thread_atfork() to
make sure that the mutexes remain in a consistent state in the
fork()ed version of a thread. Also adapt the malloc and free hooks
temporarily, because the `atfork' handler mechanism may use
malloc/free internally (e.g. in LinuxThreads). */
static void
ptmalloc_lock_all (void)
{
mstate ar_ptr;
if(__malloc_initialized < 1)
return;
if (mutex_trylock(&list_lock))
{
void_t *my_arena;
tsd_getspecific(arena_key, my_arena);
if (my_arena == ATFORK_ARENA_PTR)
/* This is the same thread which already locks the global list.
Just bump the counter. */
goto out;
/* This thread has to wait its turn. */
(void)mutex_lock(&list_lock);
}
for(ar_ptr = &main_arena;;) {
(void)mutex_lock(&ar_ptr->mutex);
ar_ptr = ar_ptr->next;
if(ar_ptr == &main_arena) break;
}
save_malloc_hook = __malloc_hook;
save_free_hook = __free_hook;
__malloc_hook = malloc_atfork;
__free_hook = free_atfork;
/* Only the current thread may perform malloc/free calls now. */
tsd_getspecific(arena_key, save_arena);
tsd_setspecific(arena_key, ATFORK_ARENA_PTR);
out:
++atfork_recursive_cntr;
}

当父进程中的某个线程使用fork的机制创建子线程时,首先调用ptmalloc_lock_all()函数暂时对全局锁list_lock和所有的分配区加锁,从而保证分配区状态的一致性。ptmalloc_lock_all()函数首先检查ptmalloc是否已经初始化,如果没有初始化,退出,如果已经初始化,尝试对全局锁list_lock加锁,直到获得全局锁list_lock,接着对所有的分配区加锁,接着保存原有的分配释放函数,将malloc_atfork()free_atfork()函数作为fork子线程期间所使用的内存分配释放函数,然后保存当前线程的私有实例中的原有分配区指针,将ATFORK_ARENA_PTR存放到当前线程的私有实例中,用于标识当前现在正在fork子线程。为了保证父线程fork多个子线程工作正常,也就是说当前线程需要fork多个子线程,当一个子线程已经创建,当前线程继续创建其它子线程时,发现当前线程已经对list_lock和所有分配区加锁,于是对全局变量atfork_recursive_cntr加1,表示递归fork子线程的层数,保证父线程在fork子线程过程中,调用ptmalloc_unlock_all()函数加锁的次数与调用ptmalloc_lock_all()函数解锁的次数保持一致,同时也保证保证所有的子线程调用ptmalloc_unlock_all()函数加锁的次数与父线程调用ptmalloc_lock_all()函数解锁的次数保持一致,防止没有释放锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void
ptmalloc_unlock_all (void)
{
mstate ar_ptr;
if(__malloc_initialized < 1)
return;
if (--atfork_recursive_cntr != 0)
return;
tsd_setspecific(arena_key, save_arena);
__malloc_hook = save_malloc_hook;
__free_hook = save_free_hook;
for(ar_ptr = &main_arena;;) {
(void)mutex_unlock(&ar_ptr->mutex);
ar_ptr = ar_ptr->next;
if(ar_ptr == &main_arena) break;
}
(void)mutex_unlock(&list_lock);
}

当进程的某个线程完成fork子线程后,父线程和子线程都调用ptmall_unlock_all()函数释放全局锁list_lock,释放所有分配区的锁。ptmall_unlock_all()函数首先检查ptmalloc是否初始化,只有初始化后才能调用该函数,接着将全局变量atfork_recursive_cntr减1,如果atfork_recursive_cntr为0,才继续执行,这保证了递归fork子线程只会解锁一次。接着将当前线程的私有实例还原为原来的分配区,__malloc_hook__free_hook还原为由来的hook函数。然后遍历所有分配区,依次解锁每个分配区,最后解锁list_lock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#ifdef __linux__

/* In NPTL, unlocking a mutex in the child process after a
fork() is currently unsafe, whereas re-initializing it is safe and
does not leak resources. Therefore, a special atfork handler is
installed for the child. */

static void
ptmalloc_unlock_all2 (void)
{
mstate ar_ptr;
if(__malloc_initialized < 1)
return;
#if defined _LIBC || defined MALLOC_HOOKS
tsd_setspecific(arena_key, save_arena);
__malloc_hook = save_malloc_hook;
__free_hook = save_free_hook;
#endif
#ifdef PER_THREAD
free_list = NULL;
#endif
for(ar_ptr = &main_arena;;) {
mutex_init(&ar_ptr->mutex);
#ifdef PER_THREAD
if (ar_ptr != save_arena) {
ar_ptr->next_free = free_list;
free_list = ar_ptr;
}
#endif
ar_ptr = ar_ptr->next;
if(ar_ptr == &main_arena) break;
}
mutex_init(&list_lock);
atfork_recursive_cntr = 0;
}
#else
#define ptmalloc_unlock_all2 ptmalloc_unlock_all
#endif

函数ptmalloc_unlock_all2()fork出的子线程调用,在Linux系统中,子线程(进程)unlock从父线程(进程)中继承的mutex不安全,会导致资源泄漏,但重新初始化mutex是安全的,所有增加了这个特殊版本用于Linux下的atfork handlerptmalloc_unlock_all2()函数的处理流程跟ptmalloc_unlock_all()函数差不多,使用mutex_init()代替了mutex_unlock(),如果开启了PER_THREAD的优化,将从父线程中继承来的分配区加入到free_list中,对于子线程来说,无论全局变量atfork_recursive_cntr的值是多少,都将该值设置为0,因为ptmalloc_unlock_all2()函数只会被子线程调用一次。

多分配区支持

由于只有一个主分配区从堆中分配小内存块,而稍大的内存块都必须从mmap映射区域分配,如果有多个线程都要分配小内存块,但多个线程是不能同时调用sbrk()函数的,因为只有一个函数调用sbrk()时才能保证分配的虚拟地址空间是连续的。如果多个线程都从主分配区中分配小内存块,效率很低效。为了解决这个问题,ptmalloc使用非主分配区来模拟主分配区的功能,非主分配区同样可以分配小内存块,并且可以创建多个非主分配区,从而在线程分配内存竞争比较激烈的情况下,可以创建更多的非主分配区来完成分配任务,减少分配区的锁竞争,提高分配效率。

ptmalloc怎么用非主分配区来模拟主分配区的行为呢?首先创建一个新的非主分配区,非主分配区使用mmap()函数分配一大块内存来模拟堆(sub-heap),所有的从该非主分配区总分配的小内存块都从sub-heap中切分出来,如果一个sub-heap的内存用光了,或是sub-heap中的内存不够用时,使用mmap()分配一块新的内存块作为sub-heap,并将新的sub-heap链接在非主分配区中sub-heap的单向链表中。

分主分配区中的sub-heap所占用的内存不会无限的增长下去,同样会像主分配区那样进行进行sub-heap收缩,将sub-heaptop chunk的一部分返回给操作系统,如果top chunk为整个sub-heap,会把整个sub-heap还回给操作系统。收缩堆的条件是当前freechunk大小加上前后能合并chunk的大小大于64KB,并且top chunk的大小达到mmap收缩阈值,才有可能收缩堆。

一般情况下,进程中有多个线程,也有多个分配区,线程的数据一般会比分配区数量多,所以必能保证没有线程独享一个分配区,每个分配区都有可能被多个线程使用,为了保证分配区的线程安全,对分配区的访问需要锁保护,当线程获得分配区的锁时,可以使用该分配区分配内存,并将该分配区的指针保存在线程的私有实例中。

当某一线程需要调用malloc分配内存空间时,该线程先查看线程私有变量中是否已经存在一个分配区,如果存在,尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败,该线程搜分配区索循环链表试图获得一个空闲的分配区。如果所有的分配区都已经加锁,那么malloc会开辟一个新的分配区,把该分配区加入到分配区的全局分配区循环链表并加锁,然后使用该分配区进行分配操作。在回收操作中,线程同样试图获得待回收块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行回收操作。

heap_info

struct heap_info定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* A heap is a single contiguous memory region holding (coalesceable)
malloc_chunks. It is allocated with mmap() and always starts at an
address aligned to HEAP_MAX_SIZE. Not used unless compiling with
USE_ARENAS. */
typedef struct _heap_info {
mstate ar_ptr; /* Arena for this heap. */
struct _heap_info *prev; /* Previous heap. */
size_t size; /* Current size in bytes. */66
size_t mprotect_size; /* Size in bytes that has been mprotected PROT_READ|PROT_WRITE. */
/* Make sure the following data is properly aligned, particularly
that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
MALLOC_ALIGNMENT. */
char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;

ar_ptr是指向所属分配区的指针,mstate的定义为: typedef struct malloc_state *mstate;

prev字段用于将同一个分配区中的sub_heap用单向链表链接起来。prev指向链表中的前一个sub_heapsize字段表示当前sub_heap中的内存大小,以page对齐。mprotect_size字段表示当前sub_heap中被读写保护的内存大小,也就是说还没有被分配的内存大小。

pad字段用于保证sizeof (heap_info) + 2 * SIZE_SZ是按MALLOC_ALIGNMENT对齐的。MALLOC_ALIGNMENT_MASK2 * SIZE_SZ - 1,无论SIZE_SZ为4或8,-6 * SIZE_SZ & MALLOC_ALIGN_MASK的值为0 ,如果sizeof (heap_info) + 2 * SIZE_SZ不是按MALLOC_ALIGNMENT对齐,编译的时候就会报错,编译时会执行下面的宏。

1
2
3
4
/* Get a compile-time error if the heap_info padding is not correct
to make alignment work as expected in sYSMALLOc. */
extern int sanity_check_heap_info_alignment[(sizeof (heap_info)
+ 2 * SIZE_SZ) % MALLOC_ALIGNMENT ? -1 : 1];

为什么一定要保证对齐呢?作为分主分配区的第一个sub_heapheap_info存放在sub_heap的头部,紧跟heap_info之后是该非主分配区的malloc_state实例,紧跟malloc_state实例后,是sub_heap中的第一个chunk,但chunk的首地址必须按照MALLOC_ALIGNMENT对齐,所以在malloc_state实例和第一个chunk之间可能有几个字节的pad,但如果sub_heap不是非主分配区的第一个sub_heap,则紧跟heap_info后是第一个chunk,但sysmalloc()函数默认heap_info是按照MALLOC_ALIGNMENT对齐的,没有再做对齐的工作,直接将heap_info后的内存强制转换成一个chunk。所以这里在编译时保证sizeof (heap_info) + 2 * SIZE_SZ是按MALLOC_ALIGNMENT对齐的,在运行时就不用再做检查了,也不必再做对齐。

获取分配区

为了支持多线程,ptmalloc定义了如下的全局变量:

1
2
3
4
5
6
7
8
9
10
static tsd_key_t arena_key;
static mutex_t list_lock;
#ifdef PER_THREAD
static size_t narenas;
static mstate free_list;
#endif
/* Mapped memory in non-main arenas (reliable only for NO_THREADS). */
static unsigned long arena_mem;67
/* Already initialized? */
int __malloc_initialized = -1;

arena_key存放的是线程的私用实例,该私有实例保存的是分配区(arena)的malloc_state实例的指针。arena_key指向的可能是主分配区的指针,也可能是非主分配区的指针。list_lock用于同步分配区的单向环形链表。

如果定义了PRE_THREADnarenas全局变量表示当前分配区的数量,free_list全局变量是空闲分配区的单向链表,这些空闲的分配区可能是从父进程那里继承来的。全局变量narenasfree_list都用锁list_lock同步。

arena_mem只用于单线程的ptmalloc版本,记录了非主分配区所分配的内存大小。

__malloc_initializd全局变量用来标识是否ptmalloc已经初始化了,其值大于0时表示已经初始化。

ptmalloc使用如下的宏来获得分配区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/* arena_get() acquires an arena and locks the corresponding mutex.
First, try the one last locked successfully by this thread. (This
is the common case and handled with a macro for speed.) Then, loop
once over the circularly linked list of arenas. If no arena is
readily available, create a new one. In this latter case, `size'
is just a hint as to how much memory will be required immediately
in the new arena. */
#define arena_get(ptr, size) do { \
arena_lookup(ptr); \
arena_lock(ptr, size); \
} while(0)

#define arena_lookup(ptr) do { \
void_t *vptr = NULL; \
ptr = (mstate)tsd_getspecific(arena_key, vptr); \
} while(0)

#ifdef PER_THREAD
#define arena_lock(ptr, size) do { \
if(ptr) \
(void)mutex_lock(&ptr->mutex); \
else \
ptr = arena_get2(ptr, (size)); \
} while(0)
#else
#define arena_lock(ptr, size) do { \
if(ptr && !mutex_trylock(&ptr->mutex)) { \
THREAD_STAT(++(ptr->stat_lock_direct)); \
} else \
ptr = arena_get2(ptr, (size)); \
} while(0)
#endif

/* find the heap and corresponding arena for a given ptr */
#define heap_for_ptr(ptr) \
((heap_info *)((unsigned long)(ptr) & ~(HEAP_MAX_SIZE-1)))

#define arena_for_chunk(ptr) \
(chunk_non_main_arena(ptr) ? heap_for_ptr(ptr)->ar_ptr : &main_arena)

arena_get首先调用arena_lookup查找本线程的私用实例中是否包含一个分配区的指针,返回该指针,调用arena_lock尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果对该分配区加锁失败,调用arena_get2获得一个分配区指针。如果定义了PRE_THREADarena_lock的处理有些不同,如果本线程拥有的私用实例中包含分配区的指针,则直接对该分配区加锁,否则,调用arena_get2获得分配区指针,PRE_THREAD的优化保证了每个线程尽量从自己所属的分配区中分配内存,减少与其它线程因共享分配区带来的锁开销,但PRE_THREAD的优化并不能保证每个线程都有一个不同的分配区,当系统中的分配区数量达到配置的最大值时,不能再增加新的分配区,如果再增加新的线程,就会有多个线程共享同一个分配区。所以ptmallocPRE_THREAD优化,对线程少时可能会提升一些性能,但线程多时,提升性能并不明显。即使没有线程共享分配区的情况下,任然需要加锁,这是不必要的开销,每次加锁操作会消耗100ns左右的时间。

每个sub_heap的内存块使用mmap()函数分配,并以HEAP_MAX_SIZE对齐,所以可以根据chunk的指针地址,获得这个chunk所属的sub_heap的地址。heap_for_ptr根据chunk的地址获得sub_heap的地址。由于sub_heap的头部存放的是heap_info的实例,heap_info中保存了分配区的指针,所以可以通过chunk的地址获得分配区的地址,前提是这个chunk属于非主分配区,arena_for_chunk用来做这样的转换。

1
2
3
4
5
6
7
8
#define HEAP_MIN_SIZE (32*1024)
#ifndef HEAP_MAX_SIZE
#ifdef DEFAULT_MMAP_THRESHOLD_MAX
#define HEAP_MAX_SIZE (2 * DEFAULT_MMAP_THRESHOLD_MAX)
#else
#define HEAP_MAX_SIZE (1024*1024) /* must be a power of two */
#endif
#endif

HEAP_MIN_SIZE定义了sub_heap内存块的最小值,32KB。HEAP_MAX_SIZE定义了sub_heap内存块的最大值,在32位系统上,HEAP_MAX_SIZE默认值为1MB,64为系统上,HEAP_MAX_SIZE`的默认值为64MB。

arena_get2()

arena_get宏尝试查看线程的私用实例中是否包含一个分配区,如果不存在分配区或是存在分配区,但对该分配区加锁失败,就会调用arena_get2()函数获得一个分配区,下面将分析arena_get2()函数的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static mstate
internal_function
#if __STD_C
arena_get2(mstate a_tsd, size_t size)
#else
arena_get2(a_tsd, size) mstate a_tsd; size_t size;
#endif
{
mstate a;
#ifdef PER_THREAD
if ((a = get_free_list ()) == NULL
&& (a = reused_arena ()) == NULL)
/* Nothing immediately available, so generate a new arena. */
a = _int_new_arena(size);

如果开启了PER_THREAD优化,首先尝试从分配区的free list中获得一个分配区,分配区的free list是从父线程(进程)中继承而来,如果free list中没有分配区,尝试重用已有的分配区,只有当分配区的数达到限制时才重用分配区,如果仍未获得可重用的分配区,创建一个新的分配区。

1
2
3
4
5
6
7
8
9
10
11
12
#else
if(!a_tsd)
a = a_tsd = &main_arena;
else {
a = a_tsd->next;
if(!a) {
/* This can only happen while initializing the new arena. */
(void)mutex_lock(&main_arena.mutex);
THREAD_STAT(++(main_arena.stat_lock_wait));
return &main_arena;
}
}

如果线程的私有实例中没有分配区,将主分配区作为候选分配区,如果线程私有实例中存在分配区,但不能获得该分配区的锁,将该分配区的下一个分配区作为候选分配区,如果候选分配区为空,意味着当前线程私用实例中的分配区正在初始化,还没有加入到全局的分配区链表中,这种情况下,只有主分配区可选了,等待获得主分配区的锁,如果获得住分配区的锁成功,返回主分配区。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Check the global, circularly linked list for available arenas. */
bool retried = false;
repeat:
do {
if(!mutex_trylock(&a->mutex)) {
if (retried)
(void)mutex_unlock(&list_lock);
THREAD_STAT(++(a->stat_lock_loop));
tsd_setspecific(arena_key, (void_t *)a);
return a;
}
a = a->next;
} while(a != a_tsd);

遍历全局分配区链表,尝试对当前遍历中的分配区加锁,如果对分配区加锁成功,将该分配区加入线程私有实例中并返回该分配区。如果retriedtrue,意味着这是第二次遍历全局分配区链表,并且获得了全局锁list_lock,当对分配区加锁成功时,需要释放全局锁list_lock

1
2
3
4
5
6
7
8
9
10
11
12
/* If not even the list_lock can be obtained, try again. This can
happen during `atfork', or for example on systems where thread
creation makes it temporarily impossible to obtain _any_
locks. */
if(!retried && mutex_trylock(&list_lock)) {
/* We will block to not run in a busy loop. */
(void)mutex_lock(&list_lock);
/* Since we blocked there might be an arena available now. */
retried = true;
a = a_tsd;
goto repeat;
}

由于在atfork时,父线程(进程)会对所有的分配区加锁,并对全局锁list_lock加锁,在有线程在创建子线程的情况下,当前线程是不能获得分配区的,所以在没有重试的情况下,先尝试获得全局锁list_lock,如果不能获得全局锁list_lock,阻塞在全局锁list_lock上,直到获得全局锁list_lock,也就是说当前已没有线程在创建子线程,然后再重新遍历全局分配区链表,尝试对分配区加锁,如果经过第二次尝试仍然未能获得一个分配区,只能创建一个新的非主分配区了。

1
2
3
/* Nothing immediately available, so generate a new arena. */
a = _int_new_arena(size);
(void)mutex_unlock(&list_lock);

通过前面的所有尝试都未能获得一个可用的分配区,只能创建一个新的非主分配区,执行到这里,可以确保获得了全局锁list_lock,在创建完新的分配区,并将分配区加入了全局分配区链表中以后,需要对全局锁list_lock解锁。

1
2
3
#endif
return a;
}

_int_new_arena()

_int_new_arena()函数用于创建一个非主分配区,在arena_get2()函数中被调用,该函数的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static mstate
_int_new_arena(size_t size)
{
mstate a;
heap_info *h;
char *ptr;
unsigned long misalign;
h = new_heap(size + (sizeof(*h) + sizeof(*a) + MALLOC_ALIGNMENT),
mp_.top_pad);
if(!h) {
/* Maybe size is too large to fit in a single heap. So, just try
to create a minimally-sized arena and let _int_malloc() attempt
to deal with the large request via mmap_chunk(). */
h = new_heap(sizeof(*h) + sizeof(*a) + MALLOC_ALIGNMENT, mp_.top_pad);
if(!h)
return 0;
}

对于一个新的非主分配区,至少包含一个sub_heap,每个非主分配区中都有相应的管理数据结构,每个非主分配区都有一个heap_info实例和malloc_state的实例,这两个实例都位于非主分配区的第一个sub_heap的开始部分,malloc_state实例紧接着heap_info实例。所以在创建非主分配区时,需要为管理数据结构分配额外的内存空间。 new_heap()函数创建一个新的sub_heap,并返回sub_heap的指针。

1
2
3
4
5
a = h->ar_ptr = (mstate)(h+1);
malloc_init_state(a);
/*a->next = NULL;*/
a->system_mem = a->max_system_mem = h->size;
arena_mem += h->size;

heap_info实例后紧接着malloc_state实例,初始化malloc_state实例,更新该分配区所分配的内存大小的统计值。

1
2
3
4
5
6
7
8
9
10
11
#ifdef NO_THREADS
if((unsigned long)(mp_.mmapped_mem + arena_mem + main_arena.system_mem) > mp_.max_total_mem)
mp_.max_total_mem = mp_.mmapped_mem + arena_mem + main_arena.system_mem;
#endif
/* Set up the top chunk, with proper alignment. */
ptr = (char *)(a + 1);
misalign = (unsigned long)chunk2mem(ptr) & MALLOC_ALIGN_MASK;
if (misalign > 0)
ptr += MALLOC_ALIGNMENT - misalign;
top(a) = (mchunkptr)ptr;
set_head(top(a), (((char*)h + h->size) - ptr) | PREV_INUSE);

sub_heapmalloc_state实例后的内存可以分配给用户使用,ptr指向存储malloc_state实例后的空闲内存,对ptr按照2*SZ_SIZE对齐后,将ptr赋值给分配区的top chunk,也就是说把sub_heap中整个空闲内存块作为top chunk,然后设置top chunksize,并标识top chunk的前一个chunk为已处于分配状态。

1
2
3
tsd_setspecific(arena_key, (void_t *)a);
mutex_init(&a->mutex);
(void)mutex_lock(&a->mutex);

将创建好的非主分配区加入线程的私有实例中,然后对非主分配区的锁进行初始化,并获得该锁。

1
2
3
4
5
6
7
8
9
10
11
#ifdef PER_THREAD
(void)mutex_lock(&list_lock);
#endif
/* Add the new arena to the global list. */
a->next = main_arena.next;
atomic_write_barrier ();
main_arena.next = a;
#ifdef PER_THREAD
++narenas;
(void)mutex_unlock(&list_lock);
#endif

将刚创建的非主分配区加入到分配区的全局链表中,如果开启了PER_THREAD优化,在arena_get2()函数中没有对全局锁list_lock加锁,这里修改全局分配区链表时需要获得全局锁list_lock。如果没有开启PER_THREAD优化,arene_get2()函数调用_int_new_arena()函数时已经获得了全局锁list_lock,所以对全局分配区链表的修改不用再加锁。

1
2
3
    THREAD_STAT(++(a->stat_lock_loop));
return a;
}

new_heap()

new_heap()函数负责从mmap区域映射一块内存来作为sub_heap,在32位系统上,该函数每次映射1M内存,映射的内存块地址按1M对齐;在64位系统上,该函数映射64M内存,映射的内存块地址按64M对齐。new_heap()函数只是映射一块虚拟地址空间,该空间不可读写,不会被swapnew_heap()函数的实现源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* If consecutive mmap (0, HEAP_MAX_SIZE << 1, ...) calls return decreasing
addresses as opposed to increasing, new_heap would badly fragment the
address space. In that case remember the second HEAP_MAX_SIZE part73
aligned to HEAP_MAX_SIZE from last mmap (0, HEAP_MAX_SIZE << 1, ...)
call (if it is already aligned) and try to reuse it next time. We need
no locking for it, as kernel ensures the atomicity for us - worst case
we'll call mmap (addr, HEAP_MAX_SIZE, ...) for some value of addr in
multiple threads, but only one will succeed. */
static char *aligned_heap_area;
/* Create a new heap. size is automatically rounded up to a multiple
of the page size. */
static heap_info *
internal_function
#if __STD_C
new_heap(size_t size, size_t top_pad)
#else
new_heap(size, top_pad) size_t size, top_pad;
#endif
{
size_t page_mask = malloc_getpagesize - 1;
char *p1, *p2;
unsigned long ul;
heap_info *h;
if(size+top_pad < HEAP_MIN_SIZE)
size = HEAP_MIN_SIZE;
else if(size+top_pad <= HEAP_MAX_SIZE)
size += top_pad;
else if(size > HEAP_MAX_SIZE)
return 0;
else
size = HEAP_MAX_SIZE;
size = (size + page_mask) & ~page_mask;

调整size的大小,size的最小值为32K,最大值HEAP_MAX_SIZE在不同的系统上不同,在32位系统为1M,64位系统为64M,将size的大小调整到最小值与最大值之间,并以页对齐,如果size大于最大值,直接报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* A memory region aligned to a multiple of HEAP_MAX_SIZE is needed.
No swap space needs to be reserved for the following large
mapping (on Linux, this is the case for all non-writable mappings
anyway). */
p2 = MAP_FAILED;
if(aligned_heap_area) {
p2 = (char *)MMAP(aligned_heap_area, HEAP_MAX_SIZE, PROT_NONE,
MAP_PRIVATE|MAP_NORESERVE);
aligned_heap_area = NULL;
if (p2 != MAP_FAILED && ((unsigned long)p2 & (HEAP_MAX_SIZE-1))) {
munmap(p2, HEAP_MAX_SIZE);
p2 = MAP_FAILED;
}
}

全局变量aligned_heap_area是上一次调用mmap分配内存的结束虚拟地址,并已经按照HEAP_MAX_SIZE大小对齐。如果aligned_heap_area不为空,尝试从上次映射结束地址开始映射大小为HEAP_MAX_SIZE的内存块,由于全局变量aligned_heap_area没有锁保护,可能存在多个线程同时mmap()函数从aligned_heap_area开始映射新的虚拟内存块,操作系统会保证只会有一个线程会成功,其它在同一地址映射新虚拟内存块都会失败。无论映射是否成功,都将全局变量aligned_heap_area设置为NULL。如果映射成功,但返回的虚拟地址不是按HEAP_MAX_SIZE大小对齐的,取消该区域的映射,映射失败。

1
2
3
if(p2 == MAP_FAILED) {
p1 = (char *)MMAP(0, HEAP_MAX_SIZE<<1, PROT_NONE,
MAP_PRIVATE|MAP_NORESERVE);

全局变量aligned_heap_areaNULL,或者从aligned_heap_area开始映射失败了,尝试映射2倍HEAP_MAX_SIZE大小的虚拟内存,便于地址对齐,因为在最坏可能情况下,需要映射2倍HEAP_MAX_SIZE大小的虚拟内存才能实现地址按照HEAP_MAX_SIZE大小对齐。

1
2
3
4
5
6
7
8
9
if(p1 != MAP_FAILED) {
p2 = (char *)(((unsigned long)p1 + (HEAP_MAX_SIZE-1)) & ~(HEAP_MAX_SIZE-1));
ul = p2 - p1;
if (ul)
munmap(p1, ul);
else
aligned_heap_area = p2 + HEAP_MAX_SIZE;
munmap(p2 + HEAP_MAX_SIZE, HEAP_MAX_SIZE - ul);

映射2倍HEAP_MAX_SIZE大小的虚拟内存成功,将大于等于p1并按HEAP_MAX_SIZE大小对齐的第一个虚拟地址赋值给p2p2作为sub_heap的起始虚拟地址,p2+HEAP_MAX_SIZE作为sub_heap的结束地址,并将sub_heap的结束地址赋值给全局变量aligned_heap_area,最后还需要将多余的虚拟内存还回给操作系统。

1
2
3
4
5
6
7
8
9
10
} else {
/* Try to take the chance that an allocation of only HEAP_MAX_SIZE
is already aligned. */
p2 = (char *)MMAP(0, HEAP_MAX_SIZE, PROT_NONE, MAP_PRIVATE|MAP_NORESERVE);
if(p2 == MAP_FAILED)
return 0;
if((unsigned long)p2 & (HEAP_MAX_SIZE-1)) {
munmap(p2, HEAP_MAX_SIZE);
return 0;
}

映射2倍HEAP_MAX_SIZE大小的虚拟内存失败了,再尝试映射HEAP_MAX_SIZE大小的虚拟内存,如果失败,返回;如果成功,但该虚拟地址不是按照HEAP_MAX_SIZE大小对齐的,返回。

1
2
3
4
5
6
7
8
9
10
11
    }
}

if(mprotect(p2, size, PROT_READ|PROT_WRITE) != 0) {
munmap(p2, HEAP_MAX_SIZE);
return 0;
}
h = (heap_info *)p2;
h->size = size;
h->mprotect_size = size;
THREAD_STAT(stat_n_heaps++);

调用mprotect()函数将size大小的内存设置为可读可写,如果失败,解除整个sub_heap的映射。然后更新heap_info实例中的相关字段。

1
2
    return h;
}

get_free_list()和reused_arena()

这两个函数在开启了PER_THRAD优化时用于获取分配区(arena),arena_get2首先调用get_free_list()尝试获得arena,如果失败在尝试调用reused_arena()获得arena,如果仍然没有获得分配区,调用_int_new_arena()创建一个新的分配区。get_free_list()函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static mstate
get_free_list (void)
{
mstate result = free_list;
if (result != NULL)
{
(void)mutex_lock(&list_lock);
result = free_list;
if (result != NULL)
free_list = result->next_free;
(void)mutex_unlock(&list_lock);
if (result != NULL)
{
(void)mutex_lock(&result->mutex);
tsd_setspecific(arena_key, (void_t *)result);
THREAD_STAT(++(result->stat_lock_loop));
}
}
return result;
}

这个函数实现很简单,首先查看arenafree_list中是否为NULL,如果不为NULL,获得全局锁list_lock,将free_list的第一个arena从单向链表中取出,解锁list_lock。如果从free_list中获得一个arena,对该arena加锁,并将该arena加入线程的私有实例中。

reused_arena()函数的源代码实现如下:

1
2
3
4
5
static mstate
reused_arena (void)
{
if (narenas <= mp_.arena_test)
return NULL;

首先判断全局分配区的总数是否小于分配区的个数的限定值(arena_test) ,在32位系统上arena_test默认值为2,64位系统上的默认值为8,如果当前进程的分配区数量没有达到限定值,直接返回NULL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int narenas_limit;
if (narenas_limit == 0)
{
if (mp_.arena_max != 0)
narenas_limit = mp_.arena_max;
else
{
int n = __get_nprocs ();
if (n >= 1)
narenas_limit = NARENAS_FROM_NCORES (n);
else
/* We have no information about the system. Assume two cores. */
narenas_limit = NARENAS_FROM_NCORES (2);
}
}
if (narenas < narenas_limit)
return NULL;

设定全局变量narenas_limit,如果应用层设置了进程的最大分配区个数(arena_max),将arena_max赋值给narenas_limit,否则根据系统的cpu个数和系统的字大小设定narenas_limit的大小,narenas_limit的大小默认与arena_test大小相同。然后再次判断进程的当前分配区个数是否达到了分配区的限制个数,如果没有达到限定值,返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    mstate result;
static mstate next_to_use;
if (next_to_use == NULL)
next_to_use = &main_arena;
result = next_to_use;
do
{
if (!mutex_trylock(&result->mutex))
goto out;
result = result->next;
} while (result != next_to_use);
/* No arena available. Wait for the next in line. */
(void)mutex_lock(&result->mutex);
out:
tsd_setspecific(arena_key, (void_t *)result);
THREAD_STAT(++(result->stat_lock_loop));
next_to_use = result->next;

全局变量next_to_use指向下一个可能可用的分配区,该全局变量没有锁保护,主要用于记录上次遍历分配区循环链表到达的位置,避免每次都从同一个分配区开始遍历,导致从某个分配区分配的内存过多。首先判断next_to_use是否为NULL,如果是,将主分配区赋值给next_to_use。然后从next_to_use开始遍历分配区链表,尝试对遍历的分配区加锁,如果加锁成功,退出循环,如果遍历分配区循环链表中的所有分配区,尝试加锁都失败了,等待获得next_to_use指向的分配区的锁。执行到out的代码,意味着已经获得一个分配区的锁,将该分配区加入线程私有实例,并将当前分配区的下一个分配区赋值给next_to_use

1
2
    return result;
}

grow_heap(),shrink_heap(),delete_heap(),heap_trim()

这几个函数实现sub_heap和增长和收缩,grow_heap()函数主要将sub_heap中可读可写区域扩大;shrink_heap()函数缩小sub_heap的虚拟内存区域,减小该sub_heap的虚拟内存占用量;delete_heap()为一个宏,如果sub_heap中所有的内存都空闲,使用该宏函数将sub_heap的虚拟内存还回给操作系统;heap_trim()函数根据sub_heaptop chunk大小调用shrink_heap()函数收缩sub_heap

grow_heap()函数的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static int
#if __STD_C
grow_heap(heap_info *h, long diff)
#else
grow_heap(h, diff) heap_info *h; long diff;
#endif
{
size_t page_mask = malloc_getpagesize - 1;
long new_size;
diff = (diff + page_mask) & ~page_mask;
new_size = (long)h->size + diff;
if((unsigned long) new_size > (unsigned long) HEAP_MAX_SIZE)
return -1;
if((unsigned long) new_size > h->mprotect_size) {
if (mprotect((char *)h + h->mprotect_size,
(unsigned long) new_size - h->mprotect_size,
PROT_READ|PROT_WRITE) != 0)
return -2;
h->mprotect_size = new_size;
}
->size = new_size;
return 0;
}

grow_heap()函数的实现比较简单,首先将要增加的可读可写的内存大小按照页对齐,然后计算sub_heap总的可读可写的内存大小new_size,判断new_size是否大于HEAP_MAX_SIZE,如果是,返回,否则判断new_size是否大于当前sub_heap的可读可写区域大小,如果否,调用mprotect()设置新增的区域可读可写,并更新当前sub_heap的可读可写区域的大小为new_size。最后将当前sub_heap的字段size更新为new_sizeshrink_heap()函数的实现源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static int
#if __STD_C
shrink_heap(heap_info *h, long diff)
#else
shrink_heap(h, diff) heap_info *h; long diff;
#endif
{
long new_size;
new_size = (long)h->size - diff;
if(new_size < (long)sizeof(*h))
return -1;
/* Try to re-map the extra heap space freshly to save memory, and make it inaccessible. */
#ifdef _LIBC79
if (__builtin_expect (__libc_enable_secure, 0))
#else
if (1)
#endif
{
if((char *)MMAP((char *)h + new_size, diff, PROT_NONE,
MAP_PRIVATE|MAP_FIXED) == (char *) MAP_FAILED)
return -2;
h->mprotect_size = new_size;
}
#ifdef _LIBC
else
madvise ((char *)h + new_size, diff, MADV_DONTNEED);
#endif
/*fprintf(stderr, "shrink %p %08lx\n", h, new_size);*/
h->size = new_size;
return 0;
}

shrink_heap()函数的参数diff已经页对齐,同时sub_heapsize也是安装页对齐的,所以计算sub_heapnew_size时不用再处理页对齐。如果new_sizesub_heap的首地址还小,报错退出,如果该函数运行在非Glibc中,则从sub_heap中切割出diff大小的虚拟内存,创建一个新的不可读写的映射区域,注意mmap()函数这里使用了MAP_FIXED标志,然后更新sub_heap的可读可写内存大小。如果该函数运行在Glibc库中,则调用madvise()函数,实际上madvise()函数什么也不做,只是返回错误,这里并没有处理madvise()函数的返回值。

1
2
3
4
5
6
#define delete_heap(heap) \
do { \
if ((char *)(heap) + HEAP_MAX_SIZE == aligned_heap_area) \
aligned_heap_area = NULL; \
munmap((char*)(heap), HEAP_MAX_SIZE); \
} while (0)

delete_heap()宏函数首先判断当前删除的sub_heap的结束地址是否与全局变量aligned_heap_area指向的地址相同,如果相同,则将全局变量aligned_heap_area设置为NULL,因为当前sub_heap删除以后,就可以从当前sub_heap的起始地址或是更低的地址开始映射新的sub_heap,这样可以尽量从地地址映射内存。然后调用munmap()函数将整个sub_heap的虚拟内存区域释放掉。在调用munmap()函数时,heap_trim()函数调用shrink_heap()函数可能已将sub_heap切分成多个子区域,munmap()函数的第二个参数为HEAP_MAX_SIZE,无论该sub_heap(大小为HEAP_MAX_SIZE)的内存区域被切分成多少个子区域,将整个sub_heap都释放掉了。

heap_trim()函数的源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int
internal_function80
#if __STD_C
heap_trim(heap_info *heap, size_t pad)
#else
heap_trim(heap, pad) heap_info *heap; size_t pad;
#endif
{
mstate ar_ptr = heap->ar_ptr;
unsigned long pagesz = mp_.pagesize;
mchunkptr top_chunk = top(ar_ptr), p, bck, fwd;
heap_info *prev_heap;
long new_size, top_size, extra;
/* Can this heap go away completely? */
while(top_chunk == chunk_at_offset(heap, sizeof(*heap))) {

每个非主分配区至少有一个sub_heap,每个非主分配区的第一个sub_heap中包含了一个heap_info的实例和malloc_state的实例,分主分配区中的其它sub_heap中只有一个heap_info实例,紧跟heap_info实例后,为可以用于分配的内存块。当当前非主分配区的topchunk与当前sub_heapheap_info实例的结束地址相同时,意味着当前sub_heap中只有一个空闲chunk,没有已分配的chunk。所以可以将当前整个sub_heap都释放掉。

1
2
3
4
5
6
7
8
9
10
11
prev_heap = heap->prev;
p = chunk_at_offset(prev_heap, prev_heap->size - (MINSIZE-2*SIZE_SZ));
assert(p->size == (0|PREV_INUSE)); /* must be fencepost */
p = prev_chunk(p);
new_size = chunksize(p) + (MINSIZE-2*SIZE_SZ);
assert(new_size>0 && new_size<(long)(2*MINSIZE));
if(!prev_inuse(p))
new_size += p->prev_size;
assert(new_size>0 && new_size<HEAP_MAX_SIZE);
if(new_size + (HEAP_MAX_SIZE - prev_heap->size) < pad + MINSIZE + pagesz)
break;

每个sub_heap的可读可写区域的末尾都有两个chunk用于fencepost,以64位系统为例,最后一个chunk占用的空间为MINSIZE-2*SIZE_SZ,为16B,最后一个chuksize字段记录的前一个chunkinuse状态,并标识当前chunk大小为0,倒数第二个chunkinuse状态,这个chunk也是fencepost的一部分,这个chunk的大小为2*SIZE_SZ,为16B,所以用于fencepost的两个chunk的空间大小为32B。fencepost也有可能大于32B,第二个chunk仍然为16B,第一个chunk的大小大于16B,这种情况发生在top chunk的空间小于2*MINSIZE,大于MINSIZE,但对于一个完全空闲的sub_heap来说,top chunk的空间肯定大于2*MINSIZE,所以在这里不考虑这种情况。用于fencepostchunk空间其实都是被分配给应用层使用的,new_size表示当前sub_heap中可读可写区域的可用空间,如果倒数第二个chunk的前一个chunk为空闲状态,当前sub_heap中可读可写区域的可用空间大小还需要加上这个空闲chunk的大小。如果new_sizesub_heap中剩余的不可读写的区域大小之和小于32+4K(64位系统),意味着前一个sub_heap的可用空间太少了,不能释放当前的sub_heap

1
2
3
4
5
6
7
8
9
10
11
12
13
    ar_ptr->system_mem -= heap->size;
arena_mem -= heap->size;
delete_heap(heap);
heap = prev_heap;
if(!prev_inuse(p)) { /* consolidate backward */
p = prev_chunk(p);
unlink(p, bck, fwd);
}
assert(((unsigned long)((char*)p + new_size) & (pagesz-1)) == 0);
assert( ((char*)p + new_size) == ((char*)heap + heap->size) );
top(ar_ptr) = top_chunk = p;
set_head(top_chunk, new_size | PREV_INUSE);
/*check_chunk(ar_ptr, top_chunk);*/

首先更新非主分配区的内存统计,然后调用delete_heap()宏函数释放该sub_heap,把当前heap设置为被释放sub_heap的前一个sub_heapp指向的是被释放sub_heap的前一个sub_heap的倒数第二个chunk,如果p的前一个chunk为空闲状态,由于不可能出现多个连续的空闲chunk,所以将p设置为p的前一个chunk,也就是p指向空闲chunk,并将该空闲chunk从空闲chunk链表中移除,并将将该空闲chunk赋值给sub_heaptop chunk,并设置top chunksize,标识top chunk的前一个chunk处于inuse状态。然后继续判断循环条件,如果循环条件不满足,退出循环,如果条件满足,继续对当前sub_heap进行收缩。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    }
top_size = chunksize(top_chunk);
extra = ((top_size - pad - MINSIZE + (pagesz-1))/pagesz - 1) * pagesz;
if(extra < (long)pagesz)
return 0;
/* Try to shrink. */
if(shrink_heap(heap, extra) != 0)
return 0;
ar_ptr->system_mem -= extra;
arena_mem -= extra;
/* Success. Adjust top accordingly. */
set_head(top_chunk, (top_size - extra) | PREV_INUSE);
/*check_chunk(ar_ptr, top_chunk);*/
···

首先查看`top chunk`的大小,如果`top chunk`的大小减去`pad`和`MINSIZE`小于一页大小,返回退出,否则调用`shrink_heap()`函数对当前`sub_heap`进行收缩,将空闲的整数个页收缩掉,仅剩下不足一页的空闲内存,如果`shrink_heap()`失败,返回退出,否则,更新内存使用统计,更新`top chunk`的大小。

```C
return 1;
}

内存分配malloc

ptmalloc2主要的内存分配函数为malloc(),但源代码中并不能找到该函数,该函数是用宏定义为public_malloc(),因为该函数在不同的编译条件下,具有不同的名称。public_malloc()函数只是简单的封装_int_malloc()函数,_int_malloc()函数才是内存分配的核心实现。下面我们将分析malloc的实现。

public_malloc()

先给出源代码:

1
2
3
4
5
6
7
8
9
void_t*
public_malloc(size_t bytes)
{
mstate ar_ptr;
void_t *victim;
__malloc_ptr_t (*hook) (size_t, __const __malloc_ptr_t)
= force_reg (__malloc_hook);
if (__builtin_expect (hook != NULL, 0))
return (*hook)(bytes, RETURN_ADDRESS (0));

首先检查是否存在内存分配的hook函数,如果存在,调用hook函数,并返回,hook函数主要用于进程在创建新线程过程中分配内存,或者支持用户提供的内存分配函数。

1
2
3
4
5
arena_lookup(ar_ptr);
arena_lock(ar_ptr, bytes);
if(!ar_ptr)
return 0;
victim = _int_malloc(ar_ptr, bytes);

获取分配区指针,如果获取分配区失败,返回退出,否则,调用_int_malloc()函数分配内存。

1
2
3
4
5
6
7
8
if(!victim) {
/* Maybe the failure is due to running out of mmapped areas. */
if(ar_ptr != &main_arena) {
(void)mutex_unlock(&ar_ptr->mutex);
ar_ptr = &main_arena;
(void)mutex_lock(&ar_ptr->mutex);
victim = _int_malloc(ar_ptr, bytes);
(void)mutex_unlock(&ar_ptr->mutex);

如果_int_malloc()函数分配内存失败,并且使用的分配区不是主分配区,这种情况可能是mmap区域的内存被用光了,当主分配区可以从堆中分配内存,所以需要再尝试从主分配区中分配内存。首先释放所使用分配区的锁,然后获得主分配区的锁,并调用_int_malloc()函数分配内存,最后释放主分配区的锁。

1
2
3
4
5
6
7
8
9
        } else {
#if USE_ARENAS
/* ... or sbrk() has failed and there is still a chance to mmap() */
ar_ptr = arena_get2(ar_ptr->next ? ar_ptr : 0, bytes);
(void)mutex_unlock(&main_arena.mutex);
if(ar_ptr) {
victim = _int_malloc(ar_ptr, bytes);
(void)mutex_unlock(&ar_ptr->mutex);
}

如果_int_malloc()函数分配内存失败,并且使用的分配区是主分配区,查看是否有非主分配区,如果有,调用arena_get2()获取分配区,然后对主分配区解锁,如果arena_get2()返回一个非主分配区,尝试调用_int_malloc()函数从该非主分配区分配内存,最后释放该非主分配区的锁。

1
2
3
4
#endif
}
} else
(void)mutex_unlock(&ar_ptr->mutex);

如果_int_malloc()函数分配内存成功,释放所使用的分配区的锁。

1
2
3
4
    assert(!victim || chunk_is_mmapped(mem2chunk(victim)) ||
ar_ptr == arena_for_chunk(mem2chunk(victim)));
return victim;
}

_int_malloc()

_int_malloc()函数是内存分配的核心,根据分配的内存块的大小,该函数中实现了四种分配内存的路径,下面将分别分析这四种分配路径。

先给出_int_malloc()函数的函数定义及临时变量的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static void_t*
_int_malloc(mstate av, size_t bytes)
{
INTERNAL_SIZE_T nb; /* normalized request size */
unsigned int idx; /* associated bin index */
mbinptr bin; /* associated bin */
mchunkptr victim; /* inspected/selected chunk */
INTERNAL_SIZE_T size; /* its size */
int victim_index; /* its bin index */
mchunkptr remainder; /* remainder from a split */
unsigned long remainder_size; /* its size */84
unsigned int block; /* bit map traverser */
unsigned int bit; /* bit map traverser */
unsigned int map; /* current word of binmap */
mchunkptr fwd; /* misc temp for linking */
mchunkptr bck; /* misc temp for linking */
const char *errstr = NULL;
/*
Convert request size to internal form by adding SIZE_SZ bytes
overhead plus possibly more to obtain necessary alignment and/or
to obtain a size of at least MINSIZE, the smallest allocatable
size. Also, checked_request2size traps (returning 0) request sizes
that are so large that they wrap around zero when padded and
aligned.
*/
checked_request2size(bytes, nb);

checked_request2size()函数将需要分配的内存大小bytes转换为需要分配的chunk大小nbptmalloc内部分配都是以chunk为单位,根据chunk的大小,决定如何获得满足条件的chunk

分配fast bin chunk

如果所需的chunk大小小于等于fast bins中的最大chunk大小,首先尝试从fast bins中分配chunk。源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
If the size qualifies as a fastbin, first check corresponding bin.
This code is safe to execute even if av is not yet initialized, so we
can try it without checking, which saves some time on this fast path.
*/
if ((unsigned long)(nb) <= (unsigned long)(get_max_fast ())) {
idx = fastbin_index(nb);
mfastbinptr* fb = &fastbin (av, idx);
#ifdef ATOMIC_FASTBINS
mchunkptr pp = *fb;
do
{
victim = pp;
if (victim == NULL)
break;
} while ((pp = catomic_compare_and_exchange_val_acq (fb, victim->fd, victim)) != victim);
#else
victim = *fb;
#endif
if (victim != 0) {
if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0))
{
errstr = "malloc(): memory corruption (fast)";
errout:
malloc_printerr (check_action, errstr, chunk2mem (victim));
return NULL;
}
#ifndef ATOMIC_FASTBINS
*fb = victim->fd;
#endif
check_remalloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
if (__builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;
}
}

如果没有开启ATOMIC_FASTBINS优化,从fast bins中分配一个chunk相当简单,首先根据所需chunk的大小获得该chunk所属fast binindex,根据该index获得所需fast bin的空闲chunk链表的头指针,然后将头指针的下一个chunk作为空闲chunk链表的头部。为了加快从fast bins中分配chunk,处于fast binschunk的状态仍然保持为inuse状态,避免被相邻的空闲chunk合并,从fast bins中分配chunk,只需取出第一个chunk,并调用chunk2mem()函数返回用户所需的内存块。

如果开启ATOMIC_FASTBINS优化,这里使用了lock-free的技术实现单向链表删除第一个node的操作。lock-free算法的基础是CAS(Compareand-Swap)原子操作。当某个地址的原始值等于某个比较值时,把值改成新值,无论有否修改,返回这个地址的原始值。目前的cpu支持最多64位的CAS,并且指针p必须对齐。原子操作指一个cpu时钟周期内就可以完成的操作,不会被其他线程干扰。

一般的CAS使用方式是:假设有指针p,它指向一个32位或者64位数,

  1. 复制p的内容(*p)到比较量cmp(原子操作)。
  2. 基于这个比较量计算一个新值xchg(非原子操作)。
  3. 调用CAS比较当前*pcmp,如果相等把*p替换成xchg(原子操作)。
  4. 如果成功退出,否则回到第一步重新进行。

第3步的CAS操作保证了写入的同时p没有被其他线程更改。如果*p已经被其他线程更改,那么第2步计算新值所使用的值cmp已经过期了,因此这个整个过程失败,重新来过。多线程环境下,由于3是一个原子操作,那么起码有一个线程(最快执行到3)的CAS操作可以成功,这样整体上看,就保证了所有的线程上在“前进”,而不需要使用效率低下的锁来协调线程,更不会导致死锁之类的麻烦。

ABA问题,当A线程执行2的时候,被B线程更改了*px,而C线程又把它改回了原始值,这时回到A线程,A线程无法监测到原始值已经被更改过了,CAS操作会成功(实际上应该失败)。ABA大部分情况下会造成一些问题,因为p的内容一般不可能是独立的,其他内容已经更改,而A线程认为它没有更改就会带来不可预知的结果。

如果开启ATOMIC_FASTBINS优化,这里的实现会出现ABA问题吗?不会出现,如果开启了ATOMIC_FASTBINS优化,在free时,如果释放的chunk属于fast bin,不需要对分配区加锁,可以通过lock-free技术将该chunk加入fast bins的链表中。当从分配区分配内存时,需要对分配区加锁,所以当A线程获得了分配区的锁,并从fast bin中分配内存执行2的时候,被B线程调用free函数向fast bin的链表中加入了一个新的chunk,即更改了*fbx,但不会存在C线程将*fb改回原值,如果存在,意味着C线程先分配了*fb所存的chunk,并将该chunk释放回了fast bin,但C线程分配*fb所存的chunk需要获得分配区的锁,但分配区的锁被A线程持有,所以C线程不可能将*fb改回原值,也就不会存在ABA问题。

分配small bin chunk

如果所需的chunk大小属于small bin,则会执行如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
If a small request, check regular bin. Since these "smallbins"
hold one size each, no searching within bins is necessary.
(For a large request, we need to wait until unsorted chunks are
processed to find best fit. But for small ones, fits are exact
anyway, so we can check now, which is faster.)
*/
if (in_smallbin_range(nb)) {
idx = smallbin_index(nb);
bin = bin_at(av,idx);
if ( (victim = last(bin)) != bin) {
if (victim == 0) /* initialization check */
malloc_consolidate(av);
else {
bck = victim->bk;
if (__builtin_expect (bck->fd != victim, 0))
{
errstr = "malloc(): smallbin double linked list corrupted";
goto errout;
}
set_inuse_bit_at_offset(victim, nb);
bin->bk = bck;
bck->fd = bin;
if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;87
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
if (__builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;
}
}
}

如果分配的chunk属于small bin,首先查找chunk所对应small bins数组的index,然后根据index获得某个small bin的空闲chunk双向循环链表表头,然后将最后一个chunk赋值给victim,如果victim与表头相同,表示该链表为空,不能从small bin的空闲chunk链表中分配,这里不处理,等后面的步骤来处理。如果victim与表头不同,有两种情况,如果victim为0,表示small bin还没有初始化为双向循环链表,调用malloc_consolidate()函数将fast bins中的chunk合并。否则,将victimsmall bin的双向循环链表中取出,设置victim chunkinuse标志,该标志处于victim chunk的下一个相邻chunksize字段的第一个bit。从small bin中取出一个chunk也可以用unlink()宏函数,只是这里没有使用。

接着判断当前分配区是否为非主分配区,如果是,将victim chunksize字段中的表示非主分配区的标志bit清零,最后调用chunk2mem()函数获得chunk的实际可用的内存指针,将该内存指针返回给应用层。到此从small bins中分配chunk的工作完成了,但我们看到,当对应的small bin中没有空闲chunk,或是对应的small bin还没有初始化完成,并没有获取到chunk,这两种情况都需要后面的步骤来处理。

分配large bin chunk

如果所需的chunk不属于small bins,首先会执行如下的代码段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
If this is a large request, consolidate fastbins before continuing.
While it might look excessive to kill all fastbins before
even seeing if there is space available, this avoids
fragmentation problems normally associated with fastbins.
Also, in practice, programs tend to have runs of either small or
large requests, but less often mixtures, so consolidation is not
invoked all that often in most programs. And the programs that
it is called frequently in otherwise tend to fragment.
*/
else {
idx = largebin_index(nb);
if (have_fastchunks(av))
malloc_consolidate(av);
}

所需chunk不属于small bins,那么就一定属于large bins,首先根据chunk的大小获得对应的large binindex,接着判断当前分配区的fast bins中是否包含chunk,如果存在,调用malloc_consolidate()函数合并fast bins中的chunk,并将这些空闲chunk加入unsorted bin中。

下面的源代码实现从last remainder chunklarge binstop chunk中分配所需的chunk,这里包含了多个多层循环,在这些循环中,主要工作是分配前两步都未分配成功的small bin chunklarge bin chunklarge chunk。最外层的循环用于重新尝试分配small bin chunk,因为如果在前一步分配small bin chunk不成功,并没有调用malloc_consolidate()函数合并fast bins中的chunk,将空闲chunk加入unsorted bin中,如果第一尝试从last remainder chunktop chunk中分配small bin chunk都失败以后,如果fast bins中存在空闲chunk,会调用malloc_consolidate()函数,那么在usorted bin中就可能存在合适的small bin chunk供分配,所以需要再次尝试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
Process recently freed or remaindered chunks, taking one only if
it is exact fit, or, if this a small request, the chunk is remainder from
the most recent non-exact fit. Place other traversed chunks in
bins. Note that this step is the only place in any routine where
chunks are placed in bins.
The outer loop here is needed because we might not realize until
near the end of malloc that we should have consolidated, so must
do so and retry. This happens at most once, and only when we would
otherwise need to expand memory to service a "small" request.
*/
for(;;) {
int iters = 0;
while ( (victim = unsorted_chunks(av)->bk) != unsorted_chunks(av)) {

反向遍历unsorted bin的双向循环链表,遍历结束的条件是循环链表中只剩下一个头结点。

1
2
3
4
5
bck = victim->bk;
if (__builtin_expect (victim->size <= 2 * SIZE_SZ, 0)
|| __builtin_expect (victim->size > av->system_mem, 0))
malloc_printerr (check_action, "malloc(): memory corruption", chunk2mem (victim));
size = chunksize(victim);

检查当前遍历的chunk是否合法,chunk的大小不能小于等于2 * SIZE_SZ,也不能超过该分配区总的内存分配量。然后获取chunk的大小并赋值给size。这里的检查似乎有点小问题,直接使用了victim->size,但victim->size中包含了相关的标志位信息,使用chunksize(victim)才比较合理,但在unsorted bin中的空闲chunk的所有标志位都清零了,所以这里直接victim->size没有问题。

1
2
3
4
5
6
7
8
9
10
11
/*
If a small request, try to use last remainder if it is the
only chunk in unsorted bin. This helps promote locality for
runs of consecutive small requests. This is the only89
exception to best-fit, and applies only when there is
no exact fit for a small chunk.
*/
if (in_smallbin_range(nb) &&
bck == unsorted_chunks(av) &&
victim == av->last_remainder &&
(unsigned long)(size) > (unsigned long)(nb + MINSIZE)) {

如果需要分配一个small bin chunk,在5.7.2.2节中的small bins中没有匹配到合适的chunk,并且unsorted bin中只有一个chunk,并且这个chunklast remainder chunk,并且这个chunk的大小大于所需chunk的大小加上MINSIZE,在满足这些条件的情况下,可以使用这个chunk切分出需要的small bin chunk。这是唯一的从unsorted bin中分配small bin chunk的情况,这种优化利于cpu的高速缓存命中。

1
2
3
4
5
6
7
8
9
10
11
/* split and reattach remainder */
remainder_size = size - nb;
remainder = chunk_at_offset(victim, nb);
unsorted_chunks(av)->bk = unsorted_chunks(av)->fd = remainder;
av->last_remainder = remainder;
remainder->bk = remainder->fd = unsorted_chunks(av);
if (!in_smallbin_range(remainder_size))
{
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}

从该chunk中切分出所需大小的chunk,计算切分后剩下chunk的大小,将剩下的chunk加入unsorted bin的链表中,并将剩下的chunk作为分配区的last remainder chunk,若剩下的chunk属于large bin chunk,将该chunkfd_nextsizebk_nextsize设置为NULL,因为这个chunk仅仅存在于unsorted bin中,并且unsorted bin中有且仅有这一个chunk

1
2
3
4
5
6
7
8
set_head(victim, nb | PREV_INUSE | (av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
set_foot(remainder, remainder_size);
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
if (__builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;

设置分配出的chunklast remainder chunk的相关信息,如chunksize,状态标志位,对于last remainder chunk,需要调用set_foot宏,因为只有处于空闲状态的chunkfoot信息(prev_size)才是有效的,处于inuse状态的chunkfoot无效,该foot是返回给应用层的内存块的一部分。设置完成chunk的相关信息,调用chunk2mem()获得chunk中可用的内存指针,返回给应用层,退出。

1
2
3
4
}
/* remove from unsorted list */
unsorted_chunks(av)->bk = bck;
bck->fd = unsorted_chunks(av);

将双向循环链表中的最后一个chunk移除。

1
2
3
4
5
6
7
8
9
10
11
/* Take now instead of binning if exact fit */
if (size == nb) {
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
if (__builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;
}

如果当前遍历的chunk与所需的chunk大小一致,将当前chunk返回。首先设置当前chunk处于inuse状态,该标志位处于相邻的下一个chunksize中,如果当前分配区不是主分配区,设置当前chunk的非主分配区标志位,最后调用chunk2mem()获得chunk中可用的内存指针,返回给应用层,退出。

1
2
3
4
5
/* place chunk in bin */
if (in_smallbin_range(size)) {
victim_index = smallbin_index(size);
bck = bin_at(av, victim_index);
fwd = bck->fd;

如果当前chunk属于small bins,获得当前chunk所属small binindex,并将该small bin的链表表头赋值给bck,第一个chunk赋值给fwd,也就是当前的chunk会插入到bckfwd之间,作为small bin链表的第一个chunk

1
2
3
4
5
}
else {
victim_index = largebin_index(size);
bck = bin_at(av, victim_index);
fwd = bck->fd;

如果当前chunk属于large bins,获得当前chunk所属large binindex,并将该large bin的链表表头赋值给bck,第一个chunk赋值给fwd,也就是当前的chunk会插入到bckfwd之间,作为large bin链表的第一个chunk

1
2
3
4
5
6
/* maintain large bins in sorted order */
if (fwd != bck) {
/* Or with inuse bit to speed comparisons */
size |= PREV_INUSE;
/* if smaller than smallest, bypass loop below */
assert((bck->bk->size & NON_MAIN_ARENA) == 0);

如果fwd不等于bck,意味着large bin中有空闲chunk存在,由于large bin中的空闲chunk是按照大小顺序排序的,需要将当前从unsorted bin中取出的chunk插入到large bin中合适的位置。将当前chunksizeinuse标志bit置位,相当于加1,便于加快chunk大小的比较,找到合适的地方插入当前chunk。这里还做了一次检查,断言在large bin双向循环链表中的最后一个chunksize字段中的非主分配区的标志bit没有置位,因为所有在large bin中的chunk都处于空闲状态,该标志位一定是清零的。

1
2
3
4
5
6
if ((unsigned long)(size) < (unsigned long)(bck->bk->size)) {
fwd = bck;
bck = bck->bk;
victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize;
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;

如果当前chunklarge bin的最后一个chunk的大小还小,那么当前chunk就插入到large bin的链表的最后,作为最后一个chunk。可以看出large bin中的chunk是按照从大到小的顺序排序的,同时一个chunk存在于两个双向循环链表中,一个链表包含了large bin中所有的chunk,另一个链表为chunk size链表,该链表从每个相同大小的chunk的取出第一个chunk按照大小顺序链接在一起,便于一次跨域多个相同大小的chunk遍历下一个不同大小的chunk,这样可以加快在large bin链表中的遍历速度。

1
2
3
4
5
6
7
8
}
else {
assert((fwd->size & NON_MAIN_ARENA) == 0);
while ((unsigned long) size < fwd->size)
{
fwd = fwd->fd_nextsize;
assert((fwd->size & NON_MAIN_ARENA) == 0);
}

正向遍历chunk size链表,直到找到第一个chunk大小小于等于当前chunk大小的chunk退出循环。

1
2
3
if ((unsigned long) size == (unsigned long) fwd->size)
/* Always insert in the second position. */
fwd = fwd->fd;

如果从large bin链表中找到了与当前chunk大小相同的chunk,则同一大小的chunk已经存在,那么chunk size链表中一定包含了fwd所指向的chunk,为了不修改chunk size链表,当前chunk只能插入fwd之后。

1
2
3
4
5
6
else
{
victim->fd_nextsize = fwd;92
victim->bk_nextsize = fwd->bk_nextsize;
fwd->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;

如果chunk size链表中还没有包含当前chunk大小的chunk,也就是说当前chunk的大小大于fwd的大小,则将当前chunk作为该chunk size的代表加入chunk size链表,chunk size链表也是按照由大到小的顺序排序。

1
2
3
4
5
        }
bck = fwd->bk;
}
} else
victim->fd_nextsize = victim->bk_nextsize = victim;

如果large bin链表中没有chunk,直接将当前chunk加入chunk size链表。

1
2
3
4
5
6
}
mark_bin(av, victim_index);
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
bck->fd = victim;

上面的代码将当前chunk插入到large bin的空闲chunk链表中,并将large bin所对应binmap的相应bit置位。

1
2
3
#define MAX_ITERS 10000
if (++iters >= MAX_ITERS)
break;

如果unsorted bin中的chunk超过了10000个,最多遍历10000个就退出,避免长时间处理unsorted bin影响内存分配的效率。

1
}

当将unsorted bin中的空闲chunk加入到相应的small binslarge bins后,将使用最佳匹配法分配large bin chunk。源代码如下:

1
2
3
4
5
6
7
8
/*
If a large request, scan through the chunks of current bin in
sorted order to find smallest that fits. Use the skip list for this.
*/
if (!in_smallbin_range(nb)) {
bin = bin_at(av, idx);
/* skip scan if empty or largest chunk is too small */
if ((victim = first(bin)) != bin && (unsigned long)(victim->size) >= (unsigned long)(nb)) {

如果所需分配的chunklarge bin chunk,查询对应的large bin链表,如果large bin链表为空,或者链表中最大的chunk也不能满足要求,则不能从large bin中分配。否则,遍历large bin链表,找到合适的chunk

1
2
3
4
victim = victim->bk_nextsize;
while (((unsigned long)(size = chunksize(victim)) <
(unsigned long)(nb)))
victim = victim->bk_nextsize;

反向遍历chunk size链表,直到找到第一个大于等于所需chunk大小的chunk退出循环。

1
2
3
4
/* Avoid removing the first entry for a size so that the skip
list does not have to be rerouted. */
if (victim != last(bin) && victim->size == victim->fd->size)
victim = victim->fd;

如果从large bin链表中选取的chunk victim不是链表中的最后一个chunk,并且与victim大小相同的chunk不止一个,那么意味着victimchunk size链表中的节点,为了不调整chunk size链表,需要避免将chunk size链表中的节点取出,所以取victim->fd节点对应的chunk作为候选chunk。由于large bin链表中的chunk也是按大小排序,同一大小的chunk有多个时,这些chunk必定排在一起,所以victim->fd节点对应的chunk的大小必定与victim的大小一样。

1
2
remainder_size = size - nb;
unlink(victim, bck, fwd);

计算将victim切分后剩余大小,并调用unlink()宏函数将victimlarge bin链表中取出。

1
2
3
4
5
/* Exhaust */
if (remainder_size < MINSIZE) {
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;

如果将victim切分后剩余大小小于MINSIZE,则将这个victim分配给应用层,这种情况下,实际分配的chunk比所需的chunk要大一些。以64位系统为例,remainder_size的可能大小为0和16,如果为0,表示victim的大小刚好等于所需chunk的大小,设置victiminuse标志,inuse标志位于下一个相邻的chunksize字段中。如果remainder_size为16,则这16字节就浪费掉了。如果当前分配区不是主分配区,将victimsize字段中的非主分配区标志置位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
}
/* Split */
else {
remainder = chunk_at_offset(victim, nb);
/* We cannot assume the unsorted list is empty and therefore
have to perform a complete insert here. */
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__builtin_expect (fwd->bk != bck, 0))94
{
errstr = "malloc(): corrupted unsorted chunks";
goto errout;
}
remainder->bk = bck;
remainder->fd = fwd;
bck->fd = remainder;
fwd->bk = remainder;
if (!in_smallbin_range(remainder_size))
{
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}

victim中切分出所需的chunk,剩余部分作为一个新的chunk加入到unsorted bin中。如果剩余部分chunk属于large bins,将剩余部分chunkchunk size链表指针设置为NULL,因为unsorted bin中的chunk是不排序的,这两个指针无用,必须清零。

1
2
3
4
set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
set_foot(remainder, remainder_size);

设置victimremainder的状态,由于remainder为空闲chunk,所以需要设置该chunkfoot

1
2
3
4
5
6
}
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
if (__builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;

large bin中使用最佳匹配法找到了合适的chunk,调用chunk2mem()获得chunk中可用的内存指针,返回给应用层,退出。

1
2
    }
}

如果通过上面的方式从最合适的small binlarge bin中都没有分配到需要的chunk,则查看比当前binindex大的small binlarge bin是否有空闲chunk可利用来分配所需的chunk。源代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
Search for a chunk by scanning bins, starting with next largest
bin. This search is strictly by best-fit; i.e., the smallest
(with ties going to approximately the least recently used) chunk
that fits is selected.95
The bitmap avoids needing to check that most blocks are nonempty.
The particular case of skipping all bins during warm-up phases
when no chunks have been returned yet is faster than it might look.
*/
++idx;
bin = bin_at(av,idx);
block = idx2block(idx);
map = av->binmap[block];
bit = idx2bit(idx);

获取下一个相邻bin的空闲chunk链表,并获取该bin对于binmap中的bit位的值。binmap中的标识了相应的bin中是否有空闲chunk存在。binmapblock管理,每个block为一个int,共32个bit,可以表示32个bin中是否有空闲chunk存在。使用binmap可以加快查找bin是否包含空闲chunk。这里只查询比所需chunk大的bin中是否有空闲chunk可用。

1
2
3
4
5
6
7
8
9
10
for (;;) {
/* Skip rest of block if there are no more set bits in this block. */
if (bit > map || bit == 0) {
do {
if (++block >= BINMAPSIZE) /* out of bins */
goto use_top;
} while ( (map = av->binmap[block]) == 0);
bin = bin_at(av, (block << BINMAPSHIFT));
bit = 1;
}

idx2bit()宏将idx指定的位设置为1,其它位清零,map表示一个block(unsigned int)值,如果bit大于map,意味着map为0,该block所对应的所有bins中都没有空闲chunk,于是遍历binmap的下一个block,直到找到一个不为0的block或者遍历完所有的block

退出循环遍历后,设置bin指向block的第一个bit对应的bin,并将bit置为1,表示该blockbit 1对应的bin,这个bin中如果有空闲chunk,该chunk的大小一定满足要求。

1
2
3
4
5
6
/* Advance to bin with set bit. There must be one. */
while ((bit & map) == 0) {
bin = next_bin(bin);
bit <<= 1;
assert(bit != 0);
}

在一个block遍历对应的bin,直到找到一个bit不为0退出遍历,则该bit对于的bin中有空闲chunk存在。

1
2
/* Inspect the bin. It is likely to be non-empty */
victim = last(bin);

bin链表中的最后一个chunk赋值为victim

1
2
3
4
5
/* If a false alarm (empty bin), clear the bit. */
if (victim == bin) {
av->binmap[block] = map &= ~bit; /* Write through */
bin = next_bin(bin);
bit <<= 1;

如果victimbin链表头指针相同,表示该bin中没有空闲chunkbinmap中的相应位设置不准确,将binmap的相应bit位清零,获取当前bin下一个bin,将bit移到下一个bit位,即乘以2。

1
2
3
4
5
6
7
8
}
else {
size = chunksize(victim);
/* We know the first chunk in this bin is big enough to use. */
assert((unsigned long)(size) >= (unsigned long)(nb));
remainder_size = size - nb;
/* unlink */
unlink(victim, bck, fwd);

当前bin中的最后一个chunk满足要求,获取该chunk的大小,计算切分出所需chunk后剩余部分的大小,然后将victimbin的链表中取出。

1
2
3
4
5
/* Exhaust */
if (remainder_size < MINSIZE) {
set_inuse_bit_at_offset(victim, size);
if (av != &main_arena)
victim->size |= NON_MAIN_ARENA;

如果剩余部分的大小小于MINSIZE,将整个chunk分配给应用层,设置victim的状态为inuse,如果当前分配区为非主分配区,设置victim的非主分配区标志位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
}
/* Split */
else {
remainder = chunk_at_offset(victim, nb);
/* We cannot assume the unsorted list is empty and therefore
have to perform a complete insert here. */
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__builtin_expect (fwd->bk != bck, 0))
{
errstr = "malloc(): corrupted unsorted chunks 2";
goto errout;
}
remainder->bk = bck;
remainder->fd = fwd;
bck->fd = remainder;
fwd->bk = remainder;
/* advertise as last remainder */
if (in_smallbin_range(nb))
av->last_remainder = remainder;
if (!in_smallbin_range(remainder_size))
{
remainder->fd_nextsize = NULL;
remainder->bk_nextsize = NULL;
}

victim中切分出所需的chunk,剩余部分作为一个新的chunk加入到unsorted bin中。如果剩余部分chunk属于small bins,将分配区的last remainder chunk设置为剩余部分构成的chunk;如果剩余部分chunk属于large bins,将剩余部分chunkchunk size链表指针设置为NULL,因为unsorted bin中的chunk是不排序的,这两个指针无用,必须清零。

1
2
3
4
set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
set_foot(remainder, remainder_size);

设置victimremainder的状态,由于remainder为空闲chunk,所以需要设置该chunkfoot

1
2
3
4
5
6
}
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
if (__builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;

调用chunk2mem()获得chunk中可用的内存指针,返回给应用层,退出。

1
2
    }
}

如果从所有的bins中都没有获得所需的chunk,可能的情况为bins中没有空闲chunk,或者所需的chunk大小很大,下一步将尝试从top chunk中分配所需chunk。源代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use_top:
/*
If large enough, split off the chunk bordering the end of memory
(held in av->top). Note that this is in accord with the best-fit
search rule. In effect, av->top is treated as larger (and thus
less well fitting) than any other available chunk since it can
be extended to be as large as necessary (up to system
limitations).98
We require that av->top always exists (i.e., has size >=
MINSIZE) after initialization, so if it would otherwise be
exhausted by current request, it is replenished. (The main
reason for ensuring it exists is that we may need MINSIZE space
to put in fenceposts in sysmalloc.)
*/
victim = av->top;
size = chunksize(victim);

将当前分配区的top chunk赋值给victim,并获得victim的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
if ((unsigned long)(size) >= (unsigned long)(nb + MINSIZE)) {
remainder_size = size - nb;
remainder = chunk_at_offset(victim, nb);
av->top = remainder;
set_head(victim, nb | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
check_malloced_chunk(av, victim, nb);
void *p = chunk2mem(victim);
if (__builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;
}

由于top chunk切分出所需chunk后,还需要MINSIZE的空间来作为fencepost,所需必须满足top chunk的大小大于所需chunk的大小加上MINSIZE这个条件,才能从top chunk中分配所需chunk。从top chunk切分出所需chunk的处理过程跟前面的chunk切分类似,不同的是,原top chunk切分后的剩余部分将作为新的top chunk,原top chunkfencepost仍然作为新的top chunkfencepost,所以切分之后剩余的chunk不用set_foot

1
2
3
4
5
6
7
8
9
10
11
#ifdef ATOMIC_FASTBINS
/* When we are using atomic ops to free fast chunks we can get
here for all block sizes. */
else if (have_fastchunks(av)) {
malloc_consolidate(av);
/* restore original bin index */
if (in_smallbin_range(nb))
idx = smallbin_index(nb);
else
idx = largebin_index(nb);
}

如果top chunk也不能满足要求,查看fast bins中是否有空闲chunk存在,由于开启了ATOMIC_FASTBINS优化情况下,free属于fast binschunk时不需要获得分配区的锁,所以在调用_int_malloc()函数时,有可能有其它线程已经向fast bins中加入了新的空闲chunk,也有可能是所需的chunk属于small bins,但通过前面的步骤都没有分配到所需的chunk,由于分配small bin chunk时在前面的步骤都不会调用malloc_consolidate()函数将fast bins中的chunk合并加入到unsorted bin中。所在这里如果fast bin中有chunk存在,调用malloc_consolidate()函数,并重新设置当前binindex。并转到最外层的循环,尝试重新分配small bin chunk或是large bin chunk。如果开启了ATOMIC_FASTBINS优化,有可能在由其它线程加入到fast bins中的chunk被合并后加入unsorted bin中,从unsorted bin中就可以分配出所需的large bin chunk了,所以对没有成功分配的large bin chunk也需要重试。

1
2
3
4
5
6
7
8
9
10
11
#else
/*
If there is space available in fastbins, consolidate and retry,
to possibly avoid expanding memory. This can occur only if nb is
in smallbin range so we didn't consolidate upon entry.
*/
else if (have_fastchunks(av)) {
assert(in_smallbin_range(nb));
malloc_consolidate(av);
idx = smallbin_index(nb); /* restore original bin index */
}

如果top chunk也不能满足要求,查看fast bins中是否有空闲chunk存在,如果fast bins中有空闲chunk存在,在没有开启ATOMIC_FASTBINS优化的情况下,只有一种可能,那就是所需的chunk属于small bins,但通过前面的步骤都没有分配到所需的small bin chunk,由于分配small bin chunk时在前面的步骤都不会调用malloc_consolidate()函数将fast bins中的空闲chunk合并加入到unsorted bin中。所在这里如果fast bins中有空闲chunk存在,调用malloc_consolidate()函数,并重新设置当前binindex。并转到最外层的循环,尝试重新分配small bin chunk

1
2
3
4
5
6
7
8
9
#endif
/*
Otherwise, relay to handle system-dependent cases
*/
else {
void *p = sYSMALLOc(nb, av);
if (p != NULL && __builtin_expect (perturb_byte, 0))
alloc_perturb (p, bytes);
return p;

山穷水尽了,只能向系统申请内存了。sYSMALLOc()函数可能分配的chunk包括small bin chunklarge bin chunklarge chunk。将在下一节中介绍该函数的实现。

1
2
    }
}

至此,_int_malloc()函数的代码就罗列完了,当还有两个关键函数没有分析,一个为malloc_consolidate(),另一个为sYSMALLOc(),将在下面的章节介绍其实现。

sYSMALLOc()

_int_malloc()函数尝试从fast binslast remainder chunksmall binslarge binstop chunk都失败之后,就会使用sYSMALLOc()函数直接向系统申请内存用于分配所需的chunk。其实现源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
sysmalloc handles malloc cases requiring more memory from the system.
On entry, it is assumed that av->top does not have enough
space to service request for nb bytes, thus requiring that av->top
be extended or replaced.
*/
#if __STD_C
static void_t* sYSMALLOc(INTERNAL_SIZE_T nb, mstate av)
#else
static void_t* sYSMALLOc(nb, av) INTERNAL_SIZE_T nb; mstate av;
#endif
{
mchunkptr old_top; /* incoming value of av->top */
INTERNAL_SIZE_T old_size; /* its size */
char* old_end; /* its end address */
long size; /* arg to first MORECORE or`mmap`call */
char* brk; /* return value from MORECORE */
long correction; /* arg to 2nd MORECORE call */
char* snd_brk; /* 2nd return val */
INTERNAL_SIZE_T front_misalign; /* unusable bytes at front of new space */
INTERNAL_SIZE_T end_misalign; /* partial page left at end of new space */
char* aligned_brk; /* aligned offset into brk */
mchunkptr p; /* the allocated/returned chunk */
mchunkptr remainder; /* remainder from allocation */
unsigned long remainder_size; /* its size */
unsigned long sum; /* for updating stats */
size_t pagemask = mp_.pagesize - 1;
bool tried_mmap = false;
#if HAVE_MMAP
/*
If have mmap, and the request size meets the`mmap`threshold, and101
the system supports mmap, and there are few enough currently
allocated mmapped regions, try to directly map this request
rather than expanding top.
*/
if ((unsigned long)(nb) >= (unsigned long)(mp_.mmap_threshold) &&
(mp_.n_mmaps < mp_.n_mmaps_max)) {
char* mm; /* return value from mmap call*/

如果所需分配的chunk大小大于mmap分配阈值,默认为128K,并且当前进程使用mmap()分配的内存块小于设定的最大值,将使用mmap()`系统调用直接向操作系统申请内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try_mmap:
/*
Round up size to nearest page. For mmapped chunks, the overhead
is one SIZE_SZ unit larger than for normal chunks, because there
is no following chunk whose prev_size field could be used.
*/
#if 1
/* See the front_misalign handling below, for glibc there is no
need for further alignments. */
size = (nb + SIZE_SZ + pagemask) & ~pagemask;
#else
size = (nb + SIZE_SZ + MALLOC_ALIGN_MASK + pagemask) & ~pagemask;
#endif
tried_mmap = true;

由于nb为所需chunk的大小,在_int_malloc()函数中已经将用户需要分配的大小转化为chunk大小,当如果这个chunk直接使用mmap()分配的话,该chunk不存在下一个相邻的chunk,也就没有prev_size的内存空间可以复用,所以还需要额外SIZE_SZ大小的内存。由于mmap()分配的内存块必须页对齐。如果使用mmap()分配内存,需要重新计算分配的内存大小size

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
        /* Don't try if size wraps around 0 */
if ((unsigned long)(size) > (unsigned long)(nb)) {
mm = (char*)(MMAP(0, size, PROT_READ|PROT_WRITE, MAP_PRIVATE));
if (mm != MAP_FAILED) {
/*
The offset to the start of the mmapped region is stored
in the prev_size field of the chunk. This allows us to adjust
returned start address to meet alignment requirements here
and in memalign(), and still be able to compute proper
address argument for later munmap in free() and realloc().
*/
#if 1
/* For glibc, chunk2mem increases the address by 2*SIZE_SZ and
MALLOC_ALIGN_MASK is 2*SIZE_SZ-1. Each mmap'ed area is page102
aligned and therefore definitely MALLOC_ALIGN_MASK-aligned. */
assert (((INTERNAL_SIZE_T)chunk2mem(mm) & MALLOC_ALIGN_MASK) == 0);
#else
front_misalign = (INTERNAL_SIZE_T)chunk2mem(mm) & MALLOC_ALIGN_MASK;
if (front_misalign > 0) {
correction = MALLOC_ALIGNMENT - front_misalign;
p = (mchunkptr)(mm + correction);
p->prev_size = correction;
set_head(p, (size - correction) |IS_MMAPPED);
}
else
#endif
{
p = (mchunkptr)mm;
set_head(p, size|IS_MMAPPED);
}

如果重新计算所需分配的size小于nb,表示溢出了,不分配内存,否则,调用mmap()分配所需大小的内存。如果mmap()分配内存成功,将mmap()返回的内存指针强制转换为chunk指针,并设置该chunk的大小为size,同时设置该chunkIS_MMAPPED标志位,表示本chunk是通过mmap()函数直接从系统分配的。由于mmap()返回的内存地址是按照页对齐的,也一定是按照2*SIZE_SZ对齐的,满足chunk的边界对齐规则,使用chunk2mem()获取chunk中实际可用的内存也没有问题,所以这里不需要做额外的对齐操作。

1
2
3
4
5
6
7
8
9
10
11
                /* update statistics */
if (++mp_.n_mmaps > mp_.max_n_mmaps)
mp_.max_n_mmaps = mp_.n_mmaps;
sum = mp_.mmapped_mem += size;
if (sum > (unsigned long)(mp_.max_mmapped_mem))
mp_.max_mmapped_mem = sum;
#ifdef NO_THREADS
sum += av->system_mem;
if (sum > (unsigned long)(mp_.max_total_mem))
mp_.max_total_mem = sum;
#endif

更新相关统计值,首先将当前进程mmap分配内存块的计数加一,如果使用mmap()分配的内存块数量大于设置的最大值,将最大值设置为最新值,这个判断不会成功,因为使用mmap分配内存的条件中包括了mp_.n_mmaps < mp_.n_mmaps_max,所以++mp_.n_mmaps > mp_.max_n_mmaps不会成立。然后更新mmap分配的内存总量,如果该值大于设置的最大值,将当前值赋值给mp_.max_mmapped_mem。如果只支持单线程,还需要计数当前进程所分配的内存总数,如果总数大于设置的最大值mp_.max_total_mem,修改mp_.max_total_mem为当前值。

1
2
3
4
5
6
7
8
9
10
11
                check_chunk(av, p);
return chunk2mem(p);103
}
}
}
#endif
/* Record incoming configuration of top */
old_top = av->top;
old_size = chunksize(old_top);
old_end = (char*)(chunk_at_offset(old_top, old_size));
brk = snd_brk = (char*)(MORECORE_FAILURE);

保存当前top chunk的指针,大小和结束地址到临时变量中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    /*
If not the first time through, we require old_size to be
at least MINSIZE and to have prev_inuse set.
*/
assert((old_top == initial_top(av) && old_size == 0) ||
((unsigned long) (old_size) >= MINSIZE &&
prev_inuse(old_top) &&
((unsigned long)old_end & pagemask) == 0));
/* Precondition: not enough current space to satisfy nb request */
assert((unsigned long)(old_size) < (unsigned long)(nb + MINSIZE));
#ifndef ATOMIC_FASTBINS
/* Precondition: all fastbins are consolidated */
assert(!have_fastchunks(av));
#endif

检查top chunk的合法性,如果第一次调用本函数,top chunk可能没有初始化,可能old_size为0,如果top chunk已经初始化,则top chunk的大小必须大于等于MINSIZE,因为top chunk中包含了fencepostfencepost需要MINSIZE大小的内存。top chunk必须标识前一个chunk处于inuse状态,这是规定,并且top chunk的结束地址必定是页对齐的。另外top chunk的除去fencepost的大小必定小于所需chunk的大小,不然在_int_malloc()函数中就应该使用top chunk获得所需的chunk。最后检查如果没有开启ATOMIC_FASTBINS优化,在使用_int_malloc()分配内存时,获得了分配区的锁,free时也要获得分配区的锁才能向fast bins中加入新的chunk,由于_int_malloc()在调用本函数前,已经将fast bins中的所有chunk都合并加入到unsorted bin中了,所以,本函数中fast bins中一定不会有空闲chunk存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    if (av != &main_arena) {
heap_info *old_heap, *heap;
size_t old_heap_size;
/* First try to extend the current heap. */
old_heap = heap_for_ptr(old_top);
old_heap_size = old_heap->size;
if ((long) (MINSIZE + nb - old_size) > 0104
&& grow_heap(old_heap, MINSIZE + nb - old_size) == 0) {
av->system_mem += old_heap->size - old_heap_size;
arena_mem += old_heap->size - old_heap_size;
#if 0
if(mmapped_mem + arena_mem + sbrked_mem > max_total_mem)
max_total_mem = mmapped_mem + arena_mem + sbrked_mem;
#endif
set_head(old_top, (((char *)old_heap + old_heap->size) - (char *)old_top)
| PREV_INUSE);

如果当前分配区为非主分配区,根据top chunk的指针获得当前sub_heapheap_info实例,如果top chunk的剩余有效空间不足以分配出所需的chunk(前面已经断言,这个肯定成立),尝试增长sub_heap的可读可写区域大小,如果成功,修改过内存分配的统计信息,并更新新的top chunksize

1
2
}
else if ((heap = new_heap(nb + (MINSIZE + sizeof(*heap)), mp_.top_pad))) {

调用new_heap()函数创建一个新的sub_heap,由于这个sub_heap中至少需要容下大小为nbchunk,大小为MINSIZEfencepost和大小为sizeof(*heap)heap_info实例,所以传入new_heap()函数的分配大小为nb + (MINSIZE + sizeof(*heap))

1
2
3
4
5
6
7
8
9
10
11
12
            /* Use a newly allocated heap. */
heap->ar_ptr = av;
heap->prev = old_heap;
av->system_mem += heap->size;
arena_mem += heap->size;
#if 0
if((unsigned long)(mmapped_mem + arena_mem + sbrked_mem) > max_total_mem)
max_total_mem = mmapped_mem + arena_mem + sbrked_mem;
#endif
/* Set up the new top. */
top(av) = chunk_at_offset(heap, sizeof(*heap));
set_head(top(av), (heap->size - sizeof(*heap)) | PREV_INUSE);

使新创建的sub_heap保存当前的分配区指针,将该sub_heap加入当前分配区的sub_heap链表中,更新当前分配区内存分配统计,将新创建的sub_heap仅有的一个空闲chunk作为当前分配区的top chunk,并设置top chunk的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
            /* Setup fencepost and free the old top chunk. */
/* The fencepost takes at least MINSIZE bytes, because it might
become the top chunk again later. Note that a footer is set
up, too, although the chunk is marked in use. */
old_size -= MINSIZE;
set_head(chunk_at_offset(old_top, old_size + 2*SIZE_SZ), 0|PREV_INUSE);
if (old_size >= MINSIZE) {
set_head(chunk_at_offset(old_top, old_size), (2*SIZE_SZ)|PREV_INUSE);105
set_foot(chunk_at_offset(old_top, old_size), (2*SIZE_SZ));
set_head(old_top, old_size|PREV_INUSE|NON_MAIN_ARENA);
#ifdef ATOMIC_FASTBINS
_int_free(av, old_top, 1);
#else
_int_free(av, old_top);
#endif
} else {
set_head(old_top, (old_size + 2*SIZE_SZ)|PREV_INUSE);
set_foot(old_top, (old_size + 2*SIZE_SZ));
}

设置原top chunkfencepostfencepost需要MINSIZE大小的内存空间,将该old_size减去MINSIZE得到原top chunk的有效内存空间,首先设置fencepost的第二个chunksize为0,并标识前一个chunk处于inuse状态。接着判断原top chunk的有效内存空间上是否大于等于MINSIZE,如果是,表示原top chunk可以分配出大于等于MINSIZE大小的chunk,于是将原top chunk切分成空闲chunkfencepost两部分,先设置fencepost的第一个chunk的大小为2*SIZE_SZ,并标识前一个chunk处于inuse状态,fencepost的第一个chunk还需要设置foot,表示该chunk处于空闲状态,而fencepost的第二个chunk却标识第一个chunk处于inuse状态,因为不能有两个空闲chunk相邻,才会出现这么奇怪的fencepost。另外其实top chunk切分出来的chunk也是处于空闲状态,但fencepost的第一个chunk却标识前一个chunkinuse状态,然后强制将该处于inuse状态的chunk调用_int_free()函数释放掉。这样做完全是要遵循不能有两个空闲chunk相邻的约定。

如果原top chunk中有效空间不足MINSIZE,则将整个原top chunk作为fencepost,并设置fencepost的第一个chunk的相关状态。

1
2
3
4
}
else if (!tried_mmap)
/* We can at least try to use to`mmap`memory. */
goto try_mmap;

如果增长sub_heap的可读可写区域大小和创建新sub_heap都失败了,尝试使用mmap()函数直接从系统分配所需chunk

1
2
3
4
} else { 
/* av == main_arena */
/* Request enough space for nb + pad + overhead */
size = nb + mp_.top_pad + MINSIZE;

如果为当前分配区为主分配区,重新计算需要分配的size

1
2
3
4
5
6
7
/*
If contiguous, we can subtract out existing space that we hope to
combine with new space. We add it back later only if
we don't actually get contiguous space.
*/
if (contiguous(av))
size -= old_size;

一般情况下,主分配区使用sbrk()heap中分配内存,sbrk()返回连续的虚拟内存,这里调整需要分配的size,减掉top chunk中已有空闲内存大小。

1
2
3
4
5
6
7
8
/*
Round to a multiple of page size.
If MORECORE is not contiguous, this ensures that we only call it
with whole-page arguments. And if MORECORE is contiguous and
this is not first time through, this preserves page-alignment of
previous calls. Otherwise, we correct to page-align below.
*/
size = (size + pagemask) & ~pagemask;

size按照页对齐,sbrk()必须以页为单位分配连续虚拟内存。

1
2
3
4
5
6
7
/*
Don't try to call MORECORE if argument is so big as to appear
negative. Note that since`mmap`takes size_t arg, it may succeed
below even if we cannot call MORECORE.
*/
if (size > 0)
brk = (char*)(MORECORE(size));

使用sbrk()heap中分配size大小的虚拟内存块。

1
2
3
4
5
if (brk != (char*)(MORECORE_FAILURE)) {
/* Call the `morecore' hook if necessary. */
void (*hook) (void) = force_reg (__after_morecore_hook);
if (__builtin_expect (hook != NULL, 0))
(*hook) ();

如果sbrk()分配成功,并且morecorehook函数存在,调用morecorehook函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
            } else {
/*
If have mmap, try using it as a backup when MORECORE fails or
cannot be used. This is worth doing on systems that have "holes" in
address space, so sbrk cannot extend to give contiguous space, but
space is available elsewhere. Note that we ignore mmap max count
and threshold limits, since the space will not be used as a
segregated mmap region.
*/
#if HAVE_MMAP
/* Cannot merge with old top, so add its size back in */
if (contiguous(av))
size = (size + old_size + pagemask) & ~pagemask;
/* If we are relying on`mmap`as backup, then use larger units */
if ((unsigned long)(size) < (unsigned long)(MMAP_AS_MORECORE_SIZE))
size = MMAP_AS_MORECORE_SIZE;

如果sbrk()返回失败,或是sbrk()不可用,使用mmap()代替,重新计算所需分配的内存大小并按页对齐,如果重新计算的size小于1M,将size设为1M,也就是说使用mmap()作为morecore函数分配的最小内存块大小为1M。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
            /* Don't try if size wraps around 0 */
if ((unsigned long)(size) > (unsigned long)(nb)) {
char *mbrk = (char*)(MMAP(0, size, PROT_READ|PROT_WRITE, MAP_PRIVATE));
if (mbrk != MAP_FAILED) {
/* We do not need, and cannot use, another sbrk call to find end */
brk = mbrk;
snd_brk = brk + size;
/*
Record that we no longer have a contiguous sbrk region.
After the first time`mmap`is used as backup, we do not
ever rely on contiguous space since this could incorrectly
bridge regions.
*/
set_noncontiguous(av);
}
···

如果所需分配的内存大小合法,使用`mmap()`函数分配内存。如果分配成功,更新`brk`和`snd_brk`,并将当前分配区属性设置为可分配不连续虚拟内存块。

```C
}
#endif
}

if (brk != (char*)(MORECORE_FAILURE)) {
if (mp_.sbrk_base == 0)
mp_.sbrk_base = brk;
av->system_mem += size;

如果brk合法,即sbrk()mmap()分配成功,如果sbrk_base还没有初始化,更新sbrk_base和当前分配区的内存分配总量。

1
2
3
4
5
6
7
8
9
/*
If MORECORE extends previous space, we can likewise extend top size.
*/
if (brk == old_end && snd_brk == (char*)(MORECORE_FAILURE))
set_head(old_top, (size + old_size) | PREV_INUSE);
else if (contiguous(av) && old_size && brk < old_end) {
/* Oops! Someone else killed our space.. Can't touch anything. */
malloc_printerr (3, "break adjusted to free malloc space", brk);
}

如果sbrk()分配成功,更新top chunk的大小,并设定top chunk的前一个chunk处于inuse状态。如果当前分配区可分配连续虚拟内存,原top chunk的大小大于0,但新的brk值小于原top chunk的结束地址,出错了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
Otherwise, make adjustments:
* If the first time through or noncontiguous, we need to call sbrk
just to find out where the end of memory lies.
* We need to ensure that all returned chunks from malloc will meet
MALLOC_ALIGNMENT
* If there was an intervening foreign sbrk, we need to adjust sbrk
request size to account for fact that we will not be able to
combine new space with existing space in old_top.
* Almost all systems internally allocate whole pages at a time, in
which case we might as well use the whole last page of request.
So we allocate enough more memory to hit a page boundary now,
which in turn causes future contiguous calls to page-align.
*/
else {
front_misalign = 0;
end_misalign = 0;
correction = 0;
aligned_brk = brk;

执行到这个分支,意味着sbrk()返回的brk值大于原top chunk的结束地址,那么新的地址与原top chunk的地址不连续,可能是由于外部其它地方调用`sbrk()函数,这里需要处理地址的重新对齐问题。

1
2
3
4
5
/* handle contiguous cases */
if (contiguous(av)) {
/* Count foreign sbrk as system_mem. */
if (old_size)
av->system_mem += brk - old_end;

如果本分配区可分配连续虚拟内存,并且有外部调用了sbrk()函数,将外部调用sbrk()分配的内存计入当前分配区所分配内存统计中。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Guarantee alignment of first new chunk made from this space */
front_misalign = (INTERNAL_SIZE_T)chunk2mem(brk) & MALLOC_ALIGN_MASK;
if (front_misalign > 0) {
/*
Skip over some bytes to arrive at an aligned position.
We don't need to specially mark these wasted front bytes.
They will never be accessed anyway because
prev_inuse of av->top (and any chunk created from its start)109
is always true after initialization.
*/
correction = MALLOC_ALIGNMENT - front_misalign;
aligned_brk += correction;
}

计算当前的brk要矫正的字节数据,保证brk地址按MALLOC_ALIGNMENT对齐。

1
2
3
4
5
6
7
8
9
10
/*
If this isn't adjacent to existing space, then we will not
be able to merge with old_top space, so must add to 2nd request.
*/
correction += old_size;
/* Extend the end address to hit a page boundary */
end_misalign = (INTERNAL_SIZE_T)(brk + size + correction);
correction += ((end_misalign + pagemask) & ~pagemask) - end_misalign;
assert(correction >= 0);
snd_brk = (char*)(MORECORE(correction));

由于原top chunk的地址与当前brk不相邻,也就不能再使用原top chunk的内存了,需要重新为所需chunk分配足够的内存,将原top chunk的大小加到矫正值中,从当前brk中分配所需chunk,计算出未对齐的chunk结束地址end_misalign,然后将end_misalign按照页对齐计算出需要矫正的字节数加到矫正值上。然后再调用sbrk()分配矫正值大小的内存,如果sbrk()分配成功,则当前的top chunk中可以分配出所需的连续内存的chunk

1
2
3
4
5
6
7
8
9
10
11
/*
If can't allocate correction, try to at least find out current
brk. It might be enough to proceed without failing.
Note that if second sbrk did NOT fail, we assume that space
is contiguous with first sbrk. This is a safe assumption unless
program is multithreaded but doesn't use locks and a foreign sbrk
occurred between our first and second calls.
*/
if (snd_brk == (char*)(MORECORE_FAILURE)) {
correction = 0;
snd_brk = (char*)(MORECORE(0));

如果sbrk()执行失败,更新当前brk的结束地址。

1
2
3
4
5
} else {
/* Call the `morecore' hook if necessary. */
void (*hook) (void) = force_reg (__after_morecore_hook);
if (__builtin_expect (hook != NULL, 0))
(*hook) ();

如果sbrk()执行成功,并且有morecore hook函数存在,执行该hook函数。

1
2
3
4
5
6
7
8
9
10
    }
}
/* handle non-contiguous cases */
else {
/* MORECORE/mmap must correctly align */
assert(((unsigned long)chunk2mem(brk) & MALLOC_ALIGN_MASK) == 0);
/* Find out current end of memory */
if (snd_brk == (char*)(MORECORE_FAILURE)) {
snd_brk = (char*)(MORECORE(0));
}

执行到这里,意味着brk是用mmap()分配的,断言brk一定是按MALLOC_ALIGNMENT对齐的,因为mmap()返回的地址按页对齐。如果brk的结束地址非法,使用morecore获得当前brk的结束地址。

1
2
3
4
5
6
}
/* Adjust top based on results of second sbrk */
if (snd_brk != (char*)(MORECORE_FAILURE)) {
av->top = (mchunkptr)aligned_brk;
set_head(av->top, (snd_brk - aligned_brk + correction) | PREV_INUSE);
av->system_mem += correction;

如果brk的结束地址合法,设置当前分配区的top chunkbrk,设置top chunk的大小,并更新分配区的总分配内存量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
                    /*
If not the first time through, we either have a
gap due to foreign sbrk or a non-contiguous region. Insert a
double fencepost at old_top to prevent consolidation with space
we don't own. These fenceposts are artificial chunks that are
marked as inuse and are in any case too small to use. We need
two to make sizes and alignments work out.
*/
if (old_size != 0) {
/*
shrink old_top to insert fenceposts, keeping size a
multiple of MALLOC_ALIGNMENT. We know there is at least
enough space in old_top to do this.
*/
old_size = (old_size - 4*SIZE_SZ) & ~MALLOC_ALIGN_MASK;
set_head(old_top, old_size | PREV_INUSE);
/*
Note that the following assignments completely overwrite
old_top when old_size was previously MINSIZE. This is
intentional. We need the fencepost, even if old_top otherwise gets
lost.
*/
chunk_at_offset(old_top, old_size )->size =
(2*SIZE_SZ)|PREV_INUSE;
chunk_at_offset(old_top, old_size + 2*SIZE_SZ)->size =
(2*SIZE_SZ)|PREV_INUSE;
/* If possible, release the rest. */
if (old_size >= MINSIZE) {
#ifdef ATOMIC_FASTBINS
_int_free(av, old_top, 1);
#else
_int_free(av, old_top);
#endif
}

设置原top chunkfencepostfencepost需要MINSIZE大小的内存空间,将该old_size减去MINSIZE得到原top chunk的有效内存空间,我们可以确信原top chunk的有效内存空间一定大于MINSIZE,将原top chunk切分成空闲chunkfencepost两部分,首先设置切分出来的chunk的大小为old_size,并标识前一个chunk处于inuse状态,原top chunk切分出来的chunk本应处于空闲状态,但fencepost的第一个chunk却标识前一个chunkinuse状态,然后强制将该处于inuse状态的chunk调用_int_free()函数释放掉。然后设置fencepost的第一个chunk的大小为2*SIZE_SZ,并标识前一个chunk处于inuse状态,然后设置fencepost的第二个chunksize2*SIZE_SZ,并标识前一个chunk处于inuse状态。这里的主分配区的fencepost与非主分配区的fencepost不同,主分配区fencepost的第二个chunk的大小设置为2*SIZE_SZ,而非主分配区的fencepost的第二个chunk的大小设置为0。

1
2
3
4
5
6
7
8
9
10
                    }
}
}
/* Update statistics */
#ifdef NO_THREADS
sum = av->system_mem + mp_.mmapped_mem;
if (sum > (unsigned long)(mp_.max_total_mem))
mp_.max_total_mem = sum;
#endif
}

到此为止,对主分配区的分配出来完毕。

1
2
3
} /* if (av != &main_arena) */
if ((unsigned long)av->system_mem > (unsigned long)(av->max_system_mem))
av->max_system_mem = av->system_mem;

如果当前分配区所分配的内存量大于设置的最大值,更新当前分配区最大分配的内存量,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
check_malloc_state(av);
/* finally, do the allocation */
p = av->top;
size = chunksize(p);
/* check that one of the above allocation paths succeeded */
if ((unsigned long)(size) >= (unsigned long)(nb + MINSIZE)) {
remainder_size = size - nb;
remainder = chunk_at_offset(p, nb);
av->top = remainder;
set_head(p, nb | PREV_INUSE | (av != &main_arena ? NON_MAIN_ARENA : 0));
set_head(remainder, remainder_size | PREV_INUSE);
check_malloced_chunk(av, p, nb);
return chunk2mem(p);
}

如果当前top chunk中已经有足够的内存来分配所需的chunk,从当前的top chunk中分配所需的chunk并返回。

1
2
3
4
    /* catch all failure paths */
MALLOC_FAILURE_ACTION;
return 0;
}

malloc_consolidate()

malloc_consolidate()函数用于将fast bins中的chunk合并,并加入unsorted bin中,其实现源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
------------------------- malloc_consolidate -------------------------
malloc_consolidate is a specialized version of free() that tears
down chunks held in fastbins. Free itself cannot be used for this
purpose since, among other things, it might place chunks back onto
fastbins. So, instead, we need to use a minor variant of the same
code.113
Also, because this routine needs to be called the first time through
malloc anyway, it turns out to be the perfect place to trigger
initialization code.
*/
#if __STD_C
static void malloc_consolidate(mstate av)
#else
static void malloc_consolidate(av) mstate av;
#endif
{
mfastbinptr* fb; /* current fastbin being consolidated */
mfastbinptr* maxfb; /* last fastbin (for loop control) */
mchunkptr p; /* current chunk being consolidated */
mchunkptr nextp; /* next chunk to consolidate */
mchunkptr unsorted_bin; /* bin header */
mchunkptr first_unsorted; /* chunk to link to */
/* These have same use as in free() */
mchunkptr nextchunk;
INTERNAL_SIZE_T size;
INTERNAL_SIZE_T nextsize;
INTERNAL_SIZE_T prevsize;
int nextinuse;
mchunkptr bck;
mchunkptr fwd;
/*
If max_fast is 0, we know that av hasn't
yet been initialized, in which case do so below
*/
if (get_max_fast () != 0) {
clear_fastchunks(av);
unsorted_bin = unsorted_chunks(av);

如果全局变量global_max_fast不为零,表示ptmalloc已经初始化,清除分配区flagfast bin的标志位,该标志位表示分配区的fast bins中包含空闲chunk。然后获得分配区的unsorted bin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        /*
Remove each chunk from fast bin and consolidate it, placing it
then in unsorted bin. Among other reasons for doing this,
placing in unsorted bin avoids needing to calculate actual bins
until malloc is sure that chunks aren't immediately going to be114
reused anyway.
*/
#if 0
/* It is wrong to limit the fast bins to search using get_max_fast
because, except for the main arena, all the others might have
blocks in the high fast bins. It's not worth it anyway, just
search all bins all the time. */
maxfb = &fastbin (av, fastbin_index(get_max_fast ()));
#else
maxfb = &fastbin (av, NFASTBINS - 1);
#endif
fb = &fastbin (av, 0);

将分配区最大的一个fast bin赋值给maxfb,第一个fast bin赋值给fb,然后遍历fast bins

1
2
3
4
5
6
7
8
9
10
        do {
#ifdef ATOMIC_FASTBINS
p = atomic_exchange_acq (fb, 0);
#else
p = *fb;
#endif
if (p != 0) {
#ifndef ATOMIC_FASTBINS
*fb = 0;
#endif

获取当前遍历的fast bin中空闲chunk单向链表的头指针赋值给p,如果p不为0,将当前fast bin链表的头指针赋值为0,即删除了该fast bin中的空闲chunk链表。

1
2
3
do {
check_inuse_chunk(av, p);
nextp = p->fd;

将空闲chunk链表的下一个chunk赋值给nextp

1
2
3
4
/* Slightly streamlined version of consolidation code in free() */
size = p->size & ~(PREV_INUSE|NON_MAIN_ARENA);
nextchunk = chunk_at_offset(p, size);
nextsize = chunksize(nextchunk);

获得当前chunksize,需要去除size中的PREV_INUSENON_MAIN_ARENA标志,并获取相邻的下一个chunk和下一个chunk的大小。

1
2
3
4
5
6
if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));115
unlink(p, bck, fwd);
}

如果当前chunk的前一个chunk空闲,则将当前chunk与前一个chunk合并成一个空闲chunk,由于前一个chunk空闲,则当前chunkprev_size保存了前一个chunk的大小,计算出合并后的chunk大小,并获取前一个chunk的指针,将前一个chunk从空闲链表中删除。

1
2
if (nextchunk != av->top) {
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

如果与当前chunk相邻的下一个chunk不是分配区的top chunk,查看与当前chunk相邻的下一个chunk是否处于inuse状态。

1
2
3
4
5
if (!nextinuse) {
size += nextsize;
unlink(nextchunk, bck, fwd);
} else
clear_inuse_bit_at_offset(nextchunk, 0);

如果与当前chunk相邻的下一个chunk处于inuse状态,清除当前chunkinuse状态,则当前chunk空闲了。否则,将相邻的下一个空闲chunk从空闲链表中删除,并计算当前chunk与下一个chunk合并后的chunk大小。

1
2
3
first_unsorted = unsorted_bin->fd;
unsorted_bin->fd = p;
first_unsorted->bk = p;

将合并后的chunk加入unsorted bin的双向循环链表中。

1
2
3
4
if (!in_smallbin_range (size)) {
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}

如果合并后的chunk属于large bin,将chunkfd_nextsizebk_nextsize设置为NULL,因为在unsorted bin中这两个字段无用。

1
2
3
4
set_head(p, size | PREV_INUSE);
p->bk = unsorted_bin;
p->fd = first_unsorted;
set_foot(p, size);

设置合并后的空闲chunk大小,并标识前一个chunk处于inuse状态,因为必须保证不能有两个相邻的chunk都处于空闲状态。然后将合并后的chunk加入unsorted bin的双向循环链表中。最后设置合并后的空闲chunkfootchunk空闲时必须设置foot,该foot处于下一个chunkprev_size中,只有chunk空闲是foot才是有效的。

1
2
3
4
5
6
}
else {
size += nextsize;
set_head(p, size | PREV_INUSE);116
av->top = p;
}

如果当前chunk的下一个chunktop chunk,则将当前chunk合并入top chunk,修改top chunk的大小。

1
} while ( (p = nextp) != 0);

直到遍历完当前fast bin中的所有空闲chunk

1
2
    }
} while (fb++ != maxfb);

直到遍历完所有的fast bins

1
2
3
4
}
else {
malloc_init_state(av);
check_malloc_state(av);

如果ptmalloc没有初始化,初始化ptmalloc

1
2
    }
}

内存释放 free

public_fREe()

public_fREe()函数的源代码如下:

1
2
3
4
5
6
7
8
9
10
11
void
public_fREe(void_t* mem)
{
mstate ar_ptr;
mchunkptr p; /* chunk corresponding to mem */
void (*hook) (__malloc_ptr_t, __const __malloc_ptr_t)
= force_reg (__free_hook);
if (__builtin_expect (hook != NULL, 0)) {
(*hook)(mem, RETURN_ADDRESS (0));
return;
}

如果存在freehook函数,执行该hook函数返回,freehook函数主要用于创建新线程使用或使用用户提供的free函数。

1
2
3
if (mem == 0) /* free(0) has no effect */
return;
p = mem2chunk(mem);

free NULL指针直接返回,然后根据内存指针获得chunk的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#if HAVE_MMAP
if (chunk_is_mmapped(p)) /* release mmapped memory. */
{
/* see if the dynamic brk/mmap threshold needs adjusting */
if (!mp_.no_dyn_threshold
&& p->size > mp_.mmap_threshold
&& p->size <= DEFAULT_MMAP_THRESHOLD_MAX)
{
mp_.mmap_threshold = chunksize (p);
mp_.trim_threshold = 2 * mp_.mmap_threshold;
}
munmap_chunk(p);
return;
}
#endif

如果当前freechunk是通过mmap()分配的,调用munmap_chunk()函数unmapchunkmunmap_chunk()函数调用munmap()函数释放mmap()分配的内存块。同时查看是否开启了mmap分配阈值动态调整机制,默认是开启的,如果当前freechunk的大小大于设置的mmap分配阈值,小于mmap分配阈值的最大值,将当前chunk的大小赋值给mmap分配阈值,并修改mmap收缩阈值为mmap分配阈值的2倍。默认情况下mmap分配阈值与mmap收缩阈值相等,都为128KB。

1
ar_ptr = arena_for_chunk(p);

根据chunk指针获得分配区的指针。

1
2
#ifdef ATOMIC_FASTBINS
_int_free(ar_ptr, p, 0);

如果开启了ATOMIC_FASTBINS优化,不需要对分配区加锁,调用_int_free()函数执行实际的释放工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#else
# if THREAD_STATS
if(!mutex_trylock(&ar_ptr->mutex))
++(ar_ptr->stat_lock_direct);
else {
(void)mutex_lock(&ar_ptr->mutex);
++(ar_ptr->stat_lock_wait);
}
# else
(void)mutex_lock(&ar_ptr->mutex);
# endif
_int_free(ar_ptr, p);
(void)mutex_unlock(&ar_ptr->mutex);
#endif

如果没有开启了ATOMIC_FASTBINS优化,或去分配区的锁,调用_int_free()函数执行实际的释放工作,然后对分配区解锁。

1
}

_int_free()

_int_free()函数的实现源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static void
#ifdef ATOMIC_FASTBINS
_int_free(mstate av, mchunkptr p, int have_lock)
#else
_int_free(mstate av, mchunkptr p)
#endif
{
INTERNAL_SIZE_T size; /* its size */
mfastbinptr* fb; /* associated fastbin */
mchunkptr nextchunk; /* next contiguous chunk */
INTERNAL_SIZE_T nextsize; /* its size */
int nextinuse; /* true if nextchunk is used */
INTERNAL_SIZE_T prevsize; /* size of previous contiguous chunk */
mchunkptr bck; /* misc temp for linking */
mchunkptr fwd; /* misc temp for linking */
const char *errstr = NULL;
#ifdef ATOMIC_FASTBINS
int locked = 0;
#endif
size = chunksize(p);

获取需要释放的chunk的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    /* Little security check which won't hurt performance: the
allocator never wrapps around at the end of the address space.
Therefore we can exclude some size values which might appear
here by accident or by "design" from some intruder. */
if (__builtin_expect ((uintptr_t) p > (uintptr_t) -size, 0)
|| __builtin_expect (misaligned_chunk (p), 0))
{
errstr = "free(): invalid pointer";
errout:
#ifdef ATOMIC_FASTBINS
if (! have_lock && locked)
(void)mutex_unlock(&av->mutex);
#endif
malloc_printerr (check_action, errstr, chunk2mem(p));
return;
}
/* We know that each chunk is at least MINSIZE bytes in size. */
if (__builtin_expect (size < MINSIZE, 0))
{
errstr = "free(): invalid size";
goto errout;
}
check_inuse_chunk(av, p);

上面的代码用于安全检查,chunk的指针地址不能溢出,chunk的大小必须大于等于MINSIZE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
    /*
If eligible, place chunk on a fastbin so it can be found
and used quickly in malloc.
*/
if ((unsigned long)(size) <= (unsigned long)(get_max_fast ())
#if TRIM_FASTBINS
/*
If TRIM_FASTBINS set, don't place chunks
bordering top into fastbins
*/
&& (chunk_at_offset(p, size) != av->top)
#endif
) {
if (__builtin_expect (chunk_at_offset (p, size)->size <= 2 * SIZE_SZ, 0)
|| __builtin_expect (chunksize (chunk_at_offset (p, size))
>= av->system_mem, 0))
{
#ifdef ATOMIC_FASTBINS
/* We might not have a lock at this point and concurrent modifications
of system_mem might have let to a false positive. Redo the test
after getting the lock. */
if (have_lock
|| ({ assert (locked == 0);
mutex_lock(&av->mutex);
locked = 1;
chunk_at_offset (p, size)->size <= 2 * SIZE_SZ
|| chunksize (chunk_at_offset (p, size)) >= av->system_mem;
}))
#endif
{
errstr = "free(): invalid next size (fast)";
goto errout;
}
#ifdef ATOMIC_FASTBINS
if (! have_lock)
{
(void)mutex_unlock(&av->mutex);
locked = 0;
}
#endif
}

如果当前freechunk属于fast bins,查看下一个相邻的chunk的大小是否小于等于2*SIZE_SZ,下一个相邻chunk的大小是否大于分配区所分配的内存总量,如果是,报错。这里计算下一个相邻chunk的大小似乎有点问题,因为chunksize字段中包含了一些标志位,正常情况下下一个相邻chunksize中的PREV_INUSE标志位会置位,但这里就是要检出错的情况,也就是下一个相邻chunksize中标志位都没有置位,并且该chunk大小为2*SIZE_SZ的错误情况。如果开启了ATOMIC_FASTBINS优化,并且调用本函数前没有对分配区加锁,所以读取分配区所分配的内存总量需要对分配区加锁,检查完以后,释放分配区的锁。

1
2
3
4
5
if (__builtin_expect (perturb_byte, 0))
free_perturb (chunk2mem(p), size - SIZE_SZ);
set_fastchunks(av);
unsigned int idx = fastbin_index(size);
fb = &fastbin (av, idx);

设置当前分配区的fast bin flag,表示当前分配区的fast bins中已有空闲chunk。然后根据当前freechunk大小获取所属的fast bin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#ifdef ATOMIC_FASTBINS
mchunkptr fd;
mchunkptr old = *fb;
unsigned int old_idx = ~0u;
do
{
/* Another simple check: make sure the top of the bin is not the
record we are going to add (i.e., double free). */
if (__builtin_expect (old == p, 0))
{
errstr = "double free or corruption (fasttop)";
goto errout;
}
if (old != NULL)
old_idx = fastbin_index(chunksize(old));
p->fd = fd = old;
}
while ((old = catomic_compare_and_exchange_val_rel (fb, p, fd)) != fd);
if (fd != NULL && __builtin_expect (old_idx != idx, 0))
{
errstr = "invalid fastbin entry (free)";
goto errout;
}

如果开启了ATOMIC_FASTBINS优化,使用lock-free技术实现fast bin的单向链表插入操作。这里也没有ABA问题,比如当前线程获取*fb并保存到old中,在调用cas原子操作前,b线程将*fb修改为x,如果B线程加入了新的chunk,则x->fb指向old,如果B线程删除了old,则xold->fb。如果C线程将*fb修改为old,则可能将B线程加入的chunk x删除,或者CB删除的old又重新加入。这两种情况,都不会导致链表出错,所以不会有ABA问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#else
/* Another simple check: make sure the top of the bin is not the
record we are going to add (i.e., double free). */
if (__builtin_expect (*fb == p, 0))
{
errstr = "double free or corruption (fasttop)";
goto errout;
}
if (*fb != NULL
&& __builtin_expect (fastbin_index(chunksize(*fb)) != idx, 0))
{
errstr = "invalid fastbin entry (free)";
goto errout;
}
p->fd = *fb;
*fb = p;

如果没有开启了ATOMIC_FASTBINS优化,将freechunk加入fast bin的单向链表中,修改过链表表头为当前freechunk。同时需要校验是否为double free错误,校验表头不为NULL情况下,保证表头chunk的所属的fast bin与当前freechunk所属的fast bin相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#endif
}
/*
Consolidate other non-mmapped chunks as they arrive.
*/
else if (!chunk_is_mmapped(p)) {
#ifdef ATOMIC_FASTBINS122
if (! have_lock) {
#if THREAD_STATS
if(!mutex_trylock(&av->mutex))
++(av->stat_lock_direct);
else {
(void)mutex_lock(&av->mutex);
++(av->stat_lock_wait);
}
#else
(void)mutex_lock(&av->mutex);
#endif
locked = 1;
}
#endif

如果当前freechunk不是通过mmap()分配的,并且当前还没有获得分配区的锁,获取分配区的锁。

1
nextchunk = chunk_at_offset(p, size);

获取当前freechunk的下一个相邻的chunk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Lightweight tests: check whether the block is already the
top block. */
if (__builtin_expect (p == av->top, 0))
{
errstr = "double free or corruption (top)";
goto errout;
}
/* Or whether the next chunk is beyond the boundaries of the arena. */
if (__builtin_expect (contiguous (av)
&& (char *) nextchunk >= ((char *) av->top + chunksize(av->top)), 0))
{
errstr = "double free or corruption (out)";
goto errout;
}
/* Or whether the block is actually not marked used. */
if (__builtin_expect (!prev_inuse(nextchunk), 0))
{
errstr = "double free or corruption (!prev)";
goto errout;
}

安全检查,当前freechunk不能为top chunk,因为top chunk为空闲chunk,如果再次free就可能为double free错误了。如果当前freechunk是通过sbrk()分配的,并且下一个相邻的chunk的地址已经超过了top chunk的结束地址,超过了当前分配区的结束地址,报错。如果当前freechunk的下一个相邻chunksize中标志位没有标识当前free chunkinuse状态,可能为double free错误。

1
2
3
4
5
6
7
8
9
nextsize = chunksize(nextchunk);
if (__builtin_expect (nextchunk->size <= 2 * SIZE_SZ, 0)
|| __builtin_expect (nextsize >= av->system_mem, 0))
{
errstr = "free(): invalid next size (normal)";
goto errout;
}
if (__builtin_expect (perturb_byte, 0))
free_perturb (chunk2mem(p), size - SIZE_SZ);

计算当前freechunk的下一个相邻chunk的大小,该大小如果小于等于2*SIZE_SZ或是大于了分配区所分配区的内存总量,报错。

1
2
3
4
5
6
7
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = p->prev_size;
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(p, bck, fwd);
}

如果当前freechunk的前一个相邻chunk为空闲状态,与前一个空闲chunk合并。计算合并后的chunk大小,并将前一个相邻空闲chunk从空闲chunk链表中删除。

1
2
3
if (nextchunk != av->top) {
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);

如果与当前freechunk相邻的下一个chunk不是分配区的top chunk,查看与当前chunk相邻的下一个chunk是否处于inuse状态。

1
2
3
4
5
6
/* consolidate forward */
if (!nextinuse) {
unlink(nextchunk, bck, fwd);
size += nextsize;
} else
clear_inuse_bit_at_offset(nextchunk, 0);

如果与当前freechunk相邻的下一个chunk处于inuse状态,清除当前chunkinuse状态,则当前chunk空闲了。否则,将相邻的下一个空闲chunk从空闲链表中删除,并计算当前chunk与下一个chunk合并后的chunk大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
Place the chunk in unsorted chunk list. Chunks are124
not placed into regular bins until after they have
been given one chance to be used in malloc.
*/
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__builtin_expect (fwd->bk != bck, 0))
{
errstr = "free(): corrupted unsorted chunks";
goto errout;
}
p->fd = fwd;
p->bk = bck;
if (!in_smallbin_range(size))
{
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}
bck->fd = p;
fwd->bk = p;

将合并后的chunk加入unsorted bin的双向循环链表中。如果合并后的chunk属于large bins,将chunkfd_nextsizebk_nextsize设置为NULL,因为在unsorted bin中这两个字段无用。

1
2
set_head(p, size | PREV_INUSE);
set_foot(p, size);

设置合并后的空闲chunk大小,并标识前一个chunk处于inuse状态,因为必须保证不能有两个相邻的chunk都处于空闲状态。然后将合并后的chunk加入unsorted bin的双向循环链表中。最后设置合并后的空闲chunkfootchunk空闲时必须设置foot,该foot处于下一个chunkprev_size中,只有chunk空闲是foot才是有效的。

1
2
3
4
5
6
7
8
9
10
11
12
    check_free_chunk(av, p);
}
/*
If the chunk borders the current high end of memory,
consolidate into top
*/
else {
size += nextsize;
set_head(p, size | PREV_INUSE);
av->top = p;
check_chunk(av, p);
}

如果当前freechunk下一个相邻的chunktop chunk,则将当前chunk合并入top chunk,修改top chunk的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
If freeing a large space, consolidate possibly-surrounding
chunks. Then, if the total unused topmost memory exceeds trim
threshold, ask malloc_trim to reduce top.
Unless max_fast is 0, we don't know if there are fastbins
bordering top, so we cannot tell for sure whether threshold
has been reached unless fastbins are consolidated. But we
don't want to consolidate on each free. As a compromise,
consolidation is performed if FASTBIN_CONSOLIDATION_THRESHOLD
is reached.
*/
if ((unsigned long)(size) >= FASTBIN_CONSOLIDATION_THRESHOLD) {
if (have_fastchunks(av))
malloc_consolidate(av);

如果合并后的chunk大小大于64KB,并且fast bins中存在空闲chunk,调用malloc_consolidate()函数合并fast bins中的空闲chunkunsorted bin中。

1
2
3
4
5
            if (av == &main_arena) {
#ifndef MORECORE_CANNOT_TRIM
if ((unsigned long)(chunksize(av->top)) >=
(unsigned long)(mp_.trim_threshold))
sYSTRIm(mp_.top_pad, av);

如果当前分配区为主分配区,并且top chunk的大小大于heap的收缩阈值,调用sYSTRIm()函数首先heap

1
2
3
4
5
6
7
#endif
} else {
/* Always try heap_trim(), even if the top chunk is not
large, because the corresponding heap might go away. */
heap_info *heap = heap_for_ptr(top(av));
assert(heap->ar_ptr == av);
heap_trim(heap, mp_.top_pad);

如果为非主分配区,调用heap_trim()函数收缩非主分配区的sub_heap`。

1
2
3
4
5
6
7
8
            }
}
#ifdef ATOMIC_FASTBINS
if (! have_lock) {
assert (locked);
(void)mutex_unlock(&av->mutex);
}
#endif

如果开启了ATOMIC_FASTBINS优化并获得分配区的锁,则对分配区解锁。

1
2
3
4
5
6
7
8
9
10
11
    }
/*
If the chunk was allocated via mmap, release via munmap(). Note
that if HAVE_MMAP is false but chunk_is_mmapped is true, then
user must have overwritten memory. There's nothing we can do to
catch this error unless MALLOC_DEBUG is set, in which case
check_inuse_chunk (above) will have triggered error.
*/
else {
#if HAVE_MMAP
munmap_chunk (p);

如果当前freechunk是通过mmap()分配的,调用munma_chunk()释放内存。

1
2
3
#endif
}
}

sYSTRIm()和munmap_chunk()

sYSTRIm()函数源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
sYSTRIm is an inverse of sorts to sYSMALLOc. It gives memory back
to the system (via negative arguments to sbrk) if there is unused
memory at the `high' end of the malloc pool. It is called
automatically by free() when top space exceeds the trim
threshold. It is also called by the public malloc_trim routine. It
returns 1 if it actually released any memory, else 0.
*/
#if __STD_C
static int sYSTRIm(size_t pad, mstate av)
#else
static int sYSTRIm(pad, av) size_t pad; mstate av;
#endif
{
long top_size; /* Amount of top-most memory */
long extra; /* Amount to release */
long released; /* Amount actually released */
char* current_brk; /* address returned by pre-check sbrk call */
char* new_brk; /* address returned by post-check sbrk call */
size_t pagesz;
pagesz = mp_.pagesize;
top_size = chunksize(av->top);

获取页大小和top chunk的大小。

1
2
/* Release in pagesize units, keeping at least one page */
extra = ((top_size - pad - MINSIZE + (pagesz-1)) / pagesz - 1) * pagesz;

计算top chunk中最大可释放的整数页大小,top chunk中至少需要MINSIZE的内存保存fencepost

1
2
3
4
5
6
7
if (extra > 0) {
/*
Only proceed if end of memory is where we last set it.
This avoids problems if there were foreign sbrk calls.
*/
current_brk = (char*)(MORECORE(0));
if (current_brk == (char*)(av->top) + top_size) {

获取当前brk值,如果当前top chunk的结束地址与当前的brk值相等,执行heap收缩。

1
2
3
4
5
6
7
8
9
10
/*
Attempt to release memory. We ignore MORECORE return value,
and instead call again to find out where new end of memory is.
This avoids problems if first call releases less than we asked,
of if failure somehow altered brk value. (We could still
encounter problems if it altered brk in some very bad way,
but the only thing we can do is adjust anyway, which will cause
some downstream failure.)
*/
MORECORE(-extra);

调用sbrk()释放指定大小的内存到heap中。

1
2
3
4
5
/* Call the `morecore' hook if necessary. */
void (*hook) (void) = force_reg (__after_morecore_hook);
if (__builtin_expect (hook != NULL, 0))
(*hook) ();
new_brk = (char*)(MORECORE(0));

如果morecore hook存在,执行hook函数,然后获得当前新的brk值。

1
2
3
4
5
6
7
8
if (new_brk != (char*)MORECORE_FAILURE) {
released = (long)(current_brk - new_brk);
if (released != 0) {
/* Success. Adjust top. */
av->system_mem -= released;
set_head(av->top, (top_size - released) | PREV_INUSE);
check_malloc_state(av);
return 1;

如果获取新的brk值成功,计算释放的内存大小,更新当前分配区所分配的内存总量,更新top chunk的大小。

1
2
3
4
5
6
                }
}
}
}
return 0;
}

unmap_chunk()函数源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static void
internal_function
#if __STD_C
munmap_chunk(mchunkptr p)
#else
munmap_chunk(p) mchunkptr p;
#endif
{
INTERNAL_SIZE_T size = chunksize(p);
assert (chunk_is_mmapped(p));
#if 0
assert(! ((char*)p >= mp_.sbrk_base && (char*)p < mp_.sbrk_base + mp_.sbrked_mem));
assert((mp_.n_mmaps > 0));
#endif
uintptr_t block = (uintptr_t) p - p->prev_size;
size_t total_size = p->prev_size + size;
/* Unfortunately we have to do the compilers job by hand here. Normally
we would test BLOCK and TOTAL-SIZE separately for compliance with the
page size. But gcc does not recognize the optimization possibility
(in the moment at least) so we combine the two values into one before
the bit test. */
if (__builtin_expect (((block | total_size) & (mp_.pagesize - 1)) != 0, 0))
{
malloc_printerr (check_action, "munmap_chunk(): invalid pointer", chunk2mem (p));
return;
}
mp_.n_mmaps--;
mp_.mmapped_mem -= total_size;129
int ret __attribute__ ((unused)) = munmap((char *)block, total_size);
/* munmap returns non-zero on failure */
assert(ret == 0);
}

munmap_chunk()函数实现相当简单,首先获取当前freechunk的大小,断言当前freechunk是通过mmap()分配的,由于使用mmap()分配的chunkprev_size中记录的前一个相邻空闲chunk的大小,mmap()分配的内存是页对齐的,所以一般情况下prev_size为0。

然后计算当前freechunk占用的总内存大小total_size,再次校验内存块的起始地址是否是对齐的,更新分配区的mmap统计信息,最后调用munmap()函数释放chunk的内存。

进程间通信

管道

所谓管道,是指用于连接一个读进程和一个写进程,以实现它们之间通信的共享文件,又称pipe文件。向管道(共享文件)提供输入的发送进程(即写进程),以字符流形式将大量的数据送入管道;而接收管道输出的接收进程(即读进程),可从管道中接收数据。由于发送进程和接收进程是利用管道进行通信的,故又称管道通信。这种方式首创于UNIX系统,因它能传送大量的数据,且很有效,故很多操作系统都引入了这种通信方式,Linux也不例外。

为了协调双方的通信,管道通信机制必须提供以下 3 方面的协调能力。

  • 互斥。当一个进程正在对pipe进行读/写操作时,另一个进程必须等待。
  • 同步。当写(输入)进程把一定数量(如 4KB)数据写入pipe后,便去睡眠等待,直到读(输出)进程取走数据后,再把它唤醒。当读进程读到一空pipe时,也应睡眠等待,直至写进程将数据写入管道后,才将它唤醒。
  • 对方是否存在。只有确定对方已存在时,才能进行通信。

Linux管道的实现机制

从本质上说,管道也是一种文件,但它又和一般的文件有所不同,管道可以克服使用文件进行通信的两个问题,具体表现如下所述。

  • 限制管道的大小。实际上,管道是一个固定大小的缓冲区。在Linux中,该缓冲区的大小为 1 页,即 4KB,使得它的大小不像文件那样不加检验地增长。使用单个固定缓冲区也会带来问题,比如在写管道时可能变满,当这种情况发生时,随后对管道的write()调用将默认地被阻塞,等待某些数据被读取,以便腾出足够的空间供write()调用写。
  • 读取进程也可能工作得比写进程快。当所有当前进程数据已被读取时,管道变空。当这种情况发生时,一个随后的read()调用将默认地被阻塞,等待某些数据被写入,这解决了read()调用返回文件结束的问题。

注意,从管道读数据是一次性操作,数据一旦被读,它就从管道中被抛弃,释放空间以便写更多的数据。

管道的结构

Linux中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。通过将两个file结构指向同一个临时的VFS索引节点,而这个VFS索引节点又指向一个物理页面而实现的。如图 7.1 所示。

两个file数据结构定义文件操作例程地址是不同的,其中一个是向管道中写入数据的例程地址,而另一个是从管道中读出数据的例程地址。

管道的读写

管道实现的源代码在fs/pipe.c中,在pipe.c中有很多函数,其中有两个函数比较重要,即管道读函数pipe_read()和管道写函数pipe_wrtie()。管道写函数通过将字节复制到VFS索引节点指向的物理内存而写入数据,而管道读函数则通过复制物理内存中的字节而读出数据。

当写进程向管道中写入时,它利用标准的库函数write(),系统根据库函数传递的文件描述符,可找到该文件的file结构。file结构中指定了用来进行写操作的函数(即写入函数)地址,于是,内核调用该函数完成写操作。写入函数在向内存中写入数据之前,必须首先检查VFS索引节点中的信息,同时满足如下条件时,才能进行实际的内存复制工作:

  • 内存中有足够的空间可容纳所有要写入的数据;
  • 内存没有被读程序锁定。

如果同时满足上述条件,写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。否则,写入进程就休眠在VFS索引节点的等待队列中,接下来,内核将调用调度程序,而调度程序会选择其他进程运行。写入进程实际处于可中断的等待状态,当内存中有足够的空间可以容纳写入数据,或内存被解锁时,读取进程会唤醒写入进程,这时,写入进程将接收到信号。当数据写入内存之后,内存被解锁,而所有休眠在索引节点的读取进程会被唤醒。

管道的读取过程和写入过程类似。但是,进程可以在没有数据或内存被锁定时立即返回错误信息,而不是阻塞该进程,这依赖于文件或管道的打开模式。反之,进程可以休眠在索引节点的等待队列中等待写入进程写入数据。当所有的进程完成了管道操作之后,管道的索引节点被丢弃,而共享数据页也被释放。

管道的应用

管道是利用pipe()系统调用而不是利用open()系统调用建立的。pipe()调用的原型是:

1
int pipe(int fd[2])

我们看到,有两个文件描述符与管道结合在一起,一个文件描述符用于管道的read()端,一个文件描述符用于管道的write()端。由于一个函数调用不能返回两个值,pipe()的参数是指向两个元素的整型数组的指针,它将由调用两个所要求的文件描述符填入。

fd[0]元素将含有管道read()端的文件描述符,而fd[1]含有管道write()端的文件描述符。系统可根据fd[0]fd[1]分别找到对应的file结构。

注意,在pipe的参数中,没有路径名,这表明,创建管道并不像创建文件一样,要为它创建一个目录连接。这样做的好处是,其他现存的进程无法得到该管道的文件描述符,从而不能访问它。那么,两个进程如何使用一个管道来通信呢?

我们知道,fork()exec()系统调用可以保证文件描述符的复制品既可供双亲进程使用,也可供它的子女进程使用。也就是说,一个进程用pipe()系统调用创建管道,然后用fork()调用创建一个或多个进程,那么,管道的文件描述符将可供所有这些进程使用。

这里更明确的含义是:一个普通的管道仅可供具有共同祖先的两个进程之间共享,并且这个祖先必须已经建立了供它们使用的管道。注意,在管道中的数据始终以和写数据相同的次序来进行读,这表示lseek()系统调用
对管道不起作用。

命名管道CFIFOC

Linux还支持另外一种管道形式,称为命名管道,或FIFO,这是因为这种管道的操作方式基于“先进先出”原理。上面讲述的管道类型也被称为“匿名管道”。命名管道中,首先写入管道的数据是首先被读出的数据。匿名管道是临时对象,而FIFO则是文件系统的真正实体,如果进程有足够的权限就可以使用FIFOFIFO和匿名管道的数据结构以及操作极其类似,二者的主要区别在于,FIFO在使用之前就已经存在,用户可打开或关闭FIFO;而匿名管道只在操作时存在,因而是临时对象。

为了创建先进先出文件,可以从shell提示符使用mknod命令或可以在程序中使用mknod()系统调用。

mknod()系统调用的原型为:

1
2
3
4
5
#include <sys/type.h>
#inlcude <sys/state.h>
#include <fcntl.h>
#include <unistd.h>
int mknod(char *pathname,node_t mode, dev_t dev);

其中pathname是被创建的文件名称,mode表示将在该文件上设置的权限位和将被创建的文件类型(在此情况下为S_IFIFO),dev是当创建设备特殊文件时使用的一个值。因此,对于先进先出文件它的值为 0。

一旦先进先出文件已经被创建,它可以由任何具有适当权限的进程利用标准的open()系统调用加以访问。当用open()调用打开时,一个先进先出文件和一个匿名管道具有同样的基本功能。即当管道是空的时候,read()调用被阻塞。当管道是满的时候,write()等待被阻塞,并且当用fcntl()设置O_NONBLOCK标志时,将引起read()调用和write()调用立即返回。

在它们已被阻塞的情况下,带有一个EAGAIN错误信息。由于命名管道可以被很多无关系的进程同时访问,那么,在有多个读进程和/或多个写进程的应用中使用FIFO是非常有用的。

多个进程写一个管道会出现这样的问题,即多个进程所写的数据混在一起怎么办?幸好系统有这样的规则:一个write()调用可以写管道能容纳(Linux为 4KB)的任意个字节,系统将保证这些数据是分开的。这表示多个写操作的数据在FIFO文件中并不混合而将被维持分离的信息。

信号(signal)

信号种类

每一种信号都给予一个符号名。Linux定义了i386的 32 个信号,在include/asm/signal.h中定义。表给出常用的符号名、描述和它们的信号值。

符号名 描述 信号值
SIGHUP 在控制终端上发生的结束信号 1
SIGINT 中断,用户键入CTRL–C时发送 2
SIGQUIT 从键盘来的中断(ctrl_c)信号 3
SIGILL 非法指令 4
SIGTRAP 跟踪陷入 5
SIGABRT 非正常结束,程序调用abort时发送 6
SIGIOT IOT指令 6
SIGBUS 总线超时 7
SIGFPE 浮点异常 8
SIGKILL 杀死进程(不能被捕或忽略) 9
SIGUSR1 用户定义信号#1 10
SIGSEGV 段违法 11
SIGUSR2 用户定义信号#2 12
SIGPIPE 向无人读到的管道写 13
SIGALRM 定时器告警,时间到 14
SIGTERM Kill发出的软件结束信号 15
SIGCHLD 子程序结束或停止 17
SIGCONT 如果已停止则续继 18
SIGSTOP 停止信号 19
SIGTSTP 交互停止信号 20
SIGTTIN 后台进程想读 21
SIGTTOU 后台进程想写 22
SIGPWR 电源失效 30

每种信号类型都有对应的信号处理程序(也叫信号的操作),就好像每个中断都有一个中断服务例程一样。大多数信号的默认操作是结束接收信号的进程。然而,一个进程通常可以请求系统采取某些代替的操作,各种代替操作如下所述。

  1. 忽略信号。随着这一选项的设置,进程将忽略信号的出现。有两个信号不可以被忽略:SIGKILL,它将结束进程;SIGSTOP,它是作业控制机制的一部分,将挂起作业的执行。
  2. 恢复信号的默认操作。
  3. 执行一个预先安排的信号处理函数。进程可以登记特殊的信号处理函数。当进程收到信号时,信号处理函数将像中断服务例程一样被调用,当从该信号处理函数返回时,控制被返回给主程序,并且继续正常执行。

但是,信号和中断有所不同。中断的响应和处理都发生在内核空间,而信号的响应发生在内核空间,信号处理程序的执行却发生在用户空间。那么,什么时候检测和响应信号呢?通常发生在以下两种情况下:

  1. 当前进程由于系统调用、中断或异常而进入内核空间以后,从内核空间返回到用户空间前夕;
  2. 当前进程在内核中进入睡眠以后刚被唤醒的时候,由于检测到信号的存在而提前返回到用户空间。

当有信号要响应时,当前进程在用户态执行的过程中,陷入系统调用或中断服务例程,于是,当前进程从用户态切换到内核态;当处理完系统调用要返回到用户态前夕,发现有信号处理程序需要执行,于是,又从内核态切换到用户态;当执行完信号处理程序后,并不是接着就在用户态执行应用程序,而是还要返回到内核态。为什么还要返回到内核态呢?这是因为此时还没有真正从系统调用返回到用户态,于是从信号处理程序返回到内核态就是为了处理从系统调用到用户态的返回。

信号掩码

POSIX下,每个进程有一个信号掩码(Signal Mask)。简单地说,信号掩码是一个“位图”,其中每一位都对应着一种信号。如果位图中的某一位为 1,就表示在执行当前信号的处理程序期间相应的信号暂时被“屏蔽”,使得在执行的过程中不会嵌套地响应那种信号。

当一个程序正在运行时,在键盘上按一下CTRL+C,内核就会向相应的进程发出一个SIGINT信号,而对这个信号的默认操作就是通过do_exit()结束该进程的运行。在实践中却发现,两次CTRL+C事件往往过于密集,有时候刚刚进入第 1 个信号的处理程序,第 2 个SIGINT信号就到达了,而第 2 个信号的默认操作是杀死进程,这样,第 1 个信号的处理程序根本没有执行完。为了避免这种情况的出现,就在执行一个信号处理程序的过程中将该种信号自动屏蔽掉。所谓“屏蔽”,与将信号忽略是不同的,它只是将信号暂时“遮盖”一下,一旦屏蔽去掉,已到达的信号又继续得到处理。

Linux内核中有一个专门的函数集合来执行设置和修改信号掩码,它们放在kernel/signal.c中,其函数形式和功能如下:

函数形式 功能
int sigemptyset(sigset_t *mask) 清所有信号掩码的阻塞标志
int sigfillset(sigset_t *mask, int signum) 设置所有信号掩码的阻塞标志
int sigdelset(sigset_t *mask, int signum) 删除个别信号阻塞
int sigaddset(sigset_t *mask, int signum) 增加个别信号阻塞
int sigisnumber(sigset_t *mask, int signum) 确定特定的信号是否在掩码中被标志为阻塞

另外,进程也可以利用sigprocmask()系统调用改变和检查自己的信号掩码的值,其实现代码在kernel/signal.c中,原型为:

1
int sys_sigprocmask(int how, sigset_t *set, sigset_t *oset)

其中,set是指向信号掩码的指针,进程的信号掩码是根据参数how的取值设置成set。参数how的取值及含义如下:

  • SIG_BOLCKset规定附加的阻塞信号
  • SIG_UNBOCKset规定一组不予阻塞的信号
  • SIG_SETBLOCKset变成新进程的信号掩码

用一段代码来说明这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
switch (how) {
case SIG_BLOCK:
current->blocked |= new_set;
break;
case SIG_UNBLOCK:
current->blocked &= ~new_set;
break;
case SIG_SETMASK:
current->blocked = new_set;
break;
default:
return -EINVAL;
}

其中current为指向当前进程task_struc结构的指针。第 3 个参数oset也是指向信号掩码的指针,它将包含以前的信号掩码值,使得在必要的时候,可以恢复它。

进程可以用sigpending()系统调用来检查是否有挂起的阻塞信号。

系统调用

除了signal()系统调用,Linux还提供关于信号的系统调用如下:

调用原型 功能
int sigaction(sig,&handler,&oldhandler) 定义对信号的处理操作
int sigreturn(&context) 从信号返回
int sigprocmask(int how, sigset_t *mask, sigset_t *old) 检查或修改信号屏蔽
int sigpending(sigset_t mask) 替换信号掩码并使进程挂起
int kill(pid_t pid, int sig) 发送信号到进程
long alarm(long secs) 设置事件闹钟
int pause(void) 将调用进程挂起直到下一个进程

其中sigset_t定义为:

1
typedef unsigned long sigset_t; /* 至少 32 位*/

下面介绍几个典型的系统调用。

kill系统调用

从前面的叙述可以看到,一个进程接收到的信号,或者是由异常的错误产生(如浮点异常),或者是用户在键盘上用中断和退出信号干涉而产生,那么,一个进程能否给另一个进程发送信号?回答是肯定的,但发送者进程必须有适当的权限。Kill()系统调用可以完成此任务:

1
int kill(pid_t pid, int sig)

参数sig规定发送哪一个信号,参数pid(进程标识号)规定把信号发送到何处,pid各种不同值具有下列意义:

  • pid>0:信号sig发送给进程标识号为pid的进程;
  • pid=0:设调用kill()的进程其组标识号为p,则把信号sig发送给与p相等的其他所有进程;
  • pid=-1Linux规定把信号sig发送给系统中除去init进程和调用者以外的所有进程;
  • pid<-1:信号发送给进程组-pid中的所有进程。

为了用kill()发送信号,调用进程的有效用户ID必须是root,或者必须和接收进程的实际或有效用户ID相同。

pause()和alarm()系统调用

当一个进程需要等待另一个进程完成某项操作时,它将执行pause()调用,当这项操作已完成时,另一个进程可以发送一个预约的信号给这一暂停的进程,它将强迫pause()返回,并且允许收到信号的进程恢复执行,知道它正在等待的事件现在已经出现。

对于许多实际应用,需要在一段指定时间后,中断进程的原有操作,以进行某种其他的处理,系统提供了alarm()系统调用。每个进程都有一个闹钟计时器与之相联,在经过预先设置的时间后,进程可以用它来给自己发送SIGALARM信号。alarm()调用只取一个参数secs,它是在闹钟关闭之前所经过的秒数。如果传递一个 0 值给alarm(),这将关闭任何当前正在运行的闹钟计时器。

alarm()返回值是以前的闹钟计时器值,如果当前没有设置任何闹钟计时器,这将是零,或者是当作出该调用时,闹钟的剩余时间。

典型系统调用的实现

sigaction()系统调用的实现较具代表性,它的主要功能为设置信号处理程序,其原型为:

1
2
int sys_sigaction(int signum, const struct sigaction * action,
struct sigaction * oldaction)

其中,sigaction数据结构在include/asm/signal.h中定义,其格式为:

1
2
3
4
5
6
struct sigaction {
__sighandler_t sa_handler;
sigset_t sa_mask;
unsigned long sa_flags;
void (*sa_restorer)(void);
};

其中__sighandler_t定义为:

1
typedef void (*__sighandler_t) (int);

在这个结构中,sa_handler为指向处理函数的指针,sa_mask是信号掩码,当该信号signum出现时,这个掩码就被逻辑或到接收进程的信号掩码中。当信号处理程序执行时,这个掩码保持有效。sa_flags域是几个位标志的逻辑或(OR)组合,其中两个主要的标志是:

  • SA_ONESHOT信号出现时,将信号操作置为默认操作;
  • SA_NOMASK忽略sigaction结构的sa_mask域。

Linux中定义的信号处理的 3 种类型为:

1
2
3
#define SIG_DFL ((__sighandler_t)0) /* 缺省的信号处理*/
#define SIG_IGN ((__sighandler_t)1) /*忽略这个信号 */
#define SIG_ERR ((__sighandler_t)-1) /*从信号返回错误 */

下面是sigaction()系统调用在内核中实现的代码及解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
int sys_sigaction(int signum, const struct sigaction * action,
struct sigaction * oldaction)
{
struct sigaction new_sa, *p;
if (signum<1 || signum>32)
return -EINVAL;
/* 信号的值不在 1~32 之间,则出错 */
if (signum==SIGKILL || signum==SIGSTOP)
return -EINVAL;
/* SIGKILL和SIGSTOP不能设置信号处理程序 */
p = signum - 1 + current->sig->action;
/*在当前进程中,指向信号`signum`的`action`的指针 */
if (action) {
int err = verify_area(VERIFY_READ, action, sizeof(*action));
/* 验证给action在用户空间分配的地址的有效性 */
if (err)
return err;
memcpy_fromfs(&new_sa, action, sizeof(struct sigaction));
/* 把actoin的内容从用户空间拷贝到内核空间*/
new_sa.sa_mask |= _S(signum);
/* 把信号signum加到掩码中 */
if (new_sa.sa_flags & SA_NOMASK)
new_sa.sa_mask &= ~_S(signum);
/* 如果标志为SA_NOMASK,当信号signum出现时,将它的操作置为默认操作 */
new_sa.sa_mask &= _BLOCKABLE;
/* 不能阻塞`SIGKILL`和`SIGSTOP */
if (new_sa.sa_handler != SIG_DFL && new_sa.sa_handler !=SIG_IGN) {
err = verify_area(VERIFY_READ, new_sa.sa_handler, 1);
/* 当处理程序不是信号默认的处理操作,并且`signum`信号不能被忽略时,验证给信号处理程序分配空间的有效性 */
if (err)
return err;
}
}
if (oldaction) {
int err = verify_area(VERIFY_WRITE, oldaction, sizeof(*oldaction));
if (err)
return err;
memcpy_tofs(oldaction, p, sizeof(struct sigaction));
/* 恢复原来的信号处理程序 */
}
if (action) {
*p = new_sa;
check_pending(signum);
}
return 0;
}

Linux可以将各种信号发送给程序,以表示程序故障、用户请求的中断、其他各种情况等。通过对sigaction()系统调用源代码的分析,有助于灵活应用信号的系统调用。

进程与信号的关系

系统在task_struct结构中利用两个域分别记录当前挂起的信号(Signal)以及当前阻塞的信号(Blocked)。挂起的信号指尚未进行处理的信号。阻塞的信号指进程当前不处理的信号,如果产生了某个当前被阻塞的信号,则该信号会一直保持挂起,直到该信号不再被阻塞为止。除了SIGKILLSIGSTOP信号外,所有的信号均可以被阻塞,信号的阻塞可通过系统调用sigprocmask()实现。每个进程的task_struct结构中还包含了一个指向sigaction结构数组的指针,该结构数组中的信息实际指定了进程处理所有信号的方式。

如果某个sigaction结构中包含有处理信号的例程地址,则由该处理例程处理该信号;反之,则根据结构中的一个标志或者由内核进行默认处理,或者只是忽略该信号。通过系统调用sigaction(),进程可以修改sigaction结构数组的信息,从而指定进程处理信号的方式。

进程不能向系统中所有的进程发送信号,一般而言,除系统和超级用户外,普通进程只能向具有相同uidgid的进程,或者处于同一进程组的进程发送信号。当有信号产生时,内核将进程task_structsignal字中的相应位设置为 1。系统不对置位之前该位已经为1 的情况进行处理,因而进程无法接收到前一次信号。如果进程当前没有阻塞该信号,并且进程正处于可中断的等待状态(INTERRUPTIBLE),则内核将该进程的状态改变为运行(RUNNING),并放置在运行队列中。这样,调度程序在进行调度时,就有可能选择该进程运行,从而可以让进程处理该信号。

发送给某个进程的信号并不会立即得到处理,相反,只有该进程再次运行时,才有机会处理该信号。每次进程从系统调用中退出时,内核会检查它的signalblock字段,如果有任何一个未被阻塞的信号发出,内核就根据sigaction结构数组中的信息进行处理。处理过程如下。

  1. 检查对应的sigaction结构,如果该信号不是SIGKILLSIGSTOP信号,且被忽略,则不处理该信号。
  2. 如果该信号利用默认的处理程序处理,则由内核处理该信号,否则转向第(3)步。
  3. 该信号由进程自己的处理程序处理,内核将修改当前进程的调用堆栈,并将进程的程序计数寄存器修改为信号处理程序的入口地址。此后,指令将跳转到信号处理程序,当从信号处理程序中返回时,实际就返回了进程的用户模式部分。

Linux是与POSIX兼容的,因此,进程在处理某个信号时,还可以修改进程的blocked掩码。但是,当信号处理程序返回时,blocked值必须恢复为原有的掩码值,这一任务由内核的sigaction()函数完成。

Linux在进程的调用堆栈帧中添加了对清理程序的调用,该清理程序可以恢复原有的blocked掩码值。当内核在处理信号时,可能同时有多个信号需要由用户处理程序处理,这时,Linux内核可以将所有的信号处理程序地址推入堆栈中,而当所有的信号处理完毕后,调用清理程序恢复原先的blocked值。

信号举例

下面通过Linux提供的系统调用signal(),来说明如何执行一个预先安排好的信号处理函数。signal()调用的原型是:

1
2
3
#include <signal.h>
#include <unistd.h>
void (* signal(int signum, void(*handler)(int)))(int);

signal()的返回值是指向一个函数的指针,该函数的参数为一个整数,无返回值,下面是用户级程序的一段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int ctrl_c_count=0;
void (* old_handler)(INT);
void ctrl_c(int);
main()
{
int c;

old_handler = signal(SIGINT,ctrl_c);
while ((c=getchar())! = '\n');
printf("ctrl-c count = %d\n",ctrl_c_count);
(void) signal(SIGINT,old_handler);
}

void ctrl_c(int signum)
{
(void)signal(SIGINT,ctrl_c)
++ctrl_c;
}

程序说明:这个程序是从键盘获得字符,直到换行符为止,然后进入无限循环。这里,程序安排了捕获ctrl_c信号(SIGINT),并且利用SIGINT来执行一个ctrl_c的处理函数。当在键盘上敲入一个换行符时,SIGINT原来的操作(很可能是默认操作)才被恢复。main()函数中的第一个语句完成设置信号处理程序:

1
old_handler = signal(SIGINT,ctrl_c);

signal()的两个参数是:信号值,这里是键盘中断信号SIGINT,以及一个指向函数的指针,这里是ctrl_c,当这个中断信号出现时,将调用该函数。signal()调用返回旧的信号处理程序的地址,在此它被赋给变量older_handler,使得原来的信号处理程序稍后可以被恢复。

一旦信号处理程序放在应放的位置,进程收到任何中断(SIGINT)信号将引起信号处理函数的执行。这个函数增加ctrl_c_count变量的值以保持对SIGINT事件出现次数的计数。注意信号处理函数也执行另一个signal()调用,它重新建立SIGINT信号和ctrl_c函数之间的联系。这是必需的,因为当信号出现时,用signal()调用设置的信号处理程序被自动恢复为默认操作,使得随后的同一信号将只执行信号的默认操作。

System V的IPC机制

为了提供与其他系统的兼容性,Linux也支持 3 种system V的进程间通信机制:消息、信号量(semaphores)和共享内存,Linux对这些机制的实施大同小异。我们把信号量、消息和共享内存统称System V IPC的对象,每一个对象都具有同样类型的接口,即系统调用。

就像每个文件都有一个打开文件号一样,每个对象也都有唯一的识别号,进程可以通过系统调用传递的识别号来存取这些对象,与文件的存取一样,对这些对象的存取也要验证存取权限,System V IPC可以通过系统调用对对象的创建者设置这些对象的存取权限。

Linux内核中,System V IPC的所有对象有一个公共的数据结构pc_perm结构,它是IPC对象的权限描述,在linux/ipc.h中定义如下:

1
2
3
4
5
6
7
8
9
10
struct ipc_perm
{
key_t key; /* 键 */
ushort uid; /* 对象拥有者对应进程的有效用户识别号和有效组识别号 */
ushort gid;
ushort cuid; /* 对象创建者对应进程的有效用户识别号和有效组识别号 */
ushort cgid;
ushort mode; /* 存取模式 */
ushort seq; /* 序列号 */
};

在这个结构中,要进一步说明的是键(key)。键和识别号指的是不同的东西。系统支持两种键:公有和私有。如果键是公有的,则系统中所有的进程通过权限检查后,均可以找到System V IPC对象的识别号。如果键是公有的,则键值为 0,说明每个进程都可以用键值 0 建立一个专供其私用的对象。注意,对System V IPC对象的引用是通过识别号而不是通过键,从后面的系统调用中可了解这一点。

信号量

信号量(semaphore )实际是一个整数,它的值由多个进程进行测试(test)和设置(set)。就每个进程所关心的测试和设置操作而言,这两个操作是不可中断的,或称“原子”操作,即一旦开始直到两个操作全部完成。测试和设置操作的结果是:信号量的当前值和设置值相加,其和或者是正或者为负。根据测试和设置操作的结果,一个进程可能必须睡眠,直到有另一个进程改变信号量的值。

信号量作为资源计数器,它的初值可以是任何正整数,其初值不一定为 0 或 1。另外,如果一个进程要先获得两个或多个的共享资源后才能执行的话,那么,相应地也需要多个信号量,而多个进程要分别获得多个临界资源后方能运行,这就是信号量集合机制。

信号量的数据结构

Linux中信号量是通过内核提供的一系列数据结构实现的,这些数据结构存在于内核空间,对它们的分析是充分理解信号量及利用信号量实现进程间通信的基础,下面先给出信号量的数据结构(存在于include/linux/sem.h中),其他一些数据结构将在相关的系统调用中介绍。

(1)系统中每个信号量的数据结构(sem)

1
2
3
4
struct sem {
int semval; /* 信号量的当前值 */
int sempid; /*在信号量上最后一次操作的进程识别号 *
};

(2)系统中表示信号量集合(set)的数据结构(semid_ds)

1
2
3
4
5
6
7
8
9
10
struct semid_ds { 
struct ipc_perm sem_perm; /* IPC`权限 */
long sem_otime; /* 最后一次对信号量操作(semop)的时间 */
long sem_ctime; /* 对这个结构最后一次修改的时间 */
struct sem *sem_base; /* 在信号量数组中指向第一个信号量的指针 */
struct sem_queue *sem_pending; /* 待处理的挂起操作*/
struct sem_queue **sem_pending_last; /* 最后一个挂起操作 */
struct sem_undo *undo; /* 在这个数组上的`undo`请求 */
ushort sem_nsems; /* 在信号量数组上的信号量号 */
};

(3)系统中每一信号量集合的队列结构(sem_queue)

1
2
3
4
5
6
7
8
9
10
11
struct sem_queue {
struct sem_queue * next; /* 队列中下一个节点 */
struct sem_queue ** prev; /* 队列中前一个节点, *(q->prev) == q */
struct wait_queue * sleeper; /* 正在睡眠的进程 */
struct sem_undo * undo; /* undo`结构*/
int pid; /* 请求进程的进程识别号 */
int status; /* 操作的完成状态 */
struct semid_ds * sma; /*有操作的信号量集合数组 */
struct sembuf * sops; /* 挂起操作的数组 */
int nsops; /* 操作的个数 */
};

(4)几个主要数据结构之间的关系:

从图 7.3 可以看出,semid_ds结构的sem_base指向一个信号量数组,允许操作这些信号量集合的进程可以利用系统调用执行操作 。注意,信号量与信号量集合的区别,从上面可以看出,信号量用sem结构描述,而信号量集合用semid_ds结构描述。

系统调用:semget()

为了创建一个新的信号量集合,或者存取一个已存在的集合,要使用segget()系统调用,其描述如下:

1
int semget ( key_t key, int nsems, int semflg );

如果成功,则返回信号量集合的IPC识别号;如果为-1,则出现错误。

semget()中的第 1 个参数是键值,这个键值要与已有的键值进行比较,已有的键值指在内核中已存在的其他信号量集合的键值。对信号量集合的打开或存取操作依赖于semflg参数的取值。

  • IPC_CREAT:如果内核中没有新创建的信号量集合,则创建它。
  • IPC_EXCL:当与IPC_CREAT一起使用时,如果信号量集合已经存在,则创建失败。

如果IPC_CREAT单独使用,semget()为一个新创建的集合返回标识号,或者返回具有相同键值的已存在集合的标识号。如果IPC_EXCLIPC_CREAT一起使用,要么创建一个新的集合,要么对已存在的集合返回-1。IPC_EXCL单独是没有用的,当与IPC_CREAT结合起来使用时,可以保证新创建集合的打开和存取。

作为System V IPC的其他形式,一种可选项是把一个八进制与掩码或,形成信号量集合的存取权限。

第 3 个参数nsems指的是在新创建的集合中信号量的个数。其最大值在linux/sem.h中定义:

1
#define SEMMSL 250 /* <= 8 000 max num of semaphores per id */ 

注意,如果你是显式地打开一个现有的集合,则nsems参数可以忽略。

下面举例说明。

1
2
3
4
5
6
7
8
9
10
11
12
int open_semaphore_set( key_t keyval, int numsems )
{
int sid;
if ( ! numsems )
return(-1);

if((sid = semget( keyval, numsems, IPC_CREAT | 0660 )) == -1) {
return(-1);
}

return(sid);
}

注意,这个例子显式地用了 0660 权限。这个函数要么返回一个集合的标识号,要么返回-1 而出错。键值必须传递给它,信号量的个数也传递给它,这是因为如果创建成功则要分配空间。

系统调用: semop()

1
int semop ( int semid, struct sembuf *sops, unsigned nsops);

如果所有的操作都执行,则成功返回 0。如果为-1,则出错。

semop()中的第 1 个参数(semid)是集合的识别号(可以由semget()系统调用得到)。第 2 个参数(sops)是一个指针,它指向在集合上执行操作的数组。而第 3 个参数(nsops)是在那个数组上操作的个数。

sops参数指向类型为sembuf的一个数组,这个结构在/inclide/linux/sem.h中声明,是内核中的一个数据结构,描述如下:

1
2
3
4
5
struct sembuf {
ushort sem_num; /* 在数组中信号量的索引值 */
short sem_op; /* 信号量操作值(正数、负数或 0) */
short sem_flg; /* 操作标志,为IPC_NOWAIT或SEM_UNDO*/
};

  • 如果sem_op为负数,那么就从信号量的值中减去sem_op的绝对值,这意味着进程要获取资源,这些资源是由信号量控制或监控来存取的。如果没有指定IPC_NOWAIT,那么调用进程睡眠到请求的资源数得到满足(其他的进程可能释放一些资源)。
  • 如果sem_op是正数,把它的值加到信号量,这意味着把资源归还给应用程序的集合。
  • 最后,如果sem_op为 0,那么调用进程将睡眠到信号量的值也为 0,这相当于一个信号量到达了 100%的利用。

综上所述,Linux按如下的规则判断是否所有的操作都可以成功:操作值和信号量的当前值相加大于 0,或操作值和当前值均为 0,则操作成功。如果系统调用中指定的所有操作中有一个操作不能成功时,则Linux会挂起这一进程。但是,如果操作标志指定这种情况下不能挂起进程的话,系统调用返回并指明信号量上的操作没有成功,而进程可以继续执行。如果进程被挂起,Linux必须保存信号量的操作状态并将当前进程放入等待队列。

为此,Linux内核在堆栈中建立一个sem_queue结构并填充该结构。新的sem_queue结构添加到集合的等待队列中(利用sem_pendingsem_pending_last指针)。当前进程放入sem_queue结构的等待队列中sleeper后调用调度程序选择其他的进程运行。

系统调用:semctl()

1
int semctl ( int semid, int semnum, int cmd, union semun arg );

成功返回正数,出错返回-1。

注意,semctl()是在集合上执行控制操作。

semctl()的第 1 个参数(semid)是集合的标识号,第 2 个参数(semnum)是将要操作的信号量个数,从本质上说,它是集合的一个索引,对于集合上的第一个信号量,则该值为0。

  • cmd参数表示在集合上执行的命令,这些命令及解释如表所示。
  • arg参数的类型为semun,这个特殊的联合体在include/linux/sem.h中声明,对它的描述如下:
    1
    2
    3
    4
    5
    6
    7
    8
    /* arg for semctl system calls. */
    union semun {
    int val; /* value for SETVAL */
    struct semid_ds *buf; /* buffer for IPC_STAT & IPC_SET */
    ushort *array; /* array for GETALL & SETALL */
    struct seminfo *__buf; /* buffer for IPC_INFO */
    void *__pad;
    };

这个联合体中,有 3 个成员已经在表 7.1 中提到,剩下的两个成员_buf_pad用在内核中信号量的实现代码,开发者很少用到。事实上,这两个成员是Linux操作系统所特有的,在UINX中没有。

命令 解释
IPC_STAT 从信号量集合上检索semid_ds结构,并存到semun联合体参数的成员buf的地址中
IPC_SET 设置一个信号量集合的semid_ds结构中ipc_perm域的值,并从semunbuf中取出值
IPC_RMID 从内核中删除信号量集合
GETALL 从信号量集合中获得所有信号量的值,并把其整数值存到semun联合体成员的一个指针数组中
GETNCNT 返回当前等待资源的进程个数
GETPID 返回最后一个执行系统调用semop()进程的PID
GETVAL 返回信号量集合内单个信号量的值
GETZCNT 返回当前等待 100%资源利用的进程个数
SETALL GETALL正好相反
SETVAL 用联合体中val成员的值设置信号量集合中单个信号量的值

这个系统调用比较复杂,我们举例说明。下面这个程序段返回集合上索引为semnum对应信号量的值。当用GETVAL命令时,最后的参数(semnum)被忽略。

1
2
3
4
int get_sem_val( int sid, int semnum )
{
return( semctl(sid, semnum, GETVAL, 0));
}

死锁

和信号量操作相关的概念还有“死锁”。当某个进程修改了信号量而进入临界区之后,却因为崩溃或被“杀死(kill)”而没有退出临界区,这时,其他被挂起在信号量上的进程永远得不到运行机会,这就是所谓的死锁。Linux通过维护一个信号量数组的调整列表(semadj)来避免这一问题。其基本思想是,当应用这些“调整”时,让信号量的状态退回到操作实施前的状态。

关于调整的描述是在sem_undo数据结构中,在include/linux/sem.h描述如下:

1
2
3
4
5
6
7
/*每一个任务都有一系列的恢复(undo)请求,当进程退出时,自动执行`undo`请求*/
struct sem_undo {
struct sem_undo * proc_next; /*在这个进程上的下一个sem_undo节点 */
struct sem_undo * id_next; /* 在这个信号量集和上的下一个sem_undo节点*/
int semid; /* 信号量集的标识号*/
short * semadj; /* 信号量数组的调整,每个进程一个*/
};

sem_undo结构也出现在task_struct数据结构中。

每一个单独的信号量操作也许要请求得到一次“调整”,Linux将为每一个信号量数组的每一个进程维护至少一个sem_undo结构。如果请求的进程没有这个结构,当必要时则创建它,新创建的sem_undo数据结构既在这个进程的task_struct数据结构中排队,也在信号量数组的semid_ds结构中排队。当对信号量数组上的一个信号量施加操作时,这个操作值的负数与这个信号量的“调整”相加,因此,如果操作值为 2,则把-2 加到这个信号量的“调整”域。

当进程被删除时,Linux完成了对sem_undo数据结构的设置及对信号量数组的调整。如果一个信号量集合被删除,sem_undo结构依然留在这个进程的task_struct结构中,但信号量集合的识别号变为无效。

消息队列

一个或多个进程可向消息队列写入消息,而一个或多个进程可从消息队列中读取消息。在许多微内核结构的操作系统中,内核和各组件之间的基本通信方式就是消息队列。例如,在Minlx操作系统中,内核、I/O任务、服务器进程和用户进程之间就是通过消息队列实现通信的。

Linux中的消息可以被描述成在内核地址空间的一个内部链表,每一个消息队列由一个IPC的标识号唯一地标识。Linux为系统中所有的消息队列维护一个msgque链表,该链表中的每个指针指向一个msgid_ds结构,该结构完整描述一个消息队列。

数据结构

(1)消息缓冲区(msgbuf),可以把这个特殊的数据结构看成一个存放消息数据的模板,它在include/linux/msg.h中声明,描述如下:

1
2
3
4
5
/* msgsnd`和`msgrcv`系统调用使用的消息缓冲区*/
struct msgbuf {
long mtype; /* 消息的类型,必须为正数 */
char mtext[1]; /* 消息正文 */
};

注意,对于消息数据元素(mtext),不要受其描述的限制。实际上,这个域(mtext)不仅能保存字符数组,而且能保存任何形式的任何数据。这个域本身是任意的,因为这个结构本身可以由应用程序员重新定义:

1
2
3
4
5
struct my_msgbuf {
long mtype; /* 消息类型 */
long request_id; /* 请求识别号 */
struct client info; /* 客户消息结构 */
};

我们看到,消息的类型还是和前面一样,但是结构的剩余部分由两个其他的元素代替,而且有一个是结构。这就是消息队列的优美之处,内核根本不管传送的是什么样的数据,任何信息都可以传送。

但是,消息的长度还是有限制的,在Linux中,给定消息的最大长度在include/linux/msg.h中定义如下:

1
#define MSGMAX 8192 /* max size of message (bytes) */

消息总的长度不能超过 8192 字节,包括mtype域,它是 4 字节长。

(2)消息结构(msg):内核把每一条消息存储在以msg结构为框架的队列中,它在include/ linux/msg.h中定义如下:

1
2
3
4
5
6
struct msg {
struct msg *msg_next; /* 队列上的下一条消息 */
long msg_type; /*消息类型*/
char *msg_spot; /* 消息正文的地址 */
short msg_ts; /* 消息正文的大小 */
};

注意,msg_next是指向下一条消息的指针,它们在内核地址空间形成一个单链表。

(3)消息队列结构(msgid_ds):当在系统中创建每一个消息队列时,内核创建、存储及维护这个结构的一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 在系统中的每一个消息队列对应一个msqid_ds结构 */
struct msqid_ds {
struct ipc_perm msg_perm;
struct msg *msg_first; /* 队列上第一条消息,即链表头*/
struct msg *msg_last; /* 队列中的最后一条消息,即链表尾 */
time_t msg_stime; /* 发送给队列的最后一条消息的时间 */
time_t msg_rtime; /* 从消息队列接收到的最后一条消息的时间 */
time_t msg_ctime; /* 最后修改队列的时间*/
ushort msg_cbytes; /*队列上所有消息总的字节数 */
ushort msg_qnum; /*在当前队列上消息的个数 */
ushort msg_qbytes; /* 队列最大的字节数 */
ushort msg_lspid; /* 发送最后一条消息的进程的`pid */
ushort msg_lrpid; /* 接收最后一条消息的进程的`pid */
};

系统调用: msgget()

为了创建一个新的消息队列,或存取一个已经存在的队列,要使用msgget()系统调用。

1
int msgget ( key_t key, int msgflg );

成功,则返回消息队列识别号,失败,则返回-1。

semget()中的第一个参数是键值,这个键值要与现有的键值进行比较,现有的键值指在内核中已存在的其他消息队列的键值。对消息队列的打开或存取操作依赖于msgflg参数的取值。

  • IPC_CREAT:如果这个队列在内核中不存在,则创建它。
  • IPC_EXCL:当与IPC_CREAT一起使用时,如果这个队列已存在,则创建失败。

如果IPC_CREAT单独使用,semget()为一个新创建的消息队列返回标识号,或者返回具有相同键值的已存在队列的标识号。如果IPC_EXCLIPC_CREAT一起使用,要么创建一个新的队列,要么对已存在的队列返回-1。IPC_EXCL不能单独使用,当与IPC_CREAT结合起来使用时,可以保证新创建队列的打开和存取。

与文件系统的存取权限一样,每一个IPC对象也具有存取权限,因此,可以把一个 8 进制与掩码或,形成对消息队列的存取权限。

下面我们来创建一个打开或创建消息队列的函数:

1
2
3
4
5
6
7
8
9
10
int open_queue( key_t keyval )
{
int qid;

if((qid = msgget( keyval, IPC_CREAT | 0660 )) == -1) {
return(-1);
}

return(qid);
}

注意,这个例子显式地用了 0660 权限。这个函数要么返回一个消息队列的标识号,要么返回-1 而出错。键值作为唯一的参数必须传递给它。

系统调用:msgsnd()

一旦我们有了队列识别号,我们就可以在这个队列上执行操作。要把一条消息传递给一个队列,必须用msgsnd()系统调用。

1
int msgsnd ( int msqid, struct msgbuf *msgp, int msgsz, int msgflg );

返回:成功为 0, 失败为-1。

msgsnd()的第 1 个参数是队列识别号,由msgget()调用返回。第 2 个参数msgp是一个指针,指向我们重新声明和装载的消息缓冲区。msgsz参数包含了消息以字节为单位的长度,其中包括了消息类型的 4 个字节。msgflg参数可以设置成 0(忽略),或者设置或IPC_NOWAIT:如果消息队列满,消息不写到队列中,并且控制权返回给调用进程(继续执行);如果不指定IPC_NOWAIT,调用进程将挂起(阻塞)直到消息被写到队列中。

下面我们来看一个发送消息的简单函数:

1
2
3
4
5
6
7
8
9
10
11
int send_message( int qid, struct mymsgbuf *qbuf )
{
int result, length;
/* mymsgbuf结构的实际长度 */
length = sizeof(struct ) - sizeof(long);
if((result = msgsnd( qid, qbuf, length, 0)) == -1){
return(-1);
}

return(result);
}

这个小函数试图把缓冲区qbuf中的消息,发送给队列识别号为qid的消息队列。

现在,我们在消息队列里有了一条消息,可以用ipcs命令来看队列的状态。如何从消息队列检索消息,可以用msgrcv()系统调用。

系统调用:msgrcv()

1
int msgrcv ( int msqid, struct msgbuf *msgp, int msgsz, long mtype, int msgflg );

成功,则返回拷贝到消息缓冲区的字节数,失败为-1。

很明显,第 1 个参数用来指定要检索的队列(必须由msgget()调用返回),第 2 个参数(msgp)是存放检索到消息的缓冲区的地址,第 3 个参数(msgsz)是消息缓冲区的大小,包括消息类型的长度(4 字节)。第 4 个参数(mtype)指定了消息的类型。内核将搜索队列中相匹配类型的最早的消息,并且返回这个消息的一个拷贝,返回的消息放在由msgp参数指向的地址。这里存在一个特殊的情况,如果传递给mytype参数的值为 0,就可以不管类型,只返回队列中最早的消息。

如果传递给参数msgflg的值为IPC_NOWAIT,并且没有可取的消息,那么给调用进程返回ENOMSG错误消息,否则,调用进程阻塞,直到一条消息到达队列并且满足msgrcv()的参数。如果一个客户正在等待消息,而队列被删除,则返回EIDRM。如果当进程正在阻塞,并且等待一条消息到达但捕获到了一个信号,则返回EINTR

下面我们来看一个从我们已建的消息队列中检索消息的例子

1
2
3
4
5
6
7
8
9
10
11
int read_message( int qid, long type, struct mymsgbuf *qbuf )
{
int result, length;
/* 计算mymsgbuf结构的实际大小*/
length = sizeof(struct mymsgbuf) - sizeof(long);
if((result = msgrcv( qid, qbuf, length, type, 0)) == -1) {
return(-1);
}

return(result);
}

当从队列中成功地检索到消息后,这个消息将从队列中删除。

共享内存

共享内存可以被描述成内存一个区域(段)的映射,这个区域可以被更多的进程所共享。一旦内存被共享之后,对共享内存的访问同步需要由其他IPC机制,例如信号量来实现。像所有的System V IPC对象一样,Linux对共享内存的存取是通过对访问键和访问权限的检查来控制的。

数据结构

与消息队列和信号量集合类似,内核为每一个共享内存段(存在于它的地址空间)维护着一个特殊的数据结构shmid_ds,这个结构在include/linux/shm.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 在系统中每一个共享内存段都有一个shmid_ds数据结构. */
struct shmid_ds {
struct ipc_perm shm_perm; /* 操作权限 */
int shm_segsz; /* 段的大小(以字节为单位) */
time_t shm_atime; /* 最后一个进程附加到该段的时间 */
time_t shm_dtime; /* 最后一个进程离开该段的时间 */
time_t shm_ctime; /* 最后一次修改这个结构的时间 */
unsigned short shm_cpid; /*创建该段进程的pid */
unsigned short shm_lpid; /* 在该段上操作的最后一个进程的`pid */
short shm_nattch; /*当前附加到该段的进程的个数 */
/* 下面是私有的 */
unsigned short shm_npages; /*段的大小(以页为单位) */
unsigned long *shm_pages; /* 指向frames -> SHMMAX的指针数组 */
struct vm_area_struct *attaches; /* 对共享段的描述 */
};

我们用图 7.4 来表示共享内存的数据结构shmid_ds与其他相关数据结构的关系。

共享内存的处理过程

某个进程第 1 次访问共享虚拟内存时将产生缺页异常。这时,Linux找出描述该内存的vm_area_struct结构,该结构中包含用来处理这种共享虚拟内存段的处理函数地址。共享内存缺页异常处理代码对shmid_ds的页表项表进行搜索,以便查看是否存在该共享虚拟内存的页表项。如果没有,系统将分配一个物理页并建立页表项,该页表项加入shmid_ds结构的同时也添加到进程的页表中。这就意味着当下一个进程试图访问这页内存时出现缺页异常,共享内存的缺页异常处理代码则把新创建的物理页给这个进程。因此说,第 1 个进程对共享内存的存取引起创建新的物理页面,而其他进程对共享内存的存取引起把那个页添加到它们的地址空间。

当某个进程不再共享其虚拟内存时,利用系统调用将共享段从自己的虚拟地址区域中移去,并更新进程页表。当最后一个进程释放了共享段之后,系统将释放给共享段所分配的物理页。当共享的虚拟内存没有被锁定到物理内存时,共享内存也可能会被交换到交换区中。

系统调用:shmget()

1
int shmget ( key_t key, int size, int shmflg )

成功,则返回共享内存段的识别号, 失败返回-1。

shmget()系统调用类似于信号量和消息队列的系统调用,在此不进一步赘述。

系统调用:shmat()

1
int shmat(int shmid, char *shmaddr, int shmflg)

成功,则返回附加到进程的那个段的地址,失败返回-1。

其中shmid是由shmget()调用返回的共享内存段识别号,shmaddr是你希望共享段附加的地址,shmflag允许你规定希望所附加的段为只读(利用SHM_RDONLY)以代替读写。通常,并不需要规定你自己的shmaddr,可以用传递参数值零使得系统为你取得一个地址。

这个调用可能是最简单的,下面看一个例子,把一个有效的识别号传递给一个段,然后返回这个段被附加到内存的内存地址。

1
2
3
4
char *attach_segment( int shmid )
{
return(shmat(shmid, 0, 0));
}

一旦一个段适当地被附加,并且一个进程有指向那个段起始地址的一个指针,那么,对那个段的读写就变得相当容易。

系统调用:shmctl()

1
int shmctl ( int shmqid, int cmd, struct shmid_ds *buf );

成功返回 0 ,失败返回-1。

这个特殊的调用和semctl()调用几乎相同,因此,这里不进行详细的讨论。有效命令的值如下所述。

  • IPC_STAT:检索一个共享段的shmid_ds结构,把它存到buf参数的地址中。
  • IPC_SET:对一个共享段来说,从buf参数中取值设置shmid_ds结构的ipc_perm域的值。
  • IPC_RMID:把一个段标记为删除。
  • IPC_RMID:命令实际上不从内核删除一个段,而是仅仅把这个段标记为删除,实际的删除发生在最后一个进程离开这个共享段时。

当一个进程不再需要共享内存段时,它将调用shmdt()系统调用取消这个段,但是,这并不是从内核真正地删除这个段,而是把相关shmid_ds结构的shm_nattch域的值减 1,当这个值为 0 时,内核才从物理上删除这个共享段。

虚拟文件系统

概述

虚拟文件系统又称虚拟文件系统转换(Virual Filesystem Switch ,简称VFS)。说它虚拟,是因为它所有的数据结构都是在运行以后才建立,并在卸载时删除,而在磁盘上并没有存储这些数据结构。如果只有VFS,系统是无法工作的,因为它的这些数据结构不能凭空而来,只有与实际的文件系统,如Ext2MinixMSDOSVFAT等相结合,才能开始工作,所以VFS并不是一个真正的文件系统。与VFS相对应,我们称Ext2、Minix、MSDOS等为具体文件系统。

虚拟文件系统的作用

对具体文件系统来说,VFS是一个管理者,而对内核的其他子系统来说,VFS是它们与具体文件系统的一个接口。

VFS提供一个统一的接口(实际上就是file_operatoin数据结构),一个具体文件系统要想被Linux支持,就必须按照这个接口编写自己的操作函数,而将自己的细节对内核其他子系统隐藏起来。因而,对内核其他子系统以及运行在操作系统之上的用户程序而言,所有的文件系统都是一样的。实际上,要支持一个新的文件系统,主要任务就是编写这些接口函数。

概括说来,VFS主要有以下几个作用。

  1. 对具体文件系统的数据结构进行抽象,以一种统一的数据结构进行管理。
  2. 接受用户层的系统调用,例如write、open、stat、link等。
  3. 支持多种具体文件系统之间相互访问。
  4. 接受内核其他子系统的操作请求,特别是内存管理子系统。

通过VFSLinux可以支持很多种具体文件系统,表是Linux支持的部分具体文件系统。

文件系统 描述
Minix Linux 最早支持的文件系统。主要缺点是最大64MB的磁盘分区和最长 14 个字符的文件名称的限制
Ext 第 1 个Linux专用的文件系统,支持2GB磁盘分区,255 字符的文件名称,但性能有问题
Xiafs Minix基础上发展起来,克服了Minix的主要缺点。但很快被更完善的文件系统取代
Ext2 当前实际上的Linux标准文件系统。性能强大,易扩充,可移植
Ext3 日志文件系统。Ext3 文件系统是对稳定的Ext2文件系统的改进
System V UNIX 早期支持的文件系统,也有与Minix同样的限制
NFS 网络文件系统。使得用户可以像访问本地文件一样访问远程主机上的文件
ISO 9660 光盘使用的文件系统
/proc 一个反映内核运行情况的虚的文件系统,并不实际存在于磁盘上
Msdos DOS 的文件系统,系统力图使它表现得像`UNIX
UMSDOS 该文件系统允许MSDOS文件系统可以当作Linux固有的文件系统一样使用
Vfat fat 文件系统的扩展,支持长文件名
NtfsWindows NT` 的文件系统
Hpfs OS/2的文件系统

VFS所处理的系统调用

表列出VFS的系统调用,这些系统调用涉及文件系统、常规文件、目录及符号链接。另外还有少数几个由VFS处理的其他系统调用:诸如ioperm()ioctl()pipe()mknod(),涉及设备文件和管道文件。由VFS处理的最后一组系统调用,诸如socket()connect()bind()protocols(),属于套接字系统调用并用于实现网络功能。

系统调用名 功能
mount()/umount() 安装/卸载文件系统
sysfs() 获取文件系统信息
statfs()/fstatfs()/ustat() 获取文件系统统计信息
chroot() 更改根目录
chdir()/fchdir()/getcwd() 更改当前目录
mkdir()/rmdir() 创建/删除目录
getdents()/readdir()/link()unlink()/rename() 对目录项进行操作
readlink()/symlink() 对软链接进行操作
chown()/fchown()/lchown() 更改文件所有者
chmod()/fchmod()/utime() 更改文件属性
stat()/fstat()/lstat()/access() 读取文件状态
open()/close()/creat()/umask() 打开/关闭文件
dup()/dup2()/fcntl() 对文件描述符进行操作
select()/poll() 异步I/O通信
truncate()/ftruncate() 更改文件长度
lseek()/_llseek() 更改文件指针
read()/write()/readv()/writev()/sendfile() 文件I/O操作
pread()/pwrite() 搜索并访问文件
mmap()/munmap() 文件内存映射
fdatasync()/fsync()/sync()/msync() 同步访问文件数据
flock() 处理文件锁

VFS中的数据结构

你可以把通用文件模型看作是面向对象的,在这里,对象是一个软件结构,其中既定义了数据结构也定义了其上的操作方法。出于效率的考虑,Linux的编码并未采用面向对象的程序设计语言(比如C++)。因此对象作为数据结构来实现:数据结构中指向函数的域就对应于对象的方法。

通用文件模型由下列对象类型组成。

  • 超级块(superblock)对象:存放系统中已安装文件系统的有关信息。每个文件系统都有一个超级块对象。
  • 索引节点(inode)对象:存放关于具体文件的一般信息。对于基于磁盘的文件系统,这类对象通常对应于存放在磁盘上的文件控制块(FCB),也就是说,每个文件都有一个索引节点对象。每个索引节点对象都有一个索引节点号,这个号唯一地标识某个文件系统中的指定文件。
  • 目录项(dentry)对象:存放目录项与对应文件进行链接的信息。VFS把每个目录看作一个由若干子目录和文件组成的常规文件。例如,在查找路径名/tmp/test时,内核为根目录/创建一个目录项对象,为根目录下的tmp项创建一个第 2 级目录项对象,为/tmp目录下的test项创建一个第 3 级目录项对象。
  • 文件(file)对象:存放打开文件与进程之间进行交互的有关信息。这类信息仅当进程访问文件期间存在于内存中。

下面我们讨论超级块、索引节点、目录项及文件的数据结构,它们的共同特点有两个:

  • 充分考虑到对多种具体文件系统的兼容性;
  • 是“虚”的,也就是说只能存在于内存。

这正体现了VFS的特点,在下面的描述中,读者也许能体会到以上特点。

超级块

VFS超级块是各种具体文件系统在安装时建立的,并在这些文件系统卸载时自动删除,可见,VFS超级块确实只存在于内存中,同时提到VFS超级块也应该说成是哪个具体文件系统的VFS超级块。VFS超级块在inculde/fs/fs.h中定义,即数据结构super_block,该结构及其主要域的含义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct super_block
{
/************描述具体文件系统的整体信息的域*****************
kdev_t s_dev; /* 包含该具体文件系统的块设备标识符。例如,对于 /dev/hda1,其设备标识符为 0x301 */
unsigned long s_blocksize; /*该具体文件系统中数据块的大小,以字节为单位 */
unsigned char s_blocksize_bits; /*块大小的值占用的位数,例如,如果块大小为 1024 字节,则该值为 10*/
unsigned long long s_maxbytes; /* 文件的最大长度 */
unsigned long s_flags; /* 安装标志*/
unsigned long s_magic; /*魔数,即该具体文件系统区别于其他文件系统的一个标志*/

/**************用于管理超级块的域******************/
struct list_head s_list; /*指向超级块链表的指针*/
struct semaphore s_lock /*锁标志位,若置该位,则其他进程不能对该超级块操作*/
struct rw_semaphore s_umount /*对超级块读写时进行同步*/
unsigned char s_dirt; /*脏位,若置该位,表明该超级块已被修改*/
struct dentry *s_root; /*指向该具体文件系统安装目录的目录项*/
int s_count; /*对超级块的使用计数*/
atomic_t s_active;
struct list_head s_dirty; /*已修改的索引节点形成的链表 */
struct list_head s_locked_inodes;/* 要进行同步的索引节点形成的链表*/
struct list_head s_files

/***********和具体文件系统相联系的域*************************/
struct file_system_type *s_type; /*指向文件系统的file_system_type数据结构的指针 */
struct super_operations *s_op; /*指向某个特定的具体文件系统的用于超级块操作的函数集合 */
struct dquot_operations *dq_op; /* 指向某个特定的具体文件系统用于限额操作的函数集合 */
u; /*一个共用体,其成员是各种文件系统的fsname_sb_info数据结构 */
};

所有超级块对象(每个已安装的文件系统都有一个超级块)以双向环形链表的形式链接在一起。链表中第一个元素和最后一个元素的地址分别存放在super_blocks变量的s_list域的nextprev域中。s_list域的数据类型为struct list_head,在超级块的s_dirty域以及内核的其他很多地方都可以找到这样的数据类型;这种数据类型仅仅包括指向链表中的前一个元素和后一个元素的指针。因此,超级块对象的s_list域包含指向链表中两个相邻超级块对象的指针。图 8.2 说明了list_head元素、nextprev是如何嵌入到超级块对象中的。

超级块最后一个u联合体域包括属于具体文件系统的超级块信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
union {
struct Minix_sb_info Minix_sb;
struct Ext2_sb_info Ext2_sb;
struct ext3_sb_info ext3_sb;
struct hpfs_sb_info hpfs_sb;
struct ntfs_sb_info ntfs_sb;
struct msdos_sb_info msdos_sb;
struct isofs_sb_info isofs_sb;
struct nfs_sb_info nfs_sb;
struct sysv_sb_info sysv_sb;
struct affs_sb_info affs_sb;
struct ufs_sb_info ufs_sb;
struct efs_sb_info efs_sb;
struct shmem_sb_info shmem_sb;
struct romfs_sb_info romfs_sb;
struct smb_sb_info smbfs_sb;
struct hfs_sb_info hfs_sb;
struct adfs_sb_info adfs_sb;
struct qnx4_sb_info qnx4_sb;
struct reiserfs_sb_info reiserfs_sb;
struct bfs_sb_info bfs_sb;
struct udf_sb_info udf_sb;
struct ncp_sb_info ncpfs_sb;
struct usbdev_sb_info usbdevfs_sb;
struct jffs2_sb_info jffs2_sb;
struct cramfs_sb_info cramfs_sb;
void *generic_sbp;
} u;

通常,为了效率起见u域的数据被复制到内存。任何基于磁盘的文件系统都需要访问和更改自己的磁盘分配位示图,以便分配和释放磁盘块。VFS允许这些文件系统直接对内存超级块的u联合体域进行操作,无需访问磁盘。

但是,这种方法带来一个新问题:有可能VFS超级块最终不再与磁盘上相应的超级块同步。因此,有必要引入一个s_dirt标志,来表示该超级块是否是脏的。Linux是通过周期性地将所有“脏”的超级块写回磁盘来减少该问题带来的危害。

与超级块关联的方法就是所谓的超级块操作。这些操作是由数据结构super_operations来描述的,该结构的起始地址存放在超级块的s_op域中。

VFS的索引节点

具体文件系统的索引节点是存储在磁盘上的,是一种静态结构,要使用它,必须调入内存,填写VFS的索引节点,因此,也称VFS索引节点为动态节点。VFS索引节点的数据结构inode/includ/fs/fs.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
struct inode
{
/**********描述索引节点高速缓存管理的域****************/
struct list_head i_hash; /*指向哈希链表的指针*/
struct list_head i_list; /*指向索引节点链表的指针*/
struct list_head i_dentry;/*指向目录项链表的指针*/

struct list_head i_dirty_buffers;
struct list_head i_dirty_data_buffers;
/**********描述文件信息的域****************/
unsigned long i_ino; /*索引节点号*/
kdev_t i_dev; /*设备标识号 */
umode_t i_mode; /*文件的类型与访问权限 */
nlink_t i_nlink; /*与该节点建立链接的文件数 */
uid_t i_uid; /*文件拥有者标识号*/
gid_t i_gid; /*文件拥有者所在组的标识号*/
kdev_t i_rdev; /*实际设备标识号*/
off_t i_size; /*文件的大小(以字节为单位)*/
unsigned long i_blksize; /*块大小*/
unsigned long i_blocks; /*该文件所占块数*/
time_t i_atime; /*文件的最后访问时间*/
time_t i_mtime; /*文件的最后修改时间*/
time_t i_ctime; /*节点的修改时间*/
unsigned long i_version; /*版本号*/
struct semaphore i_zombie; /*僵死索引节点的信号量*/

/***********用于索引节点操作的域*****************/
struct inode_operations *i_op; /*索引节点的操作*/
struct super_block *i_sb; /*指向该文件系统超级块的指针 */
atomic_t i_count; /*当前使用该节点的进程数。计数为 0,表明该节点可丢弃或被重新使用 */
struct file_operations *i_fop; /*指向文件操作的指针 */
unsigned char i_lock; /*该节点是否被锁定,用于同步操作中*/
struct semaphore i_sem; /*指向用于同步操作的信号量结构*/
wait_queue_head_t *i_wait; /*指向索引节点等待队列的指针*/
unsigned char i_dirt; /*表明该节点是否被修改过,若已被修改,则应当将该节点写回磁盘*/
struct file_lock *i_flock; /*指向文件加锁链表的指针*/
struct dquot *i_dquot[MAXQUOTAS]; /*索引节点的磁盘限额*/
/************用于分页机制的域**********************************/
struct address_space *i_mapping; /* 把所有可交换的页面管理起来*/
struct address_space i_data;
/**********以下几个域应当是联合体****************************************/
struct list_head i_devices; /*设备文件形成的链表*/
struct pipe_inode_info i_pipe; /*指向管道文件*/
struct block_device *i_bdev; /*指向块设备文件的指针*/
struct char_device *i_cdev; /*指向字符设备文件的指针*/
/*************************其他域***************************************/
unsigned long i_dnotify_mask; /* Directory notify events */
struct dnotify_struct *i_dnotify; /* for directory notifications */
unsigned long i_state; /*索引节点的状态标志*/
unsigned int i_flags; /*文件系统的安装标志*/
unsigned char i_sock; /*如果是套接字文件则为真*/
atomic_t i_writecount; /*写进程的引用计数*/
unsigned int i_attr_flags; /*文件创建标志*/
__u32 i_generation /*为以后的开发保留*/
/*************************各个具体文件系统的索引节点********************/
union; /*类似于超级块的一个共用体,其成员是各种具体文件系统的fsname_inode_info数据结构 */
}

inode数据结构的进一步说明。

  • 每个文件都有一个inode,每个inode有一个索引节点号i_ino。在同一个文件系统中,每个索引节点号都是唯一的,内核有时根据索引节点号的哈希值查找其inode结构。
  • 每个文件都有个文件主,其最初的文件主是创建了这个文件的用户,但以后可以改变。
  • 每个用户都有一个用户组,且属于某个用户组,因此,inode结构中就有相应的i_uidi_gid以指明文件主的身份。
  • inode中有两个设备号,i_devi_rdev。首先,除特殊文件外,每个节点都存储在某个设备上,这就是i_dev。其次,如果索引节点所代表的并不是常规文件,而是某个设备,那就还得有个设备号,这就是i_rdev
  • 每当一个文件被访问时,系统都要在这个文件的inode中记下时间标记,这就是inode中与时间相关的几个域。
  • 每个索引节点都会复制磁盘索引节点包含的一些数据,比如文件占用的磁盘块数。如果i_state域的值等于I_DIRTY,该索引节点就是“脏”的,也就是说,对应的磁盘索引节点必须被更新。i_state域的其他值有I_LOCK(这意味着该索引节点对象已加锁),I_FREEING(这意味着该索引节点对象正在被释放)。每个索引节点对象总是出现在下列循环双向链表的某个链表中。
    • 未用索引节点链表。变量inode_unusednext域和prev域分别指向该链表中的首元素和尾元素。这个链表用做内存高速缓存。
    • 正在使用索引节点链表。变量inode_in_use指向该链表中的首元素和尾元素。
    • 脏索引节点链表。由相应超级块的s_dirty域指向该链表中的首元素和尾元素。
    • 这 3 个链表都是通过索引节点的i_list域链接在一起的。
  • 属于“正在使用”或“脏”链表的索引节点对象也同时存放在一个称为inode_hashtable链表中。哈希表加快了对索引节点对象的搜索,前提是系统内核要知道索引节点号及对应文件所在文件系统的超级块对象的地址。由于散列技术可能引发冲突,所以,索引节点对象设置一个i_hash域,其中包含向前和向后的两个指针,分别指向散列到同一地址的前一个索引节点和后一个索引节点;该域由此创建了由这些索引节点组成的一个双向链
    表。

与索引节点关联的方法也叫索引节点操作,由inode_operations结构来描述,该结构的地址存放在i_op域中,该结构也包括一个指向文件操作方法的指针。

目录项对象

每个文件除了有一个索引节点inode数据结构外,还有一个目录项dentry(directory enrty)数据结构。dentry结构中有个d_inode指针指向相应的inode结构。二者所描述的目标不同,dentry结构代表的是逻辑意义上的文件,所描述的是文件逻辑上的属性,因此,目录项对象在磁盘上并没有对应的映像;而inode结构代表的是物理意义上的文件,记录的是物理上的属性,对于一个具体的文件系统(如Ext2),Ext2_inode结构在磁盘上就有对应的映像。所以说,一个索引节点对象可能对应多个目录项对象。

dentry的定义在include/linux/dcache.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct dentry {
atomic_t d_count; /* 目录项引用计数器 */
unsigned int d_flags; /* 目录项标志 */
struct inode * d_inode; /* 与文件名关联的索引节点 */
struct dentry * d_parent; /* 父目录的目录项 */
struct list_head d_hash; /* 目录项形成的哈希表 */
struct list_head d_lru; /*未使用的LRU链表 */
struct list_head d_child; /*父目录的子目录项所形成的链表 */
struct list_head d_subdirs; /* 该目录项的子目录所形成的链表*/
struct list_head d_alias; /* 索引节点别名的链表*/
int d_mounted; /* 目录项的安装点 */
struct qstr d_name; /* 目录项名(可快速查找) */
unsigned long d_time; /* 由d_revalidate函数使用 */
struct dentry_operations *d_op; /* 目录项的函数集*/
struct super_block * d_sb; /* 目录项树的根 (即文件的超级块)*/
unsigned long d_vfs_flags;
void * d_fsdata; /* 具体文件系统的数据 */
unsigned char d_iname[DNAME_INLINE_LEN]; /* 短文件名 */
};

一个有效的dentry结构必定有一个inode结构,这是因为一个目录项要么代表着一个文件,要么代表着一个目录,而目录实际上也是文件。所以,只要dentry结构是有效的,则其指针d_inode必定指向一个inode结构。可是,反过来则不然,一个inode却可能对应着不止一个dentry结构;也就是说,一个文件可以有不止一个文件名或路径名。这是因为一个已经建立的文件可以被连接(link)到其他文件名。所以在inode结构中有一个队列i_dentry,凡是代表着同一个文件的所有目录项都通过其dentry结构中的d_alias域挂入相应inode结构中的i_dentry队列。

在内核中有一个哈希表dentry_hashtable,是一个list_head的指针数组。一旦在内存中建立起一个目录节点的dentry结构,该dentry结构就通过其d_hash域链入哈希表中的某个队列中。

内核中还有一个队列dentry_unused,凡是已经没有用户(count域为 0)使用的dentry结构就通过其d_lru域挂入这个队列。dentry结构中除了d_aliasd_hashd_lru三个队列外,还有d_vfsmntd_childd_subdir三个队列。其中d_vfsmnt仅在该dentry为一个安装点时才使用。另外,当该目录节点有父目录时,则其dentry结构就通过d_child挂入其父节点的d_subdirs队列中,同时又通过指针d_parent指向其父目录的dentry结构,而它自己各个子目录的dentry结构则挂在其d_subdirs域指向的队列中。

从上面的叙述可以看出,一个文件系统中所有目录项结构或组织为一个哈希表,或组织为一颗树,或按照某种需要组织为一个链表,这将为文件访问和文件路径搜索奠定下良好的基础。

与进程相关的文件结构

文件对象

Linux中,进程是通过文件描述符(file descriptors,简称fd)而不是文件名来访问文件的,文件描述符实际上是一个整数。Linux中规定每个进程最多能同时使用NR_OPEN个文件描述符,这个值在fs.h中定义,为 1024×1024(2.0 版中仅定义为 256)。每个文件都有一个 32 位的数字来表示下一个读写的字节位置,这个数字叫做文件位置。

每次打开一个文件,除非明确要求,否则文件位置都被置为 0,即文件的开始处,此后的读或写操作都将从文件的开始处执行,但你可以通过执行系统调用LSEEK(随机存储)对这个文件位置进行修改。Linux中专门用了一个数据结构file来保存打开文件的文件位置,这个结构称为打开的文件描述(open file description)。

我们知道,Linux中的文件是能够共享的,假如把文件位置存放在索引节点中,则如果有两个或更多个进程同时打开同一个文件时,它们将去访问同一个索引节点,于是一个进程的LSEEK操作将影响到另一个进程的读操作,这显然是不允许也是不可想象的。

另一个想法是既然进程是通过文件描述符访问文件的,为什么不用一个与文件描述符数组相平行的数组来保存每个打开文件的文件位置?这个想法也是不能实现的,原因就在于在生成一个新进程时,子进程要共享父进程的所有信息,包括文件描述符数组。

file结构中主要保存了文件位置,此外,还把指向该文件索引节点的指针也放在其中。file结构形成一个双链表,称为系统打开文件表,其最大长度是NR_FILE,在fs.h中定义为8192。

file结构在include\linux\fs.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct file
{
struct list_head f_list; /*所有打开的文件形成一个链表*/
struct dentry *f_dentry; /*指向相关目录项的指针*/
struct vfsmount *f_vfsmnt; /*指向VFS安装点的指针*/
struct file_operations *f_op; /*指向文件操作表的指针*/
mode_t f_mode; /*文件的打开模式*/
loff_t f_pos; /*文件的当前位置*/
unsigned short f_flags; /*打开文件时所指定的标志*/
unsigned short f_count; /*使用该结构的进程数*/
unsigned long f_reada, f_ramax, f_raend, f_ralen, f_rawin;
/*预读标志、要预读的最多页面数、上次预读后的文件指针、预读的字节数以及预读的页面数*/
int f_owner; /* 通过信号进行异步I/O数据的传送*/
unsigned int f_uid, f_gid; /*用户的UID和GID*/
int f_error; /*网络写操作的错误码*/
unsigned long f_version; /*版本号*/
void *private_data; /* tty`驱动程序所需 */
};

每个文件对象总是包含在下列的一个双向循环链表之中。

  • “未使用”文件对象的链表。该链表既可以用做文件对象的内存高速缓存,又可以当作超级用户的备用存储器,也就是说,即使系统的动态内存用完,也允许超级用户打开文件。由于这些对象是未使用的,它们的f_count域是NULL,该链表首元素的地址存放在变量free_list中,内核必须确认该链表总是至少包含NR_RESERVED_FILES个对象,通常该值设为 10。
  • “正在使用”文件对象的链表。该链表中的每个元素至少由一个进程使用,因此,各个元素的f_count域不会为NULL,该链表中第一个元素的地址存放在变量anon_list中。

如果VFS需要分配一个新的文件对象,就调用函数get_empty_filp()。该函数检测“未使用”文件对象链表的元素个数是否多于NR_RESERVED_FILES,如果是,可以为新打开的文件使用其中的一个元素;如果没有,则退回到正常的内存分配。

用户打开文件表

每个进程用一个files_struct结构来记录文件描述符的使用情况,这个files_struct结构称为用户打开文件表,它是进程的私有数据。files_struct结构在include/linux/sched.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct files_struct {
atomic_t count; /* 共享该表的进程数 */
rwlock_t file_lock; /* 保护以下的所有域,以免在tsk->alloc_lock中的嵌套*/
int max_fds; /* 当前文件对象的最大数 */
int max_fdset; /* 当前文件描述符的最大数 */
int next_fd; /* 已分配的文件描述符加 1 */
struct file ** fd; /* 指向文件对象指针数组的指针 */
fd_set *close_on_exec; /*指向执行`exec()`时需要关闭的文件描述符*/
fd_set *open_fds; /*指向打开文件描述符的指针*/
fd_set close_on_exec_init;/* 执行`exec()`时需要关闭的文件描述符的初值集合*/
fd_set open_fds_init; /*文件描述符的初值集合*/
struct file * fd_array[32];/* 文件对象指针的初始化数组*/
};

fd域指向文件对象的指针数组。该数组的长度存放在max_fds域中。通常,fd域指向files_struct结构的fd_array域,该域包括 32 个文件对象指针。如果进程打开的文件数目多于 32,内核就分配一个新的、更大的文件指针数组,并将其地址存放在fd域中;内核同时也更新max_fds域的值。

对于在fd数组中有入口地址的每个文件来说,数组的索引就是文件描述符(file descriptor)。通常,数组的第 1 个元素(索引为 0)是进程的标准输入文件,数组的第 2 个元素(索引为 1)是进程的标准输出文件,数组的第 3 个元素(索引为 2)是进程的标准错误文件。请注意,借助于dup()、dup2()fcntl()系统调用,两个文件描述符就可以指向同一个打开的文件,也就是说,数组的两个元素可能指向同一个文件对象。

open_fds域包含open_fds_init域的地址,open_fds_init域表示当前已打开文件的文件描述符的位图。max_fdset域存放位图中的位数。由于数据结构fd_set有 1024 位,通常不需要扩大位图的大小。不过,如果确实需要,内核仍能动态增加位图的大小,这非常类似文件对象的数组的情形。

当开始使用一个文件对象时调用内核提供的fget()函数。这个函数接收文件描述符fd作为参数,返回在current->files->fd[fd]中的地址,即对应文件对象的地址,如果没有任何文件与fd对应,则返回NULL。在第 1 种情况下,fget()使文件对象引用计数器f_count的值增 1。

当内核完成对文件对象的使用时,调用内核提供的fput()函数。该函数将文件对象的地址作为参数,并递减文件对象引用计数器f_count的值,另外,如果这个域变为NULL,该函数就调用文件操作的“释放”方法(如果已定义),释放相应的目录项对象,并递减对应索引节点对象的i_writeaccess域的值(如果该文件是写打开),最后,将该文件对象从“正在使用”链表移到“未使用”链表。

关于文件系统信息的fs_struct结构

fs_struct结构在 2.4 以前的版本中在include/linux/sched.h中定义为:

1
2
3
4
5
struct fs_struct {
atomic_t count;
int umask;
struct dentry * root, * pwd;
};

在 2.4 版本中,单独定义在include/linux/fs_struct.h中:

1
2
3
4
5
6
7
struct fs_struct {
atomic_t count;
rwlock_t lock;
int umask;
struct dentry * root, * pwd, * altroot;
struct vfsmount * rootmnt, * pwdmnt, * altrootmnt;
};

count域表示共享同一fs_struct表的进程数目。umask域由umask()系统调用使用,用于为新创建的文件设置初始文件许可权。

fs_struct中的dentry结构是对一个目录项的描述,rootpwdaltroot三个指针都指向这个结构。其中,root所指向的dentry结构代表着本进程所在的根目录,也就是在用户登录进入系统时所看到的根目录;pwd指向进程当前所在的目录;而altroot则是为用户设置的替换根目录。实际运行时,这 3 个目录不一定都在同一个文件系统中。例如,进程的根目录通常是安装于/节点上的Ext2文件系统,而当前工作目录可能是安装于/msdos的一个DOS文件系统。因此,fs_struct结构中的rootmntpwdmntaltrootmnt就是对那 3 个目录的安装点的描述,安装点的数据结构为vfsmount

主要数据结构间的关系

超级块是对一个文件系统的描述;索引节点是对一个文件物理属性的描述;而目录项是对一个文件逻辑属性的描述。除此之外,文件与进程之间的关系是由另外的数据结构来描述的。一个进程所处的位置是由fs_struct来描述的,而一个进程(或用户)打开的文件是由files_struct来描述的,而整个系统所打开的文件是由file结构来描述。如图 8.4 给出了这些数据结构之间的关系。

有关操作的数据结构

各种Linux支持的具体文件系统都有一套自己的操作函数,在安装时,这些结构体的成员指针将被初始化,指向对应的函数。如果说VFS体现了Linux的优越性,那么这些数据结构的设计就体现了VFS的优越性所在。

超级块操作

超级块操作是由super_operations数据结构来描述的,该结构的起始地址存放在超级块的s_op域中。该结构定义于fs.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* NOTE: write_inode, delete_inode, clear_inode, put_inode can be called
* without the big kernel lock held in all filesystems.
*/
struct super_operations {
void (*read_inode) (struct inode *);
void (*read_inode2) (struct inode *, void *) ;
void (*dirty_inode) (struct inode *);
void (*write_inode) (struct inode *, int);
void (*put_inode) (struct inode *);
void (*delete_inode) (struct inode *);
void (*put_super) (struct super_block *);
void (*write_super) (struct super_block *);
void (*write_super_lockfs) (struct super_block *);
void (*unlockfs) (struct super_block *);
int (*statfs) (struct super_block *, struct statfs *);
int (*remount_fs) (struct super_block *, int *, char *);
void (*clear_inode) (struct inode *);
void (*umount_begin) (struct super_block *);
}

其中的每个函数就叫做超级块的一个方法,表给予描述。

函数形式 描述
Read_inode(inode) inode的地址是该函数的参数,inode中的i_no域表示从磁盘要读取的具体文件系统的inode,用磁盘上的数据填充参数inode的域
Dirty_inode(inode) inode标记为“脏”
Write_inode(inode) 用参数指定的inode更新某个文件系统的inode。inodei_ino域标识指定磁盘上文件系统的索引节点
Put_inode(inode) 释放参数指定的索引节点对象。释放一个对象并不意味着释放内存,因为其他进程可能仍然在使用这个对象。该方法是可选的
Delete_inode(inode) 删除那些包含文件、磁盘索引节点及VFS索引节点的数据块
Notify_change(dentry, iattr) 依照参数iattr的值修改索引节点的一些属性。如果没有定义该函数,VFS转去执行write_inode()方法
Put_super(super) 释放超级块对象
Write_super(super) 将超级块的信息写回磁盘,该方法是可选的
Statfs(super, buf, bufsize) 将文件系统的统计信息填写在buf缓冲区中
Remount_fs(super, flags, data) 用新的选项重新安装文件系统(当某个安装选项必须被修改时进行调用)
Clear_inode(inode) put_inode类似,但同时也把索引节点对应文件中的数据占用的所有页释放
Umount_begin(super) 中断一个安装操作(只在网络文件系统中使用)

上面这些方法对所有的文件系统都是适用的,但对于一个具体的文件系统来说,可能只用到其中的几个方法。如果那些方法没有定义,则对应的域为空。

索引节点操作inode_operations

索引节点操作是由inode_operations结构来描述的,主要是用来将VFS对索引节点的操作转化为具体文件系统处理相应操作的函数,在fs.h中描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct inode_operations {
int (*create) (struct inode *,struct dentry *,int);
struct dentry * (*lookup) (struct inode *,struct dentry *);
int (*link) (struct dentry *,struct inode *,struct dentry *);
int (*unlink) (struct inode *,struct dentry *);
int (*symlink) (struct inode *,struct dentry *,const char *);
int (*mkdir) (struct inode *,struct dentry *,int);
int (*rmdir) (struct inode *,struct dentry *);
int (*mknod) (struct inode *,struct dentry *,int,int);
int (*rename) (struct inode *, struct dentry *,
struct inode *, struct dentry *);
int (*readlink) (struct dentry *, char *,int);
int (*follow_link) (struct dentry *, struct nameidata *);
void (*truncate) (struct inode *);
int (*permission) (struct inode *, int);
int (*revalidate) (struct dentry *);
int (*setattr) (struct dentry *, struct iattr *);
int (*getattr) (struct dentry *, struct iattr *);
};

表所示为对索引节点的每个方法给予描述。

函数形式 描述
Create(dir, dentry, mode) 在某个目录下,为与dentry目录项相关的常规文件创建一个新的磁盘索引节点
Lookup(dir, dentry) 查找索引节点所在的目录,这个索引节点所对应的文件名就包含在dentry目录项中
Link(old_dentry, dir, new_dentry) 创建一个新的名为new_dentry硬链接,这个新的硬连接指向dir目录下名为old_dentry的文件
unlink(dir, dentry) dir目录删除dentry目录项所指文件的硬链接
symlink(dir, dentry, symname) 在某个目录下,为与目录项相关的符号链创建一个新的索引节点
mkdir(dir, dentry, mode) 在某个目录下,为与目录项对应的目录创建一个新的索引节点
mknod(dir, dentry, mode, rdev) dir目录下,为与目录项对象相关的特殊文件创建一个新的磁盘索引节点。其中参数moderdev分别表示文件的类型和该设备的主码
rename(old_dir, old_dentry, new_dir, new_dentry) old_dir目录下的文件old_dentry移到new_dir目录下,新文件名包含在new_dentry指向的目录项中
readlink(dentry, buffer, buflen) dentry所指定的符号链中对应的文件路径名拷贝到buffer所指定的内存区
follow_link(inode, dir) 解释inode索引节点所指定的符号链;如果该符号链是相对路径名,从指定的dir目录开始进行查找
truncate(inode) 修改索引节点inode所指文件的长度。在调用该方法之前,必须将inode对象的i_size域设置为需要的新长度值
permission(inode, mask) 确认是否允许对inode索引节点所指的文件进行指定模式的访问
revalidate(dentry) 更新由目录项所指定文件的已缓存的属性(通常由网络文件系统调用)
setattr(dentry, attr) 设置目录项的属性
getattr(dentry, attr) 获得目录项的属性

以上这些方法均适用于所有的文件系统,但对某一个具体文件系统来说,可能只用到其中的一部分方法。例如,msdos文件系统其公用索引节点的操作在fs/msdos/namei.c中定义如下:

1
2
3
4
5
6
7
8
9
struct inode_operations msdos_dir_inode_operations = {
create: msdos_create,
lookup: msdos_lookup,
unlink: msdos_unlink,
mkdir: msdos_mkdir,
rmdir: msdos_rmdir,
rename: msdos_rename,
setattr: fat_notify_change,
};

目录项操作

目录项操作是由dentry_operations数据结构来描述的,定义于include/linux/dcache.h中:

1
2
3
4
5
6
7
8
struct dentry_operations {
int (*d_revalidate)(struct dentry *, int);
int (*d_hash) (struct dentry *, struct qstr *);
int (*d_compare) (struct dentry *, struct qstr *, struct qstr *);
int (*d_delete)(struct dentry *);
void (*d_release)(struct dentry *);
void (*d_iput)(struct dentry *, struct inode *);
};

表给出目录项对象的方法及其描述。

函数形成 描述
d_revalidate(dentry) 判定目录项是否有效。默认情况下,VFS函数什么也不做,而网络文件系统可以指定自己的函数
d_hash(dentry, hash) 生成一个哈希值。对目录项哈希表而言,这是一个具体文件系统的哈希函数。参数dentry标识包含该路径分量的目录。参数hash指向一个结构,该结构包含要查找的路径名分量以及由hash函数生成的哈希值
d_compare(dir, name1, name2) 比较两个文件名。name1 应该属于dir所指目录。默认情况下,VFS的这个函数就是常用的字符串匹配。
d_delete(dentry) 如果对目录项的最后一个引用被删除(d_count变为“0”),就调用该方法。默认情况下,VFS的这个函数什么也不做
d_release(dentry) 当要释放一个目录项时(放入slab分配器),就调用该方法。默认情况下,VFS`的这个函数什么也不做
d_iput(dentry, ino) 当要丢弃目录项对应的索引节点时,就调用该方法。默认情况下,VFS的这个函数调用iput()释放索引节点

文件操作

文件操作是由file_operations结构来描述的,定义在fs.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* NOTE:
* read, write, poll, fsync, readv, writev can be called
* without the big kernel lock held in all filesystems.
*/
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, struct dentry *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long ( *get_unmapped_area)( struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
};

这个数据结构就是连接VFS文件操作与具体文件系统的文件操作之间的枢纽,也是编写设备驱动程序的重要接口,后面还会给出进一步的说明。对每个函数的描述如表所示。

函数形式 描述
Owner() 指向模块的指针。只有驱动程序才把这个域置为THIS_MODULE,文件系统一般忽略这个域
llseek(file, offset, whence) 修改文件指针
read(file, buf, count, offset) 从文件的offset处开始读出count个字节,然后增加*offset的值
write(file, buf, count, offset) 从文件的*offset处开始写入count个字节,然后增加*offset的值
readdir(dir, dirent, filldir) 返回dir所指目录的下一个目录项,这个值存入参数dirent;参数filldir存放一个辅助函数的地址,该函数可以提取目录项的各个域
poll(file, poll_table) 检查是否存在关于某文件的操作事件,如果没有则睡眠,直到发生该类操作事件为止
ioctl(inode, file, cmd, arg) 向一个基本硬件设备发送命令。该方法只适用于设备文件
mmap(file, vma) 执行文件的内存映射,并将这个映射放入进程的地址空间
open(inode, file) 通过创建一个新的文件而打开一个文件,并把它链接到相应的索引节点
flush(file) 当关闭对一个打开文件的引用时,就调用该方法。也就是说,减少该文件对象f_count域的值。该方法的实际用途依赖于具体文件系统
release(inode, file) 释放文件对象。当关闭对打开文件的最后一个引用时,也就是说,该文件对象f_count域的值变为 0 时,调用该方法
fsync(file, dentry) file文件在高速缓存中的全部数据写入磁盘
fasync(file, on) 通过信号来启用或禁用异步I/O通告
check_media_change(dev) 检测自上次对设备文件操作以来是否存在介质的改变(可以对块设备使用这一方法,因为它支持可移动介质)
revalidate(dev) 恢复设备的一致性(由网络文件系统使用,这是在确认某个远程设备上的介质已被改变之后才使用)
lock(file, cmd, file_lock) file文件申请一个锁
readv(file, iovec, count, offset) read()类似,所不同的是,readv()把读入的数据放在多个缓冲区中(叫缓冲区向量)
writev(file, buf, iovec, offset) write()类似。所不同的是,writev()把数据写入多个缓冲区中(叫缓冲区向量)

VFS中定义的这个file_operations数据结构相当于一个标准模板,对于一个具体的文件系统来说,可能只用到其中的一些函数。注意,2.2 和 2.4 版在对file_operations进行初始化时有所不同,在 2.2 版中,如果某个函数没有定义,则将其置为NULL,如:

1
2
3
4
5
6
7
8
9
10
11
12
struct file_operations device_fops = {
NULL, /* seek */
device_read, /* read */
device_write, /* write */
NULL, /* readdir */
NULL, /* poll */
NULL, /* ioctl */
NULL, /* mmap */
device_open, /* open */
NULL, /* flush */
device_release /* release */
};

这是标准C的用法,在 2.4 版中,采用了gcc的扩展用法,如:

1
2
3
4
5
6
struct file_operations device_fops = {
read : device_read, /* read */
write : device_write, /* write */
open : device_open, /* open */
release : device_release /* release */
};

这种方式显然简单明了,在设备驱动程序的开发中,经常会用到这种形式。

高速缓存

块高速缓存

Linux支持的文件系统大多以块的形式组织文件,为了减少对物理块设备的访问,在文件以块的形式调入内存后,使用块高速缓存(buffer_cache)对它们进行管理。每个缓冲区由两部分组成,第 1 部分称为缓冲区首部,用数据结构buffer_head表示,第 2 部分是真正的缓冲区内容(即所存储的数据)。由于缓冲区首部不与数据区域相连,数据区域独立存储。因而在缓冲区首部中,有一个指向数据的指针和一个缓冲区长度的字段。图 8.6 给出了一个缓冲区的格式。

缓冲区首部包含如下内容。

  • 用于描述缓冲区内容的信息,包括:所在设备号、起始物理块号、包含在缓冲区中的字节数。
  • 缓冲区状态的域:是否有有用数据、是否正在使用、重新利用之前是否要写回磁盘等。
  • 用于管理的域。

buffer-head数据结构在include\linux\fs.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/*
* Try to keep the most commonly used fields in single cache lines (16
* bytes) to improve performance. This ordering should be
* particularly beneficial on 32-bit processors.
*
* We use the first 16 bytes for the data which is used in searches
* over the block hash lists (ie. getblk() and friends).
*
* The second 16 bytes we use for lru buffer scans, as used by
* sync_buffers() and refill_freelist(). -- sct
*/
struct buffer_head {
/* First cache line: */
struct buffer_head *b_next; /* 哈希队列链表*/
unsigned long b_blocknr; /* 逻辑块号 */
unsigned short b_size; /* 块大小 */
unsigned short b_list; /* 本缓冲区所出现的链表 */
kdev_t b_dev; /* 虚拟设备标示符(B_FREE = free) */
atomic_t b_count; /* 块引用计数器 */
kdev_t b_rdev; /* 实际设备标识符*/
unsigned long b_state; /* 缓冲区状态位图 */
unsigned long b_flushtime; /* 对脏缓冲区进行刷新的时间*/
struct buffer_head *b_next_free;/* 指向`lru/free`链表中的下一个元素 */
struct buffer_head *b_prev_free;/* 指向链表中的上一个元素*/
struct buffer_head *b_this_page;/* 每个页面中的缓冲区链表*/
struct buffer_head *b_reqnext; /*请求队列 */
struct buffer_head **b_pprev; /* 哈希队列的双向链表 */
char * b_data; /* 指向数据块 */
struct page *b_page; /* 这个`bh`所映射的页面*/
void (*b_end_io)(struct buffer_head *bh, int uptodate); /* I/O`结束方法*/
void *b_private; /* 给`b_end_io`保留 */
unsigned long b_rsector; /* 缓冲区在磁盘上的实际位置*/
wait_queue_head_t b_wait; /* 缓冲区等待队列 */
struct inode * b_inode;
struct list_head b_inode_buffers; /* inode`脏缓冲区的循环链表*/
};

其中缓冲区状态在fs.h中定义为枚举类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* bh state bits */
enum bh_state_bits {
BH_Uptodate, /* 如果缓冲区包含有效数据则置 1 */
BH_Dirty, /* 如果缓冲区数据被改变则置 1 */
BH_Lock, /* 如果缓冲区被锁定则置 1*/
BH_Req, /* 如果缓冲区数据无效则置 0 */
BH_Mapped, /* 如果缓冲区有一个磁盘映射则置 1 */
BH_New, /* 如果缓冲区为新且还没有被写出则置 1 */
BH_Async, /* 如果缓冲区是进行end_buffer_io_async I/O同步则置 1 */
BH_Wait_IO, /* 如果我们应该把这个缓冲区写出则置 1 */
BH_launder, /* 如果我们应该“清洗”这个缓冲区则置 1 */
BH_JBD, /* 如果与journal_head相连接则置 1 */
BH_PrivateStart,/* 这不是一个状态位,但是,第 1 位由其他实体用于私有分配*/
}

显然一个缓冲区可以同时具有上述状态的几种。

块高速缓存的管理很复杂,下面先对空缓冲区、空闲缓冲区、正使用的缓冲区、缓冲区的大小以及缓冲区的类型作一个简短的介绍。

缓冲区可以分为两种,一种是包含了有效数据的,另一种是没有被使用的,即空缓冲区。具有有效数据并不能表明某个缓冲区正在被使用,毕竟,在同一时间内,被进程访问的缓冲区(即处于使用状态)只有少数几个。当前没有被进程访问的有效缓冲区和空缓冲区称为空闲缓冲区。其实,buffer_head结构中的b_count就可以反映出缓冲区是否处于使用状态。如果它为 0,则缓冲区是空闲的。大于 0,则缓冲区正被进程访问。

缓冲区的大小不是固定的,当前Linux支持 5 种大小的缓冲区,分别是 512、1024、2048、4096、8192 字节。Linux所支持的文件系统都使用共同的块高速缓存,在同一时刻,块高速缓存中存在着来自不同物理设备的数据块,为了支持这些不同大小的数据块,Linux使用了几种不同大小的缓冲区。当前的Linux缓冲区有 3 种类型,在include/linux/fs.h中有如下的定义:

1
2
3
#define BUF_CLEAN 0 /*未使用的、干净的缓冲区*/
#define BUF_LOCKED 1 /*被锁定的缓冲区,正等待写入*/
#define BUF_DIRTY 2 /*脏的缓冲区,其中有有效数据,需要写回磁盘*/

VFS使用了多个链表来管理块高速缓存中的缓冲区。

首先,对于包含了有效数据的缓冲区,用一个哈希表来管理,用hash_table来指向这个哈希表。哈希索引值由数据块号以及其所在的设备标识号计算(散列)得到。所以在buffer_head这个结构中有一些用于哈希表管理的域。使用哈希表可以迅速地查找到所要寻找的数据块所在的缓冲区。

对于每一种类型的未使用的有效缓冲区,系统还使用一个LRU(最近最少使用)双链表管理,即lru-list链。由于共有 3 种类型的缓冲区,所以有 3 个这样的LRU链表。当需要访问某个数据块时,系统采取如下算法。

首先,根据数据块号和所在设备号在块高速缓存中查找,如果找到,则将它的b_count域加 1,因为这个域正是反映了当前使用这个缓冲区的进程数。如果这个缓冲区同时又处于某个LRU链中,则将它从LRU链中解开。如果数据块还没有调入缓冲区,则系统必须进行磁盘I/O操作,将数据块调入块高速缓存,同时将空缓冲区分配一个给它。如果块高速缓存已满(即没有空缓冲区可供分配),则从某个LRU链首取下一个,先看是否置了“脏”位,如已置,则将它的内容写回磁盘。然后清空内容,将它分配给新的数据块。

在缓冲区使用完了后,将它的b_count域减 1,如果b_count变为 0,则将它放在某个LRU链尾,表示该缓冲区已可以重新利用。为了配合以上这些操作,以及其他一些多块高速缓存的操作,系统另外使用了几个链表,主要是:

  • 对于每一种大小的空闲缓冲区,系统使用一个链表管理,即free_list链。
  • 对于空缓冲区,系统使用一个unused_list链管理。

以上几种链表都在fs/buffer.c定义。

Linux中,用bdflush守护进程完成对块高速缓存的一般管理。bdflush守护进程是一个简单的内核线程,在系统启动时运行,它在系统中注册的进程名称为kflushd,你可以使用ps命令看到此系统进程。它的一个作用是监视块高速缓存中的“脏”缓冲区,在分配或丢弃缓冲区时,将对“脏”缓冲区数目作一个统计。通常情况下,该进程处于休眠状态,当块高速缓存中“脏”缓冲区的数目达到一定的比例,默认是 60%,该进程将被唤醒。但是,如果系统急需,则在任何时刻都可以唤醒这个进程。使用update命令可以看到和改变这个数值。

1
# update -d

当有数据写入缓冲区使之变成“脏”时,所有的“脏”缓冲区被连接到一个BUF_DIRTY_LRU链表中,bdflush会将适当数目的缓冲区中的数据块写到磁盘上。这个数值的缺省值为 500,可以用update命令改变这个值。

另一个与块高速缓存管理相关的是update命令,它不仅仅是一个命令,还是一个后台进程。当以超级用户的身份运行时(在系统初始化时),它将周期性调用系统服务例程将老的“脏”缓冲区中内容“冲刷”到磁盘上去。它所完成的这个工作与bdflush类似,不同之处在于,当一个“脏”缓冲区完成这个操作后, 它将把写入到磁盘上的时间标记到buffer_head结构中。update每次运行时它将在系统的所有“脏”缓冲区中查找那些“冲刷”时间已经超过一定期限的,这些过期缓冲区都要被写回磁盘。

索引节点高速缓存

VFS也用了一个高速缓存来加快对索引节点的访问,和块高速缓存不同的一点是每个缓冲区不用再分为两个部分了,因为inode结构中已经有了类似于块高速缓存中缓冲区首部的域。索引节点高速缓存的实现代码全部在fs/inode.c

索引节点链表

每个索引节点可能处于哈希表中,也可能同时处于下列“类型”链表的一种中:

  • in_use有效的索引节点,即i_count > 0i_nlink > 0(参看前面的inode结构)
  • dirty类似于in_use ,但还“脏”;
  • unused有效的索引节点但还没使用,即i_count = 0

这几个链表定义如下:

1
2
3
4
static LIST_HEAD(inode_in_use);
static LIST_HEAD(inode_unused);
static struct list_head *inode_hashtable;
static LIST_HEAD(anon_hash_chain); /* for inodes with NULL i_sb */

因此,索引节点高速缓存的结构概述如下。

  • 全局哈希表inode_hashtable,其中哈希值是根据每个超级块指针的值和 32 位索引节点号而得。对没有超级块的索引节点inode->i_sb == NULL,则将其加入到anon_hash_chain链表的首部。例如,net/socket.csock_alloc()函数,通过调用fs/inode.cget_empty_inode()创建的套接字是一个匿名索引节点,这个节点就加入到了anon_hash_chain链表。
  • 正在使用的索引节点链表。全局变量inode_in_use指向该链表中的首元素和尾元素。函数get_empty_inode()获得一个空节点,get_new_inode()获得一个新节点,通过这两个函数新分配的索引节点就加入到这个链表中。
  • 未用索引节点链表。全局变量inode_unusednext域和prev域分别指向该链表中的首元素和尾元素。
  • 脏索引节点链表。由相应超级块的s_dirty域指向该链表中的首元素和尾元素。
  • inode对象的缓存,定义如下:static kmem_cache_t * inode_cachep,这是一个Slab缓存,用于分配和释放索引节点对象。

索引节点的i_hash域指向哈希表,i_list指向in_useunuseddirty某个链表。所有这些链表都受单个自旋锁inode_lock的保护,其定义如下:

1
2
3
4
5
6
7
/*
* A simple spinlock to protect the list manipulations.
*
* NOTE! You also have to own the lock if you change
* the i_state of an inode while it is in use..
*/
static spinlock_t inode_lock = SPIN_LOCK_UNLOCKED;

索引节点高速缓存的初始化是由inode_init()实现的,而这个函数是在系统启动时由init/main.c中的start_kernel()函数调用的。inode_init()只有一个参数,表示索引节点高速缓存所使用的物理页面数。因此,索引节点高速缓存可以根据可用物理内存的大小来进行配置,例如,如果物理内存足够大,就可以创建一个大的哈希表。

索引节点状态的信息存放在数据结构inodes_stat_t中,在fs/fs.h中定义如下:

1
2
3
4
5
6
struct inodes_stat_t {
int nr_inodes;
int nr_unused;
int dummy[5];
};
extern struct inodes_stat_t inodes_stat

用户程序可以通过/proc/sys/fs/inode-nr/proc/sys/fs/inode-state获得索引节点高速缓存中索引节点总数及未用索引节点数。

索引节点高速缓存的工作过程

为了帮助大家理解索引节点高速缓存如何工作,我们来跟踪一下在打开Ext2文件系统的一个常规文件时,相应索引节点的作用。

1
2
fd = open("file", O_RDONLY);
close(fd);

open()系统调用是由fs/open.c中的sys_open函数实现的,而真正的工作是由fs/open.c中的filp_open()函数完成的,filp_open()函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct file *filp_open(const char * filename, int flags, int mode)
{
int namei_flags, error;
struct nameidata nd;
namei_flags = flags;
if ((namei_flags+1) & O_ACCMODE)
namei_flags++;
if (namei_flags & O_TRUNC)
namei_flags |= 2;
error = open_namei(filename, namei_flags, mode, &nd);
if (!error)
return dentry_open(nd.dentry, nd.mnt, flags);
return ERR_PTR(error);
}

其中nameidata结构在fs.h中定义如下:

1
2
3
4
5
6
7
struct nameidata {
struct dentry *dentry;
struct vfsmount *mnt;
struct qstr last;
unsigned int flags;
int last_type;
};

这个数据结构是临时性的,其中,我们主要关注dentrymnt域。dentry结构我们已经在前面介绍过,而vfsmount结构记录着所属文件系统的安装信息,例如文件系统的安装点、文件系统的根节点等。filp_open()主要调用以下两个函数。

  • open_namei():填充目标文件所在目录的dentry结构和所在文件系统的vfsmount结构。在dentry结构中dentry->d_inode就指向目标文件的索引节点。
  • dentry_open():建立目标文件的一个“上下文”,即file数据结构,并让它与当前进程的task_strrct结构挂上钩。同时,在这个函数中,调用了具体文件系统的打开函数,即f_op->open()。该函数返回指向新建立的file结构的指针。

open_namei()函数通过path_walk()与目录项高速缓存(即目录项哈希表)打交道,而path_walk()又调用具体文件系统的inode_operations->lookup()方法;该方法从磁盘找到并读入当前节点的目录项,然后通过iget(sb, ino),根据索引节点号从磁盘读入相应索引节点并在内存建立起相应的inode结构,这就到了我们讨论的索引节点高速缓存。

当索引节点读入内存后,通过调用d_add(dentry, inode),就将dentry结构和inode结构之间的链接关系建立起来。两个数据结构之间的联系是双向的。一方面,dentry结构中的指针d_inode指向inode结构,这是一对一的关系,因为一个目录项只对应着一个文件。反之则不然,同一个文件可以有多个不同的文件名或路径(通过系统调用link()建立,注意与符号连接的区别,那是由symlink()建立的),所以从inode结构到dentry结构的方向是一对多的关系。因此,inode结构的i_ dentry是个队列,dentry结构通过其队列头部d_alias挂入相应inode结构的队列中。

为了进一步说明索引节点高速缓存,我们来进一步考察iget()。当我们打开一个文件时,就调用了iget()函数,而iget真正调用的是iget4(sb, ino, NULL, NULL)函数,该函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct inode *iget4(struct super_block *sb, unsigned long ino, find_inode_t find_actor, void *opaque)
{
struct list_head * head = inode_hashtable + hash(sb,ino);
struct inode * inode;
spin_lock(&inode_lock);
inode = find_inode(sb, ino, head, find_actor, opaque);
if (inode) {
__iget(inode);
spin_unlock(&inode_lock);
wait_on_inode(inode);
return inode;
}
spin_unlock(&inode_lock);
/*
* get_new_inode() will do the right thing, re-trying the search
* in case it had to block at any point.
*/
return get_new_inode(sb, ino, head, find_actor, opaque);
}

下面对以上代码给出进一步的解释。

  • inode结构中有个哈希表inode_hashtable,首先在inode_lock锁的保护下,通过find_ inode函数在哈希表中查找目标节点的inode结构,由于索引节点号只有在同一设备上时才是唯一的,因此,在哈希计算时要把索引节点所在设备的super_block结构的地址也结合进去。如果在哈希表中找到该节点,则其引用计数i_count加 1;如果i_count在增加之前为 0,说明该节点不“脏”,则该节点当前肯定处于inode_unused list队列中,于是,就把该节点从这个队列删除而插入inode_in_use队列;最后,把inodes_stat.nr_unused减 1。
  • 如果该节点当前被加锁,则必须等待,直到解锁,以便确保iget4()返回一个未加锁的节点。
  • 如果在哈希表中没有找到该节点,说明目标节点的inode结构还不在内存,因此,调用get_new_inode()从磁盘上读入相应的索引节点并建立起一个inode结构,并把该结构插入到哈希表中。
  • get_new_inode()给出进一步的说明,该函数从slab缓存区中分配一个新的inode结构,但是这个分配操作有可能出现阻塞,于是,就应当解除保护哈希表的inode_lock自旋锁,以便在哈希表中再次进行搜索。如果这次在哈希表中找到这个索引节点,就通过__iget把该节点的引用计数加 1,并撤销新分配的节点;如果在哈希表中还没有找到,就使用新分配的索引节点。因此,把该索引节点的一些域先初始化为必须的值,然后调用具体文件系统的sb->s_op->read_inode()域填充该节点的其他域。这就把我们从索引节点高速缓存带到了某个具体文件系统的代码中。当s_op->read_inode()方法正在从磁盘读索引节点时,该节点被加锁(i_state = I_LOCK);当read_inode()返回时,该节点的锁被解除,并且唤醒所有等待者。

目录高速缓存

每个目录项对象属于以下 4 种状态之一。

  • 空闲状态:处于该状态的目录项对象不包含有效的信息,还没有被VFS使用。它对应的内存区由slab分配器进行管理。
  • 未使用状态:处于该状态的目录项对象当前还没有被内核使用。该对象的引用计数器d_count的值为NULL。但其d_inode域仍然指向相关的索引节点。该目录项对象包含有效的信息,但为了在必要时回收内存,它的内容可能被丢弃。
  • 正在使用状态:处于该状态的目录项对象当前正在被内核使用。该对象的引用计数器d_count的值为正数,而其d_inode域指向相关的索引节点对象。该目录项对象包含有效的信息,并且不能被丢弃。
  • 负状态:与目录项相关的索引节点不复存在,那是因为相应的磁盘索引节点已被删除。该目录项对象的d_inode域置为NULL,但该对象仍然被保存在目录项高速缓存中,以便后续对同一文件目录名的查找操作能够快速完成,术语“负的”容易使人误解,因为根本不涉及任何负值。

为了最大限度地提高处理这些目录项对象的效率,Linux使用目录项高速缓存,它由以下两种类型的数据结构组成。

  • 处于正在使用、未使用或负状态的目录项对象的集合。
  • 一个哈希表,从中能够快速获取与给定的文件名和目录名对应的目录项对象。如果访问的对象不在目录项高速缓存中,哈希函数返回一个空值。

目录项高速缓存的作用也相当于索引节点高速缓存的控制器。内核内存中,目录项可能已经不使用,但与其相关的索引节点并不被丢弃,这是由于目录项高速缓存仍在使用它们,因此,索引节点的i_count域不为空。于是,这些索引节点对象还保存在RAM中,并能够借助相应的目录项快速引用它们。

所有“未使用”目录项对象都存放在一个“最近最少使用”的双向链表中,该链表按照插入的时间排序。一旦目录项高速缓存的空间开始变小,内核就从链表的尾部删除元素,使得多数最近经常使用的对象得以保留。LRU链表的首元素和尾元素的地址存放在变量dentry_unused中的next域和prev域中。目录项对象的d_lru域包含的指针指向该链表中相邻目录的对象。

每个“正在使用”的目录项对象都被插入一个双向链表中,该链表由相应索引节点对象的i_dentry域所指向。目录项对象的d_alias域存放链表中相邻元素的地址。当指向相应文件的最后一个硬链接被删除后,一个“正在使用”的目录项对象可能变成“负”状态。在这种情况下,该目录项对象被移到“未使用” 目录项对象组成的LRU链表中。每当内核缩减目录项高速缓存时,“负”状态目录项对象就朝着LRU链表的尾部移动,这样一来,这些对象就逐渐被释放。

哈希表是由dentry_hashtable数组实现的。数组中的每个元素是一个指向链表的指针,这种链表就是把具有相同哈希表值的目录项进行散列而形成的。该数组的长度取决于系统已安装RAM的数量。目录项对象的d_hash域包含指向具有相同hash值的链表中的相邻元素。哈希函数产生的值是由目录及文件名的目录项对象的地址计算出的。

文件系统的注册、安装与卸载

文件系统的注册

每个文件系统都有一个初始化例程,它的作用就是在VFS中进行注册,即填写一个叫做file_system_type的数据结构,该结构包含了文件系统的名称以及一个指向对应的VFS超级块读取例程的地址,所有已注册的文件系统的file_system_type结构形成一个链表,为区别后面将要说到的已安装的文件系统形成的另一个链表,我们把这个链表称为注册链表。

file_system_type的数据结构在fs.h中定义如下:

1
2
3
4
5
6
7
8
struct file_system_type {
const char *name;
int fs_flags;
struct super_block *(*read_super) (struct super_block *, void *, int);
struct module *owner;
struct file_system_type * next;
struct list_head fs_supers;
};

对其中几个域的说明如下。

  • name:文件系统的类型名,以字符串的形式出现。
  • fs_flags:指明具体文件系统的一些特性,有关标志定义于fs.h中:
1
2
3
4
5
6
7
8
9
10
11
12
13
/* public flags for file_system_type */
#define FS_REQUIRES_DEV 1
#define FS_NO_DCACHE 2 /* Only dcache the necessary things. */
#define FS_NO_PRELIM 4 /* prevent preloading of dentries, even if
* FS_NO_DCACHE is not set.
*/

#define FS_SINGLE 8 /* Filesystem that can have only one superblock */
#define FS_NOMOUNT 16 /* Never mount from userland */
#define FS_LITTER 32 /* Keeps the tree in dcache */
#define FS_ODD_RENAME 32768 /* Temporary stuff; will go away as soon
* as nfs_rename() will be cleaned up
*/

对某些常用标志的说明如下。

  1. 有些虚拟的文件系统,如pipe、共享内存等,根本不允许由用户进程通过系统调用mount()来安装。这样的文件系统其fs_flags中的FS_NOMOUNT标志位为 1。
  2. 一般的文件系统类型要求有物理的设备作为其物质基础,其fs_flags中的FS_REQUIRES_DEV标志位为 1,这些文件系统如Ext2Minixufs等。
  3. 有些虚拟文件系统在安装了同类型中的第 1 个“设备”,从而创建了其超级块的super_block数据结构,在安装同一类型中的其他设备时就共享已存在的super_block结构,而不再有自己的超级块结构。此时fs_flags中的FS_SINGLE标志位为 1,表示整个文件系统只有一个超级块,而不像一般的文件系统类型那样,每个具体的设备上都有一个超级块。
  • read_super:这是各种文件系统读入其超级块的函数指针。因为不同的文件系统其超级块不同,因此其读入函数也不同。
  • owner:如果file_system_type所代表的文件系统是通过可安装模块实现的,则该指针指向代表着具体模块的module结构。如果文件系统是静态地链接到内核,则这个域为NULL
    • 实际上,你只需要把这个域置为THIS_MODLUE(这是个一个宏),它就能自动地完成上述工作。
  • next:把所有的file_system_type结构链接成单项链表的链接指针,变量file_systems指向这个链表。这个链表是一个临界资源,受file_systems_lock自旋读写锁的保护。
  • fs_supers:这个域是一个双向链表。链表中的元素是超级块结构。如前说述,每个文件系统都有一个超级块,但有些文件系统可能被安装在不同的设备上,而且每个具体的设备都有一个超级块,这些超级块就形成一个双向链表。

搞清楚这个数据结构的各个域以后,就很容易理解注册函数register_filesystem(),该函数定义于fs/super.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
 /**
* register_filesystem - register a new filesystem
* @fs: the file system structure
*
* Adds the file system passed to the list of file systems the kernel
* is aware of for mount and other syscalls. Returns 0 on success,
* or a negative errno code on an error.
*
* The &struct file_system_type that is passed is linked into the kernel
* structures and must not be freed until the file system has been
* unregistered.
*/
int register_filesystem(struct file_system_type * fs)
{
int res = 0;
struct file_system_type ** p;
if (!fs)
return -EINVAL;
if (fs->next)
return -EBUSY;
INIT_LIST_HEAD(&fs->fs_supers);
write_lock(&file_systems_lock);
p = find_filesystem(fs->name);
if (*p)
res = -EBUSY;
else
*p = fs;
write_unlock(&file_systems_lock);
return res;
}

find_filesystem()函数在同一个文件中定义如下:

1
2
3
4
5
6
7
8
static struct file_system_type **find_filesystem(const char *name)
{
struct file_system_type **p;
for (p=&file_systems; *p; p=&(*p)->next)
if (strcmp((*p)->name,name) == 0)
break;
return p;
}

注意,对注册链表的操作必须互斥地进行,因此,对该链表的查找加了写锁write_lock。文件系统注册后,还可以撤消这个注册,即从注册链表中删除一个file_system_type结构,此后系统不再支持该种文件系统。fs/super.c中的unregister_filesystem()函数就是起这个作用的,它在执行成功后返回 0,如果注册链表中本来就没有指定的要删除的结构,则返回-1,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* unregister_filesystem - unregister a file system
* @fs: filesystem to unregister
*
* Remove a file system that was previously successfully registered
* with the kernel. An error is returned if the file system is not found.
* Zero is returned on a success.
*
* Once this function has returned the &struct file_system_type structure
* may be freed or reused.
*/

int unregister_filesystem(struct file_system_type * fs)
{
struct file_system_type ** tmp;
write_lock(&file_systems_lock);
tmp = &file_systems;
while (*tmp) {
if (fs == *tmp) {
*tmp = fs->next;
fs->next = NULL;
write_unlock(&file_systems_lock);
return 0;
}
tmp = &(*tmp)->next;
}
write_unlock(&file_systems_lock);
return -EINVAL;
}

文件系统的安装

要使用一个文件系统,仅仅注册是不行的,还必须安装这个文件系统。在安装Linux时,硬盘上已经有一个分区安装了Ext2文件系统,它是作为根文件系统的,根文件系统在启动时自动安装。其实,在系统启动后你所看到的文件系统,都是在启动时安装的。如果需要自己(一般是超级用户)安装文件系统,则需要指定 3 种信息:文件系统的名称、包含文件系统的物理块设备、文件系统在已有文件系统中的安装点。例如:

1
$ mount -t iso9660 /dev/hdc /mnt/cdrom

其中,iso9660就是文件系统的名称,/dev/hdc是包含文件系统的物理块设备,/mnt/cdrom是将要安装到的目录,即安装点。从这个例子可以看出,安装一个文件系统实际上是安装一个物理设备。

把一个文件系统(或设备)安装到一个目录点时要用到的主要数据结构为vfsmount,定义于include/linux/mount.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct vfsmount
{
struct list_head mnt_hash;
struct vfsmount *mnt_parent; /* fs we are mounted on */
struct dentry *mnt_mountpoint; /* dentry of mountpoint */
struct dentry *mnt_root; /* root of the mounted tree */
struct super_block *mnt_sb; /* pointer to superblock */
struct list_head mnt_mounts; /* list of children, anchored here */
struct list_head mnt_child; /* and going through their mnt_child */
atomic_t mnt_count;
int mnt_flags;
char *mnt_devname; /* Name of device e.g. /dev/dsk/hda1 */
struct list_head mnt_list;
};

下面对结构中的主要域给予进一步说明。

  • 为了对系统中的所有安装点进行快速查找,内核把它们按哈希表来组织,mnt_hash就是形成哈希表的队列指针。
  • mnt_mountpoint是指向安装点dentry结构的指针。而dentry指针指向安装点所在目录树中根目录的dentry结构。
  • mnt_parent是指向上一层安装点的指针。如果当前的安装点没有上一层安装点(如根设备),则这个指针为NULL。同时,vfsmount结构中还有mnt_mountsmnt_child两个队列头,只要上一层vfsmount结构存在,就把当前vfsmount结构中mnt_child链入上一层vfsmount结构的mnt_mounts队列中。这样就形成一个设备安装的树结构,从一个vfsmount结构的mnt_mounts队列开始,可以找到所有直接或间接安装在这个安装点上的其他设备。
  • mnt_sb指向所安装设备的超级块结构super_blaock
  • mnt_list是指向vfsmount结构所形成链表的头指针。

另外,系统还定义了vfsmntlist变量,指向mnt_list队列。对这个数据结构的进一步理解请看后面文件系统安装的具体实现过程。

文件系统的安装选项,也就是vfsmount结构中的安装标志mnt_flagslinux/fs.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
* These are the fs-independent mount-flags: up to 32 flags are supported
*/
#define MS_RDONLY 1 /* Mount read-only */
#define MS_NOSUID 2 /* Ignore suid and sgid bits */
#define MS_NODEV 4 /* Disallow access to device special files */
#define MS_NOEXEC 8 /* Disallow program execution */
#define MS_SYNCHRONOUS 16 /* Writes are synced at once */
#define MS_REMOUNT 32 /* Alter flags of a mounted FS */
#define MS_MANDLOCK 64 /* Allow mandatory locks on an FS */
#define MS_NOATIME 1024 /* Do not update access times. */
#define MS_NODIRATIME 2048 /* Do not update directory access times */
#define MS_BIND 4096
#define MS_MOVE 8192
#define MS_REC 16384
#define MS_VERBOSE 32768
#define MS_ACTIVE (1<<30)
#define MS_NOUSER (1<<31)

从定义可以看出,每个标志对应 32 位中的一位。安装标志是针对整个文件系统中的所有文件的。例如,如果MS_NOSUID标志为 1,则整个文件系统中所有可执行文件的suid标志位都不起作用了。

安装根文件系统

当系统启动时,就要在变量ROOT_DEV中寻找包含根文件系统的磁盘主码。当编译内核或向最初的启动装入程序传递一个合适的选项时,根文件系统可以被指定为/dev目录下的一个设备文件。类似地,根文件系统的安装标志存放在root_mountflags变量中。用户可以指定这些标志,这是通过对已编译的内核映像执行/sbin/rdev外部程序,或者向最初的启动装入程序传递一个合适的选项来达到的。根文件系统的安装函数为mount_root()

安装一个常规文件系统

一旦在系统中安装了根文件系统,就可以安装其他的文件系统。每个文件系统都可以安装在系统目录树中的一个目录上。

前面我们介绍了以命令方式来安装文件系统,在用户程序中要安装一个文件系统则可以调用mount()系统调用。mount()系统调用在内核的实现函数为sys_mount(),其代码在fs/namespace.c中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
asmlinkage long sys_mount(char * dev_name, char * dir_name, char * type,
unsigned long flags, void * data)
{
int retval;
unsigned long data_page;
unsigned long type_page;
unsigned long dev_page;
char *dir_page;
retval = copy_mount_options (type, &type_page);
if (retval < 0)
return retval;
dir_page = getname(dir_name);
retval = PTR_ERR(dir_page);
if (IS_ERR(dir_page))
goto out1;
retval = copy_mount_options (dev_name, &dev_page);
if (retval < 0)
goto out2;
retval = copy_mount_options (data, &data_page);
if (retval < 0)
goto out3;
lock_kernel();
retval = do_mount((char*)dev_page, dir_page, (char*)type_page, flags, (void*)data_page);
unlock_kernel();
free_page(data_page);
out3:
free_page(dev_page);
out2:
putname(dir_page);
out1:
free_page(type_page);
return retval;
}

下面给出进一步的解释。

  • 参数dev_name为待安装文件系统所在设备的路径名,如果不需要就为空(例如,当待安装的是基于网络的文件系统时);dir_name则是安装点(空闲目录)的路径名;type是文件系统的类型,必须是已注册文件系统的字符串名(如“Ext2”,“MSDOS”等);flags是安装模式,如前面所述。data指向一个与文件系统相关的数据结构(可以为NULL)。
  • copy_mount_options()getname()函数将结构形式或字符串形式的参数值从用户空间拷贝到内核空间。这些参数值的长度均以一个页面为限,但是getname()在复制时遇到字符串结尾符“\0”就停止,并返回指向该字符串的指针;而copy_mount_options()则拷贝整个页面,并返回该页面的起始地址。

该函数调用的主要函数为do_mount()do_mount()执行期间要加内核锁,不过这个锁是针对SMP,我们暂不考虑。do_mount()的实现代码在fs/namespace.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
long do_mount(char * dev_name, char * dir_name, char *type_page,
unsigned long flags, void *data_page)
{
struct nameidata nd;
int retval = 0;
int mnt_flags = 0;
/* Discard magic */
if ((flags & MS_MGC_MSK) == MS_MGC_VAL)
flags &= ~MS_MGC_MSK;
/* Basic sanity checks */
if (!dir_name || !*dir_name || !memchr(dir_name, 0, PAGE_SIZE))
return -EINVAL;
if (dev_name && !memchr(dev_name, 0, PAGE_SIZE))
return -EINVAL;
/* Separate the per-mountpoint flags */
if (flags & MS_NOSUID)
mnt_flags |= MNT_NOSUID;
if (flags & MS_NODEV)
mnt_flags |= MNT_NODEV;
if (flags & MS_NOEXEC)
mnt_flags |= MNT_NOEXEC;
flags &= ~(MS_NOSUID|MS_NOEXEC|MS_NODEV);
/* ... and get the mountpoint */
if (path_init(dir_name, LOOKUP_FOLLOW|LOOKUP_POSITIVE, &nd))
retval = path_walk(dir_name, &nd);
if (retval)
return retval;
if (flags & MS_REMOUNT)
retval = do_remount(&nd, flags & ~MS_REMOUNT, mnt_flags, data_page);
else if (flags & MS_BIND)
retval = do_loopback(&nd, dev_name, flags & MS_REC);
else if (flags & MS_MOVE)
retval = do_move_mount(&nd, dev_name);
else
retval = do_add_mount(&nd, type_page, flags, mnt_flags, dev_name, data_page);
path_release(&nd);
return retval;
}

下面对函数中的主要代码给予解释。

  • MS_MGC_VALMS_MGC_MSK是在以前的版本中定义的安装标志和掩码,现在的安装标志中已经不使用这些魔数了,因此,当还有这个魔数时,则丢弃它。
  • 对参数dir_namedev_name进行基本检查,注意!dir_name!*dir_name的不同,前者指指向字符串的指针为不为空,而后者指字符串不为空。memchr()函数在指定长度的字符串中寻找指定的字符,如果字符串中没有结尾符“\0”,也是一种错误。前面已说过,对于基于网络的文件系统dev_name可以为空。
  • 把安装标志为MS_NOSUID、MS_NOEXECMS_NODEV的 3 个标志位从flags分离出来,放在局部安装标志变量mnt_flags中。
  • 函数path_init()path_walk()寻找安装点的dentry数据结构,找到的dentry结构存放在局部变量nddentry域中。
  • 如果flags中的MS_REMOUNT标志位为 1,就表示所要求的只是改变一个原已安装设备的安装方式,例如从“只读“安装方式改为“可写”安装方式,这是通过调用do_remount()函数完成的。
  • 如果flags中的MS_BIND标志位为 1,就表示把一个“回接”设备捆绑到另一个对象上。回接设备是一种特殊的设备(虚拟设备),而实际上并不是一种真正设备,而是一种机制,这种机制提供了把回接设备回接到某个可访问的常规文件或块设备的手段。通常在/dev目录中有/dev/loop0/dev/loop1两个回接设备文件。调用do_loopback()来实现回接设备的安装。
  • 如果flags中的MS_MOVE标志位为 1,就表示把一个已安装的设备可以移到另一个安装点,这是通过调用do_move_mount()函数来实现的。
  • 如果不是以上 3 种情况,那就是一般的安装请求,于是把安装点加入到目录树中,这是通过调用do_add_mount()函数来实现的,而do_add_mount()首先调用do_kern_mount()函数形成一个安装点,该函数的代码在fs/super.c中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
struct vfsmount *do_kern_mount(char *type, int flags, char *name, void *data)
{
struct file_system_type * fstype;
struct vfsmount *mnt = NULL;
struct super_block *sb;

if (!type || !memchr(type, 0, PAGE_SIZE))
return ERR_PTR(-EINVAL);

/* we need capabilities... */
if (!capable(CAP_SYS_ADMIN))
return ERR_PTR(-EPERM);

/* ... filesystem driver... */

fstype = get_fs_type(type);
if (!fstype)
return ERR_PTR(-ENODEV);
/* ... allocated vfsmount... */
mnt = alloc_vfsmnt();
if (!mnt) {
mnt = ERR_PTR(-ENOMEM);
goto fs_out;
}
set_devname(mnt, name);
/* get locked superblock */
if (fstype->fs_flags & FS_REQUIRES_DEV)
sb = get_sb_bdev(fstype, name, flags, data);
else if (fstype->fs_flags & FS_SINGLE)
sb = get_sb_single(fstype, flags, data);
else
sb = get_sb_nodev(fstype, flags, data);

if (IS_ERR(sb)) {
free_vfsmnt(mnt);
mnt = (struct vfsmount *)sb;
goto fs_out;
}
if (fstype->fs_flags & FS_NOMOUNT)
sb->s_flags |= MS_NOUSER;

mnt->mnt_sb = sb;
mnt->mnt_root = dget(sb->s_root);
mnt->mnt_mountpoint = mnt->mnt_root;
mnt->mnt_parent = mnt;
up_write(&sb->s_umount);
fs_out:
put_filesystem(fstype);
return mnt;
}

对该函数的解释如下。

  • 只有系统管理员才具有安装一个设备的权力,因此首先要检查当前进程是否具有这种权限。
  • get_fs_type()函数根据具体文件系统的类型名在file_system_file链表中找到相应的结构。
  • alloc_vfsmnt()函数调用slab分配器给类型为vfsmount结构的局部变量mnt分配空间,并进行相应的初始化。
  • set_devname()函数设置设备名。
  • 一般的文件系统类型要求有物理的设备作为其物质基础,如果fs_flags中的FS_REQUIRES_DEV标志位为 1,说明这就是正常的文件系统类型,如Ext2、mnix等。对于这种文件系统类型,通过调用get_sb_bdev()从待安装设备上读其超级块。
  • 如果fs_flags中的FS_SINGLE标志位为 1,说明整个文件系统只有一个类型,也就是说,这是一种虚拟的文件系统类型。这种文件类型在安装了同类型的第 1 个“设备”,通过调用get_sb_single()创建了超级块super_block结构后,再安装的同类型设备就共享这个数据结构。但是像Ext2这样的文件系统类型在每个具体设备上都有一个超级块。
  • 还有些文件系统类型的fs_flags中的FS_NOMOUNT、FS_REUIRE_DEV以及FS_SINGLE标志位全都为 0,那么这些所谓的文件系统其实是“虚拟的”,通常只是用来实现某种机制或者规程,所以根本就没有对应的物理设备。对于这样的文件系统类型都是通过get_sb_nodev()来生成一个super_block结构的。
  • 如果文件类型fs_flagsFS_NOMOUNT标志位为 1,说明根本就没有用户进行安装,因此,把超级块中的MS_NOUSER标志位置 1。
  • mnt->mnt_sb指向所安装设备的超级块sbmnt->mnt_root指向其超级块的根b->s_rootdget()函数把dentry的引用计数count加 1;mnt->mnt_mountpoint也指向超级块的根,而mnt->mnt_parent指向自己。到此为止,仅仅形成了一个安装点,但还没有把这个安装点挂接在目录树上。

下面我们来看do_add_mount()的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static int do_add_mount(struct nameidata *nd, char *type, int flags,
int mnt_flags, char *name, void *data)
{
struct vfsmount *mnt = do_kern_mount(type, flags, name, data);
int err = PTR_ERR(mnt);
if (IS_ERR(mnt))
goto out;
down(&mount_sem);
/* Something was mounted here while we slept */
while(d_mountpoint(nd->dentry) && follow_down(&nd->mnt, &nd->dentry)) ;
err = -EINVAL;
if (!check_mnt(nd->mnt))
goto unlock;
/* Refuse the same filesystem on the same mount point */
err = -EBUSY;
if (nd->mnt->mnt_sb == mnt->mnt_sb && nd->mnt->mnt_root == nd->dentry)
goto unlock;
mnt->mnt_flags = mnt_flags;
err = graft_tree(mnt, nd);
nlock:
up(&mount_sem);
mntput(mnt);
out:
return err;
}

下面是对以上代码的解释。

  • 首先检查do_kern_mount()所形成的安装点是否有效。
  • do_mount()函数中,path_init()path_walk()函数已经找到了安装点的dentry结构、inode结构以及vfsmount结构,并存放在类型为nameidata的局部变量nd中,在do_add_mount()中通过参数传递了过来。
  • 但是,在do_kern_mount()函数中从设备上读入超级块的过程是个较为漫长的过程,当前进程在等待从设备上读入超级块的过程中几乎可肯定要睡眠,这样就有可能另一个进程捷足先登抢先将另一个设备安装到了同一个安装点上。d_mountpoint()函数就是检查是否发生了这种情况。如果确实发生了这种情况,其对策就是调用follow_down()前进到已安装设备的根节点,并且通过while循环进一步检测新的安装点,直到找到一个空安装点为止。
  • 如果在同一个安装点上要安装两个同样的文件系统,则出错。
  • 调用graft_tree()mnt与安装树挂接起来,完成最终的安装。
  • 至此,设备的安装就完成了。

文件系统的卸载

如果文件系统中的文件当前正在使用,该文件系统是不能被卸载的。如果文件系统中的文件或目录正在使用,则VFS索引节点高速缓存中可能包含相应的VFS索引节点。根据文件系统所在设备的标识符,检查在索引节点高速缓存中是否有来自该文件系统的VFS索引节点,如果有且使用计数大于 0,则说明该文件系统正在被使用,因此,该文件系统不能被卸载。否则,查看对应的VFS超级块,如果该文件系统的VFS超级块标志为“脏”,则必须将超级块信息写回磁盘。上述过程结束之后,对应的VFS超级块被释放,vfsmount数据结构将从vfsmntlist链表中断开并被释放。具体的实现代码为fs/super.c中的sysy_umount()函数,在此不再进行详细的讨论。

限额机制

限额机制对一个用户可分配的文件数目和可使用的磁盘空间设置了限制。限制有软限制和硬限制之分,硬限制是绝对不允许超过的,而软限制则由系统管理员来确定。当用户占用的资源超过软限制时,系统开始启动定时机制,并在用户的终端上显示警告信息,但并不终止用户进程的运行,在include/linux/quota.h中有如下宏定义:

1
2
#define MAX_IQ_TIME 604800 /* (7*24*60*60) =1 周 */
#define MAX_DQ_TIME 604800 /* (7*24*60*60) =1 周 */

分别是超过索引节点软限制的最长允许时间和超过块的软限制的最长允许时间。

首先,在编译内核时,要选择“支持限额机制”一项,默认情况下,Linux不使用限额机制。如果使用了限额机制,每一个安装的文件系统都与一个限额文件相联系,限额文件通常驻留在文件系统的根目录里,它实际是一组以用户标识号来索引的限额记录,每个限额记录可称为一个限额块,其数据结构如下(在inclue/linux/quota.h中定义):

1
2
3
4
5
6
7
8
9
10
struct dqblk {
__u32 dqb_bhardlimit; /* 块的硬限制*/
__u32 dqb_bsoftlimit; /* 块的软限制 */
__u32 dqb_curblocks; /* 当前占有的块数 */
__u32 dqb_ihardlimit; /* 索引节点的硬限制 */
__u32 dqb_isoftlimit; /* 索引节点的软限制 */
__u32 dqb_curinodes; /* 当前占用的索引节点数 */
time_t dqb_btime; /* 块的软限制变为硬限制前,剩余的警告次数*/
time_t dqb_itime; /* 索引节点的软限制变为硬限制前,剩余的警告次数 */
};

限额块调入内存后,用哈希表来管理,这就要用到另一个结构dquot(也在inclue/linux/quota.h中定义),其数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct dquot {
struct list_head dq_hash; /*在内存的哈希表*/
struct list_head dq_inuse; /*正在使用的限额块组成的链表*/
struct list_head dq_free; /* 空闲限额块组成的链表 */
wait_queue_head_t dq_wait_lock; /* 指向加锁限额块的等待队列*/
wait_queue_head_t dq_wait_free; /* 指向未用限额块的等待队列*/
int dq_count; /* 引用计数 */
/* fields after this point are cleared when invalidating */
struct super_block *dq_sb; /* superblock this applies to */
unsigned int dq_id; /* ID this applies to (uid, gid) */
kdev_t dq_dev; /* Device this applies to */
short dq_type; /* Type of quota */
short dq_flags; /* See DQ_* */
unsigned long dq_referenced; /* Number of times this dquot was
referenced during its lifetime */
struct dqblk dq_dqb; /* Diskquota usage */
};

哈希表是用文件系统所在的设备号和用户标识号为散列关键值的。vfs的索引节点结构中有一个指向dquot结构的指针。也就是说,调入内存的索引节点都要与相应的dquot结构联系,dquot结构中,引用计数就是反映了当前有几个索引节点与之联系,只有在引用计数为 0 时,才将该结构放入空闲链表中。

如果使用了限额机制,则当有新的块分配请求,系统要以文件拥有者的标识号为索引去查找限额文件中相应的限额块,如果限额并没有满,则接受请求,并把它加入使用计数中。如果已达到或超过限额,则拒绝请求,并返回错误信息。

文件系统的系统调用

open系统调用

进程要访问一个文件,必须首先获得一个文件描述符,这是通过open系统调用来完成的。该系统调用是用来获得欲访问文件的文件描述符,如果文件并不存在,则还可以用它来创建一个新文件。其函数为sys_open(),在fs/open.c中定义,函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
asmlinkage long sys_open(const char * filename, int flags, int mode)
{
char * tmp;
int fd, error;
#if BITS_PER_LONG != 32
flags |= O_LARGEFILE;
#endif
tmp = getname(filename);
fd = PTR_ERR(tmp);
if (!IS_ERR(tmp)) {
fd = get_unused_fd();
if (fd >= 0) {
struct file *f = filp_open(tmp, flags, mode);
error = PTR_ERR(f);
if (IS_ERR(f))
goto out_error;
fd_install(fd, f);
}
out:
putname(tmp);
}
return fd;

out_error:
put_unused_fd(fd);
fd = error;
goto out;
}

1.入口参数

  • filename:欲打开文件的路径。
  • flags:规定如何打开该文件,它必须取下列 3 个值之一。
    • O_RDONLY以只读方式打开文件
    • O_WRONLY以只写方式打开文件
    • O_RDWR以读和写的方式打开文件
    • 此外,还可以用或运算对下列标志值任意组合。
      • O_CREAT打开文件,如果文件不存在则建立文件
      • O_EXCL如果已经置O_CREAT且文件存在,则强制open()失败
      • O_TRUNC将文件的长度截为 0
      • O_APPEND强制write()从文件尾开始
    • 对于终端文件,这 4 个标志是没有任何意义的,另提供了两个新的标志。
      • O_NOCTTY停止这个终端作为控制终端
      • O_NONBLOCK使open()read()write()不被阻塞。
    • 这些标志的符号名称在/include/asmi386/fcntl.h中定义。
  • mode:这个参数实际上是可选的,如果用open()创建一个新文件,则要用到该参数,它用来规定对该文件的所有者、文件的用户组和系统中其他用户的访问权限位。它用或运算对下列符号常量建立所需的组合。
    • S_IRUSR文件所有者的读权限位
    • S_IWUSR文件所有者的写权限位
    • S_IXUSR文件所有者的执行权限位
    • S_IRGRP文件用户组的读权限位
    • S_IWGRP文件用户组的写权限位
    • S_IXGRP文件用户组的执行权限位
    • S_IROTH文件其他用户的读权限位
    • S_IWOTH文件其他用户的写权限位
    • S_IXOTH文件其他用户的执行权限位
    • 这些标志的符号名称在/include/linux/stat.h中定义。

出口参数

返回一个文件描述符。

执行过程

sys_open()主要是调用filp_open(),这个函数也在fs/open.c中,这已在前面做过介绍。

从当前进程的files_struct结构的fd数组中找到第 1 个未使用项,使其指向file结构,将该项的下标作为文件描述符返回,结束。

在以上过程中,如果出错,则将分配的文件描述符、file结构收回,inode也被释放,函数返回一个负数以示出错,其中PTR_ERR()IS_ERR()是出错处理函数。

read系统调用

如果通过open调用获得一个文件描述符,而且是用O_RDONLYO_RDWR标志打开的,就可以用read系统调用从该文件中读取字节。其内核函数在fs/read_write.c中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
asmlinkage ssize_t sys_read(unsigned int fd, char * buf, size_t count)
{
ssize_t ret;
struct file * file;
ret = -EBADF;
file = fget(fd);
if (file) {
if (file->f_mode & FMODE_READ) {
ret = locks_verify_area ( FLOCK_VERIFY_READ,
file->f_dentry->d_inode,
file, file->f_pos, count);
if (!ret) {
ssize_t (*read)(struct file *, char *, size_t, loff_t *);
ret = -EINVAL;
if (file->f_op && (read = file->f_op->read) != NULL)
ret = read(file, buf, count, &file->f_pos);
}
}
if (ret > 0)
dnotify_parent(file->f_dentry, DN_ACCESS);
fput(file);
}
return ret;
}

入口参数

  • fd:要读的文件的文件描述符。
  • buf:指向用户内存区中用来存储将读取字节的区域的指针。
  • count:欲读的字节数。

出口参数

返回一个整数。在出错时返回-1;否则返回所读的字节数,通常这个数就是count值,但如果请求的字节数超过剩余的字节数,则返回实际读的字节数,例如文件的当前位置在文件尾,则返回值为 0。

执行过程

  1. 函数fget()根据打开文件号fd找到该文件已打开文件的file结构。
  2. 取得了目标文件的file结构指针,并确认文件是以只读方式打开后,还要检查文件从当前位置f_pos开始的count个字节是否对读操作加上了“强制锁”,这是通过调用locks_verify_area()函数完成的,其代码在fs.h中。
  3. 通过了对强制锁的检查后,就是读操作本身了。
  4. 如果读操作的返回值大于 0,说明出错,则调用dnotify_parent()报告错误,并释放文件描述符、file结构、inode结构。

fcntl系统调用

这个系统调用功能比较多,可以执行多种操作,其内核函数在fs/fcntl.c中定义。

入口参数

  1. fd:欲访问文件的文件描述符。
  2. cmd:要执行的操作的命令,这个参数定义了 10 个标志,下面介绍其中的 5 个,F_DUPFDF_GETFDF_SETFDF_GETFLF_SETFL
  3. arg:可选,主要根据cmd来决定是否需要。

出口参数

根据第二个参数(cmd)的不同,这个返回值也不一样

函数功能

  • 如果第二个参数(cmd)取值是F_DUPFD,则进行复制文件描述符的操作。它需要用到第三个参数arg,这时arg是一个文件描述符,fcntl(fd, F_DUPFD, arg)files_struct结构中从指定的arg开始搜索空闲的文件描述符,找到第一个后,将fd的内容复制进来,然后将新找到的文件描述符返回。
  • 第二个参数(cmd)取值是F_GETFD,则返回files_struct结构中close_on_exec的值。无需第三个参数。
  • 第二个参数(cmd)取值是F_SETFD,则需要第三个参数,若arg最低位为 1,则对close_on_exec置位,否则清除close_on_exec
  • 第二个参数(cmd)取值是F_GETFL,则用来读取open系统调用第二个参数设置的标志,即文件的打开方式(O_RDONLYO_WRONLYO_APPEND等),它不需要第三个参数。实际上这时函数返回的是file结构中的flags域。
  • 第二个参数(cmd)取值是F_SETFL,则用来对open系统调用第二个参数设置的标志进行改变,但是它只能对O_APPENDO_NONBLOCK标志进行改变,这时需要第三个参数arg,用来确定如何改变。函数返回 0 表示操作成功,否则返回-1,并置一个错