Blog

搭建文章好结构的三步骤

Content #

  1. 要为事件找到主线,确定主心骨,方法是“一句话法则”,本质就是找到打动你的价值。
  2. 长出骨架,关键是找到触发点、冲突、解决这 3 部分。
  3. 展开骨架,方法是时空坐标系。

Viewpoint #

From #

自我领导与自我管理

领导(Leadership)不同于管理(Management)。两者的区别是什么? #

领导是第一次的创造,必须先于管理;管理是第二次的创造。领导与管理就好比思想与行为。管理关注基层,思考的是“怎样才能有效地把事情做好”;领导关注高层,思考的是“我想成就的是什么事业”。用彼得·德鲁克(Peter Drucker)和华伦·贝尼斯(Warren Bennis)的话来说就是:“管理是正确地做事,领导则是做正确的事。”管理是有效地顺着成功的梯子往上爬,领导则判断这个梯子是否搭在了正确的墙上。

Viewpoint #

From #

Guava中为什么要用Builder来创建Cache对象

Guava中为什么要用Builder来创建Cache对象 #

构建一个缓存,需要配置 n 多参数,比如过期时间、淘汰策略、最大缓存大小等等。相应地,Cache 类就会包含 n 多成员变量。我们需要在构造函数中,设置这些成员变量的值,但又不是所有的值都必须设置,设置哪些值由用户来决定。为了满足这个需求,我们就需要定义多个包含不同参数列表的构造函数。

为了避免构造函数的参数列表过长、不同的构造函数过多,我们一般有两种解决方案。其中,一个解决方案是使用 Builder 模式;另一个方案是先通过无参构造函数创建对象,然后再通过 setXXX() 方法来逐一设置需要的设置的成员变量。

那么,为什么 Guava 选择第一种而不是第二种解决方案呢?使用第二种解决方案是否也可以呢?答案是不行的。至于为什么,我们看下源码就清楚了。我把 CacheBuilder 类中的 build() 函数摘抄到了下面,你可以先看下。

public <K1 extends K, V1 extends V> Cache<K1, V1> build() {
  this.checkWeightWithWeigher();
  this.checkNonLoadingCache();
  return new LocalManualCache(this);
}
private void checkNonLoadingCache() {
  Preconditions.checkState(this.refreshNanos == -1L, "refreshAfterWrite requires a LoadingCache");
}
private void checkWeightWithWeigher() {
  if (this.weigher == null) {
    Preconditions.checkState(this.maximumWeight == -1L, "maximumWeight requires weigher");
  } else if (this.strictParsing) {
    Preconditions.checkState(this.maximumWeight != -1L, "weigher requires maximumWeight");
  } else if (this.maximumWeight == -1L) {
    logger.log(Level.WARNING, "ignoring weigher specified without maximumWeight");
  }
}

必须使用 Builder 模式的主要原因是,在真正构造 Cache 对象的时候,我们必须做一些必要的参数校验,也就是 build() 函数中前两行代码要做的工作。如果采用无参默认构造函数加 setXXX() 方法的方案,这两个校验就无处安放了。而不经过校验,创建的 Cache 对象有可能是不合法、不可用的。

...

logical address translating

逻辑地址 #

逻辑地址由两部分组成: 16位的段选择符(segment selector)和32位的位移(offset)。

进程可访问的地址空间称为线性地址空间(linear address space), Segmentation将线性地址空间划分为多个段(Segment)。

下面是节选自 Linux 的 bootsect 中的代码:

BOOTSEG   = 0x7c0
_start:
  jmpl $BOOTSEG, $start2
start2:
  movw $BOOTSEG, %ax
  movw %ax, %ds
  ...

跳转的目标地址就是 0x7c0 << 4 + OFFSET(start2)。跳转成功以后,cs 段寄存器中的值就是段基址 0x7c0,start2 的偏移值是 8,所以记录当前执行指令地址的 ip 寄存器中的值就是实际地址 0x7c08。

Viewpoint #

From #

02|聊聊x86体系架构中的实模式和保护模式

mmap用于父子进程之间的通信

mmap用于父子进程之间的通信 #

通过共享匿名映射,可以实现用 mmap 实现父子进程之间的通信,它的用法示例代码如下:

#include <sys/mman.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
int main() {
    pid_t pid;
    char* shm = (char*)mmap(0, 4096, PROT_READ | PROT_WRITE,
        MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (!(pid = fork())){
        sleep(1);
        printf("child got a message: %s\n", shm);
        sprintf(shm, "%s", "hello, father.");
        exit(0);
    }

    sprintf(shm, "%s", "hello, my child");
    sleep(2);
    printf("parent got a message: %s\n", shm);
    return 0;
}

在这个过程中,我们先是用 mmap 方法创建了一块共享内存区域,命名为 shm,接着,又通过 fork 这个系统调用创建了子进程。子进程休眠一秒后,从 shm 中取出一行字符并打印出来,然后又向共享内存中写入了一行消息。

在子进程的执行逻辑之后,是父进程的执行逻辑:父进程先写入一行消息,然后休眠两秒,等待子进程完成读取消息和发消息的过程并退出后,父进程再从共享内存中取出子进程发过来的消息。

Viewpoint #

From #

03 | 内存布局:应用程序是如何安排数据的?

mmap的用法

mmap的用法 #

mmap是申请堆内存的系统调用,它是最重要的内存管理接口。mmap 的头文件和原型如下所示:

#include <unistd.h>
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr 代表该区域的起始地址;
  • length 代表该区域长度;
  • prot 描述了这块新的内存区域的访问权限;
  • flags 描述了该区域的类型;
  • fd 代表文件描述符;
  • offset 代表文件内的偏移值。

mmap 的功能非常强大,根据参数的不同,它可以用于创建共享内存,也可以创建文件映射区域用于提升 IO 效率,还可以用来申请堆内存。决定它的功能的,主要是 prot, flags 和 fd 这三个参数,分别来看看。

prot 的值可以是以下四个常量的组合:

  • PROT_EXEC 表示这块内存区域有可执行权限,意味着这部分内存可以看成是代码段,它里面存储的往往是 CPU 可以执行的机器码。
  • PROT_READ 表示这块内存区域可读。
  • PROT_WRITE 表示这块内存区域可写。
  • PROT_NONE 表示这块内存区域的页面不能被访问。

而 flags 的值可取的常量比较多,你可以通过 man mmap 查看,这里我只列举一下最重要的四种可取值常量:

  • MAP_SHARED 创建一个共享映射的区域,多个进程可以通过共享映射的方式,来共享同一个文件。这样一来,一个进程对该文件的修改,其他进程也可以观察到,这就实现了数据的通讯。
  • MAP_PRIVATE 创建一个私有的映射区域,多个进程可以使用私有映射的方式,来映射同一个文件。但是,当一个进程对文件进行修改时,操作系统就会为它创建一个独立的副本,这样它对文件的修改,其他进程就看不到了,从而达到映射区域私有的目的。
  • MAP_ANONYMOUS 创建一个匿名映射,也就是没有关联文件。使用这个选项时,fd 参数必须为空。
  • MAP_FIXED 一般来说,addr 参数只是建议操作系统尽量以 addr 为起始地址进行内存映射,但如果操作系统判断 addr 作为起始地址不能满足长度或者权限要求时,就会另外再找其他适合的区域进行映射。如果 flags 的值取是 MAP_FIXED 的话,就不再把 addr 看成是建议了,而是将其视为强制要求。如果不能成功映射,就会返回空指针。

通常,使用私有匿名映射来进行堆内存的分配。

...

malloc与sbrk

malloc与sbrk #

sbrk 函数的头文件和原型定义如下:

#include <unistd.h>
void* sbrk(intptr_t incr);

sbrk 通过给内核的 brk 变量增加 incr,来改变堆的大小,incr 可以为负数。当 incr 为正数时,堆增大,当 incr 为负数时,堆减小。如果 sbrk 函数执行成功,那返回值就是 brk 的旧值;如果失败,就会返回 -1,同时会把 errno 设置为 ENOMEM。

在实际应用中,我们很少直接使用 sbrk 来申请堆内存,而是使用 C 语言提供的 malloc 函数进行堆内存的分配,然后用 free 进行内存释放。你要注意的是, malloc 和 free 函数不是系统调用,而是 C 语言的运行时库。Linux 上的主流运行时库是 glibc,其他影响力比较大的运行时库还有 musl 等。C 语言的运行时库多是以动态链接库的方式实现的。

在 C 语言的运行时库里,malloc 向程序提供分配一小块内存的功能,当运行时库的内存分配完之后,它会使用 sbrk 方法向操作系统再申请一块大的内存。我们可以将 C 语言的运行时库类比为零售商,它从操作系统那里批发一块比较大的内存,然后再通过零售的方式一点点地提供给程序员使用。

Viewpoint #

From #

03 | 内存布局:应用程序是如何安排数据的?

IA-32机器上的Linux进程内存布局

IA-32机器上的Linux进程内存布局 #

在 32 位机器上,每个进程都具有 4GB 的寻址能力。Linux 系统会默认将高地址的 1GB 空间分配给内核,剩余的低 3GB 是用户可以使用的用户空间。下图是 32 位机器上 Linux 进程的一个典型的内存布局。在实践中,我们可以通过cat /proc/pid/maps来查看某个进程的实际虚拟内存布局。

首先,我们发现在 32 位 Linux 系统下,从 0 地址开始的内存区域并不是直接就是代码段区域,而是一段不可访问的保留区。这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。

接下来,我们可以看到,代码段从 0x08048000 的位置开始排布(需要注意的是,以上地址需要 gcc 编译的时候不开启 pie 的选项)。就像我们前面提到的,代码段、数据段都是从可执行文件映像中装载到内存中;BSS 段则是根据 BSS 段所需的大小,在加载时生成一段 0 填充的内存空间。

紧接着,排在 BSS 段后边的就是堆空间了。在图中,堆的空间里有一个向上的箭头,这里标明了堆地址空间的增长方向,也就是说,每次在进程向内核申请新的堆地址时候,其地址的值是在增大的。与之对应的是栈空间,有一个向下的箭头,说明栈增长的方向是向低地址方向增长,也就是说,每次进程申请新的栈地址时,其地址值是在减少的。

对此,我们可以想象堆和栈分别由两个指针控制,堆指针指明了当前堆空间的边界,栈指针指明了当前栈空间的边界。当堆申请新的内存空间时,只需要将堆指针增加对应的大小,回收地址时减少对应的大小即可。而栈的申请刚好相反。这其实就是内核对堆跟栈使用的最根本的方式,其中,堆的指针叫做“Program break”,栈的指针叫做“Stack pointer”,也就是 x86 架构下的 sp 寄存器。

继续往下看,就到了内存映射区域,这里最常见的就是程序所依赖的共享库,例如 libc.so。共享库的代码段、数据段、BSS 段都会被装载到这里。

Viewpoint #

From #

03 | 内存布局:应用程序是如何安排数据的?

Section与Segment

Section与Segment #

下图从两个视角展示了应用程序的分布,左边是程序在磁盘中的文件布局结构,右边是程序加载到内存中的内存布局结构。

磁盘程序的每一个单元结构称为 Section。可以通过 readelf -S 来查看。内存镜像的每一个单元结构称为 Segment。可以通过 readelf -l 来查看。

多个 Section 往往会对应一个 Segment,例如.text、.rodata 等一些只读的 Section,会被映射到内存的一个只读 / 执行的 Segment 里;而.data、.bss 等一些可读写的 Section,则会被映射到内存的一个具有读写权限的 Segment 里。对于磁盘二进制中一些辅助信息的Section,例如.symtab、.strtab 等,不需要在内存中进行映射。

程序员的自我修养 #

当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。ELF文件中,段的权限往往只有为数不多的几种组合,基本上是三种:

  1. 以代码段为代表的权限为可读可执行的段。
  2. 以数据段和BSS段为代表的权限为可读可写的段。
  3. 以只读数据段为代表的权限为只读的段。

那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射。比如有两个段分别叫“.text”和“.init”,它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设.text为4 097字节,.init为512字节,这两个段分别映射的话就要占用三个页面,但是,如果将它们合并成一起映射的话只须占用两个页面,

ELF可执行文件引入了一个概念叫做“Segment”,一个“Segment”包含一个或多个属性类似的“Section”。如果将“.text”段和“.init”段合并在一起看作是一个“Segment”,那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的VMA,而不是两个,这样做的好处是可以很明显地减少页面内部碎片,从而节省了内存空间。

很明显,从链接的角度看,ELF文件是按“Section”存储的;从装载的角度看, ELF文件又可以按照“Segment”划分。

“Segment”的概念实际上是从装载的角度重新划分了ELF的各个段。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。比如可读可执行的段都放在一起,这种段的典型是代码段;可读可写的段都放在一起,这种段的典型是数据段。在ELF中把这些属性相似的、又连在一起的段叫做一个“Segment”,而系统正是按照“Segment”而不是“Section”来映射可执行文件的。

From #

03 | 内存布局:应用程序是如何安排数据的?