Jul 18
这个坑比较新鲜,周一刚填完,还冒着冷气。


- 1 -

在字节跳动,我们线上服务的所有 log 都通过统一的日志库采集到流式日志服务、落地 ES 集群,配上字节云超(sang)级(xin)强(bing)大(kuang)的监控能力,每一条 panic log 都可以触发一个打给值班同学的电话。 

所以我们常常不选电话,只选飞书 ↓↓↓

点击在新窗口中浏览此图片

但毕竟是 panic,大部分 case 都会迅速被就地正法,除了少数排查费劲、又不对线上产生太大影响的,比如这一个:

Error: invalid memory address or nil pointer dereference
Traceback:
goroutine 68532877 [running]:
...
src/encoding/json/encode.go:880 +0x59
encoding/json.stringEncoder(0xcb9fead550, ...)
...
src/encoding/json/encode.go:298 +0xa5
encoding/json.Marshal(0x1ecb9a0, ...)
...
/path/to/util.SendData(0xca813cd300)


注:为了方便阅读,略有简化。

你看,它可以被 recover 兜住(不会把服务搞挂),而且出现频率很低(每天几次甚至没有),考虑到在每天数百亿请求中的占比,解决它的 ROI 实在太低,所以就耽搁了一段时间 且不用担心背 P0 的锅

点击在新窗口中浏览此图片

- 2 -

其实之前 S 同学和我都关注过这个 panic ,从上面的 Error log 可以看到,错误发生在调用 json.Marshal 的时候,调用方的代码大概长这样:

func SendData(...) {
  data := map[string]interface{} {
    "code":    ctx.ErrorCode,
    "message": ctx.Message,
    "step":    ctx.StepName,
  }
  msg, err := json.Marshal(data)
  ...
}


注:实际map有更多key/value,这里略作简化。 

看这代码,第一反应是:这**也能 panic ?

点击在新窗口中浏览此图片

找到对应的 json 库源码(encode.go第880行,对应下面第5行):

func (e *encodeState) string(s string, escapeHTML bool) {
  e.WriteByte('"')
  start := 0
  for i := 0; i < len(s); {
    if b := s[i]; b < utf8.RuneSelf {
      ...


—— 也只是从string里逐个读取字符,看着并没什么猫饼。 

由于 panic 发生在官方 json 库里,不适合修改并部署到全量机器;引入第三方 json 库又涉及很多依赖问题,所以当时没再跟进。

直到最近 panic 频率逐渐升高, H 和 L 同学实在看不下去了。

- 3 -

L 同学的思路是,既然这个 panic 能被 recover 兜住,那为什么不看看 panic 时这个 map 里装了什么呢?

点击在新窗口中浏览此图片

于是代码就变成了这样:

defer func() {
  if p := recover(); p != nil {
    log.Warnf("Error: %v, data: %v", p, data)
  }
}()
data := map[string]...


然后 panic 顺利转移到了 log.Warnf 这一行[doge]


- 4 - 

不管怎么说成功地转移了问题,只要把 log.Warnf 这一行注释掉……

点击在新窗口中浏览此图片

作为一个追求极致的 ByteDancer,L 同学抵制住了诱惑并尝试了新的思路,既然从 panic log 看到是跪在了一个 string 上,那至少先看看是哪一个string:

data := make(map[string]interface{})
defer func() {
  if p := recover(); p != nil {
    for k, v := range data {
      log.Warnf("CatchMe: k=%v", k)
      log.Warnf("CatchMe: v=%v", v)
    }
  }
}()
...


改起来倒是很简单,赶在这个 需要上班的 周日下午发了车,晚上就捉到了一个case。

通过线上 log,我们发现错误出现在 "step" 这个 key 上(log里有输出key、但没输出value),value 本应是 ctx.StepName 这个 string。

可是 string 这种看起来人畜无害的 immutable 的 type 为什么会导致 panic 呢?

点击在新窗口中浏览此图片


- 5 -

通过走读代码得知,在遇到异常的时候,我们会往 ctx.StepName 写入这个异常点的名称,就像这样: 

const STEP_XX = "XX"

func XX(...) {
  if err := process(); err != nil {
    ctx.StepName = STEP_XX
  }
}


一边读一边写,有那么点并发的味道了。

考虑到我们为了降低媒体感知的超时率,将整个广告的召回流程包装成一个带时间限制的任务:

finished := make(chan struct{})
timer := time.NewTimer(duration)
go recall(finished)
select {
  case <-finished:
    sendResponse()
  case <- timer.C:
    sendTimeoutResponse()
}


因此在一个请求流程中,确实可能会出现并发读写 ctx.StepName 这个 string object 的情况。 

但如何实锤是这儿挖的坑呢? 


- 6 -

在线上服务中直接验证这一点不太容易,但是 H 同学做了一个简单的 POC,大概像这样:

const (
  FIRST  = "WHAT THE"
  SECOND = "F*CK"
)

func main() {
  var s string
  go func() {
    i := 1
    for {
      i = 1 - i
      if i == 0 {
        s = FIRST
      } else {
        s = SECOND
      }
      time.Sleep(10)
    }
  }()

  for {
    fmt.Println(s)
    time.Sleep(10)
  }
}


代码一跑起来就有点味道了:

$ go run poc.go
WHAT THE
F*CK
...
WHAT
WHAT
WHAT
F*CKGOGC
...


点击在新窗口中浏览此图片

虽然没看到 panic,但是确实看到了点奇怪的东西(严正声明:不是故意要吐槽GO的GC)。

再用 go 的 race detector 瞅瞅:

$ go run -race poc.go >/dev/null   
==================
WARNING: DATA RACE
Write at 0x00c00011c1e0 by goroutine 7:
  main.main.func1()
    poc.go:19 +0x66(赋值那行)

Previous read at 0x00c00011c1e0 by main goroutine:
  main.main()
    poc.go:28 +0x9d(println那行)


这下可算是实锤了。


- 7 -

那么为什么 string 的并发读写会出现这种现象呢? 

这就得从 string 底层的数据结构说起了。在 go 的 reflect 包里有一个 type StringHeader ,对应的就是 string 在 go runtime的表示:

type StringHeader struct {
    Data uintptr
    Len  int
}


可以看到, string 由一个指针(指向字符串实际内容)和一个长度组成。 

比如说我们可以这么玩弄 StringHeader:

s := "hello"
p := *(*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Println(p.Len)


对于这样一个 struct ,golang 无法保证原子性地完成赋值,因此可能会出现goroutine 1 刚修改完指针(Data)、还没来得及修改长度(Len),goroutine 2 就读取了这个string 的情况。

因此我们看到了 "WHAT" 这个输出 —— 这就是将 s 从 "F*CK" 改成 "WHAT THE" 时,Data 改了、Len 还没来得及改的情况(仍然等于4)。

至于 "F*CKGOGC" 则正好相反,而且显然是出现了越界,只不过越界访问的地址仍然在进程可访问的地址空间里。 


- 8 - 

既然问题定位到了,解决起来就很简单了。 

最直接的方法是使用 sync.Mutex:

func (ctx *Context) SetStep(step string) {
  ctx.Mutex.Lock()
  defer ctx.Mutex.Unlock()
  ctx.StepName = Step
}


Mutex 性能不够好(lock does not scale with the number of the processors),对于这种读写冲突概率很小的场景,性能更好的方案是将 ctx.StepName 类型改成 atomic.Value,然后 

ctx.StepName.Store(step)


注:也可以改成 *string 然后使用 atomic.StorePointer

实际上,Golang 不保证任何单独的操作是原子性的,除非使用 atomic 包里提供的原语或加锁


- 9 -

大结局:周一下午 H 同学提交了修复代码并完成发布,这个 panic 就再没出现了。

总结一下:
* string 没有看起来那么人畜无害
* 并发的坑可以找 -race 帮帮忙
* 记得使用 mutex 或 atomic

最后留下一个小问题供思考:

这说了半天并没有完全复现 panic,不过文中已经给了足够多的工具,你能想到怎么办吗?




推荐阅读:

* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* [译] C程序员该知道的内存知识 (1)

Jul 11
Linux里养僵尸是怎么回事呢?Linux相信大家都很熟悉,但是Linux里养僵尸是怎么回事呢,下面就让小编带大家一起了解吧。


# - 1 -

上一篇挖了个 SIGHUP 的坑,这篇试着填一下。

之前在《程序员面试指北:面试官视角》里面说过,在结构化面试中,我们会从各个方向去考查候选人,其中之一是操作系统。

上篇介绍了一套题,我还有另一套,一般这么开场:

在终端下启动一个命令,如果在命令结束前关掉终端,它还能正常运行吗?

点击在新窗口中浏览此图片


# - 2 -

这其实是一个很常见的case,但凡 Linux 或者 Mac 用得多一点,都会遇到。

在我还是一个穷酸学生的2009年,每个月都需要支付 20 元巨款(当时能买3根鸭脖),通过一个禁止分享网络的认证客户端接入校园网。

为了共建和谐宿舍 节省网费 ,我历经千辛万苦,交叉编译开源的Linux认证客户端,集成到固件里,并刷到了我的 NETGEAR 路由器上。

然后山水 BBS 的 Linux 版主把我的帖子置顶了 11 年。可见他有多痛恨禁止共享网络

这么一回忆,感觉自己的共享经济思维真是前卫,当时怎么就没想到去搞共享单车呢?

点击在新窗口中浏览此图片

扯远了,在捣腾的过程中,我就踩了这么个坑:当我ssh到路由器上、刚启动认证时,能够正常联网;但是退出ssh后一会,网就断了。

经过一番捣腾后发现,只要一退出ssh,认证程序就凉了,而不是继续在后台保持和认证服务器的通信。


# - 3 -

所以前面那个问题,我以为大部分候选人应该会回答“否”,但没想到竟然还有不少人回答“是”。

其实回答“是”也没什么错,因为确实也有些命令不会随着终端关闭而结束。

问题是当我追问当时执行的是什么命令时,候选人往往又说不出个所以然来。

点击在新窗口中浏览此图片
(借学长的表情一用)

然后我就感到很强的挫败感:这不按剧本来,没法问了啊……只好换题。

当然大部分候选人确实被坑过,于是我可以接着问:

如果确实需要在后台继续执行命令怎么办呢?

有些人只记得要在后面加个 & ;但也有不少人知道前面还得加个 nohup,就像这样:

$ nohup python process.py &
[1] 1806824
nohup: ignoring input and appending output to 'nohup.out'


注:其实我更喜欢 screen(或 tmux),偶尔也用 setsid 。

然后就可以放心地关闭终端 开始放羊 了。

但我的套题还没结束:为什么加上 nohup 就可以让进程在后台继续运行呢?

点击在新窗口中浏览此图片
(这表情熟悉吗)


# - 4 -

铺垫了这么多,总算是可以开始填坑了。

答案其实很好找,man nohup 就能看到:

引用
The nohup utility invokes utility with its arguments and at this time sets the signal SIGHUP to be ignored


nohup工具在启动命令的同时会将 SIGHUP 信号设置为忽略。
而关于 SIGHUP,Wikipedia原文是这样介绍的:

引用
On POSIX-compliant platforms, SIGHUP ("signal hang up") is a signal sent to a process when its controlling terminal is closed.

wikipedia.org/wiki/SIGHUP


对于 POSIX 兼容的平台(如Unix、Linux、BSD、Mac),当进程所在的控制终端关闭时,系统会给进程发送 SIGHUP 信号(Signal Hang Up,挂断信号)。

为什么叫 SIGHUP 呢?(严正申明:这一问不在套题里[doge])

我们知道,在上古时代,捉 bug 就已经是码农的必备技能(更准确地说是 moth)。

点击在新窗口中浏览此图片
(我总觉得这个图是假的)

到了远古时代,他们不再需要去机房,通过基于 RS-232 协议的串行线路连接到大型机的终端上,就可以开始收福报。

收完福报,程序员通知自己的猫(modem)挂断(Hang Up)连接;大型机的 OS 检测到连接断开,就会给进程发送信号 —— 所以这信号被称为 SIGHUP 。

这果然是毫无卵用的知识啊。

点击在新窗口中浏览此图片


# - 5 -

很多同学在操作系统的课程上学习了“进程间的通信方式有信号、管道、消息队列、共享内存……”,但是对信号到底是个什么东西,并没有现实的概念。

课堂教学的理论和实践往往是割裂的,在此特别推荐《Unix环境高级编程》(简称APUE)。

APUE在 1.9 - 信号 中写到:信号是通知进程已发生某种条件的一种技术。

而在 Linux/Unix 下,进程对信号的处理有三种选择:

* 按系统默认方式处理
* 提供一个回调函数
* 或忽略该信号(有些信号例外,不允许被忽略)

以 SIGHUP 信号为例,系统默认处理方式就是结束进程

当然终端下打开的第一个进程通常都是shell(例如bash)。shell会给 SIGHUP 信号注册一个回调函数,用于给该 shell 下所有的子进程发送 SIGHUP 信号,然后再主动退出。

对于求生欲很强的程序(例如nohup),可以主动选择忽略该信号

有一些进程本来就被设计成在后台运行,不需要控制终端,因此它们将 SIGHUP 挪作它用,一个常见的用法就是重新读取配置文件(例如Apache、Nginx),上篇提到的 logrotate 正是利用了这一点。

终于填完了坑。

# - 6 -

说了这么多都还是纸上谈兵,实操中如何主动忽略 SIGHUP 呢?

实际上也很简单,使用 Linux 的 signal 系统调用即可:

#include <signal.h>
#include <unistd.h>

int main() {
    signal(SIGHUP, SIG_IGN);
    sleep(1000);
    return 0;
}


不妨试试看,编译运行起来,即使关闭终端,它也会在后台继续运行。

signal 也可以用于指定回调函数(或重置为系统默认处理方式),这里就不展开了,感兴趣的同学可以参考 APUE 里的代码,以及阅读 signal 的manual。

使用回调函数还需要注意一个坑:

由于回调函数可能在任意时刻被触发,因此要避免调用不可重入的函数(典型如printf)。常见的做法是 set 一个 flag,然后在程序的主循环中检测该 flag,再按需执行相应任务。

# - 7 -

SIGHUP 只是常见的一个信号,在 Linux 下,信号还有大量其他的场景和应用。

当你按下 Ctrl + C ,就是给进程发送了一个 SIGINT 信号。

当你执行 kill -TERM $PID,就是给进程发送了一个 SIGTERM 信号。可能和你期望有出入的是,SIGTERM 是可以被进程忽略的。所以有时候你得用 SIGKILL (kill -9) 。

你还可以使用可自定义的 SIGUSR1、SIGUSR2、SIGURG 来实现一些功能,比如《踩坑记#2:Go服务锁死》中提到 Golang 在其 goroutine 调度中使用了 SIGURG 。

# - 8 -

这次就不总结了,最后再用一个和信号有关的 case 收尾。

Linux 内核会为每一个进程分配一个 task_struct 结构体,用于保存进程的相关信息。

在进程死亡后,系统会发送一个 SIGCHLD 信号给它的父进程。

正确的父进程实现,通常应当使用 wait 系统调用来给子进程收尸 —— 父进程往往需要知道子进程结束这个事件,而且可能还需要得知其退出原因(exit code)。

然后内核才会将对应的 task_struct 释放。

如果父进程没有收尸,task_struct 里的 state 会一直保持为 EXIT_ZOMBIE,这时在 ps 或 top 等命令里,就可以看到该进程的状态为 Z ,而且无法被 kill 。

这就是所谓的僵尸进程,这时候你找九叔都没用。

点击在新窗口中浏览此图片
(大半夜找这图还挺渗人的)

所以Linux里养僵尸,其实就是子进程死了父进程不收尸,大家可能会很惊讶Linux里怎么会养僵尸呢?但事实就是这样,小编也感到非常惊讶。

这就是关于Linux里养僵尸的事情了,大家有什么想法呢,欢迎在评论区告诉小编一起讨论哦!

---

推荐阅读

* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* [译] C程序员该知道的内存知识 (1)
Jul 6

关于写作的一点思考 不指定

felix021 @ 2020-7-6 01:07 [随想] 评论(2) , 引用(0) , 阅读(2226) | Via 本站原创
今年开始写公众号,最初是想通过写点东西辅助招聘(真的很缺人)。几篇以后发现效果并不好,但“写作”这件事情却是断断续续坚持下来了。 

这背后有些自觉有意思的思考,希望通过本文做一个梳理和总结。 


写作

如果写技术博客也算写作的话,那我已经写作十几年了。

刚开始啥也不懂,一个简单的知识点(比如ASCII码表)也写一篇,又或者觍着脸从别的博客转载过来,自己还挺开心 —— 看,又多了一篇。

最近几年我意识到,写作其实就是自己和自己对话的过程

在这个过程中,你需要把之前输入的知识串到自己的逻辑链上,从而可以很好地把这些知识内化,变成自己的东西。

在写作时我还常常会发现,很多知识点原来并没有吃透。为了把它们写出来,需要进一步查找资料、学习、夯实。

比如之前发的这篇《踩坑记:go服务内存暴涨》,其中 MADV_ADVISE 和 MADV_FREE 这些知识背后的细节也算是现学现卖的,通过写作把它吃透,加入了自己的知识库。

文章被分享后,常会收到很多建设性的讨论,在这个过程中不仅学习到了他人的知识,还通过和他人的对话进一步强化或修正了自己的逻辑链。

越写收获越多、收获越多越愿意写,这样就形成了一个正反馈循环


视角

在这个过程中,我不断提示自己要注意写作的视角 —— 要充分认识到知识的诅咒。我把它简单理解成“你知道的东西都会觉得简单、不知道的就会觉得难”。

比如快排这个算法,大一的时候我花了很久才想明白它是如何在数组里做原地划分的;而一旦想明白了,就会觉得其实很简单,无非就是两个指针加一个临时变量来回捣腾。

有鉴于此,我们往往是很难完全以新手的视角来看待问题;相信你也注意到,现实中很多高手在实战中很厉害,但是却不擅长教导别人(因此知识的诅咒也称为“专家盲点”)。

这对我的启示是,不要因为觉得某个知识太简单就不写了,实际上越简单的知识能帮到的人越多(所以知识付费大卖的课程往往都是比较简单的课程)。这一点在团队的文档建设中尤其重要。


可读性

另一方面我也在努力提高行文的可读性

首先,在写作时我会尽量避免思维跳跃,如前所述,有很多自己觉得显而易见的知识点,对于新手往往就是不可逾越的鸿沟。

为什么我们在高数课上容易犯困?因为一旦有个知识点没有衔接上,后面的逻辑都变得无法理解;而对于持续输入的无法理解的信息,大脑只能罢工了。

因此我宁愿行文时啰嗦一些,多补充相关细节。

其次,如果不是严肃话题,我会想办法提高内容的故事性,并选用轻松活跃的措辞

人类大脑保留的动物性有两个体现,一是喜欢听故事而不是冗长乏味的逻辑推演,二是喜欢即时反馈(给我一袋瓜子,我能磕一个下午)。

这使得今日头条和抖音这样提供即时满足感的app能够如此大行其道,虽然一鸣同学对ByteDancer的要求都是延迟满足

具体到写作时可以使用一些小技巧,比如在文章中有意识地加入一些段子、表情包、埋包袱,都可以提高阅读体验,从而提升阅读完成率。

为了制造效果,甚至可以考虑植入一些无伤大雅的错误,比如端午节的月饼和中秋节的粽子。 

此外,还要尽量避免大段的文字,以避免对阅读者造成视觉上的压迫感。 

这也就是这句话要换行、以及隔一段起标题的原因。


分歧

禅宗有云:不立文字。

文字是思维的投影,在这个过程中不可避免地,会丢失一些无法用文字描述的信息。

而阅读则是这个投影的逆过程,因各自阅历不同,每个读者又会融入自己的独特见解。

因此读者很可能无法准确了解作者想表达的含义

另一方面,即使读者能够理解,往往也会有自己的不同观点。

因此存在分歧属于正常情况,但随之而来的反馈却往往大相径庭。


建设性的反馈

对于这些分歧,有些人会给出建设性的反馈,通过友好而理性的表述,将自己的疑惑或者反对意见表达出来,并且给出自己的理由。

我喜欢这种反馈 —— 有时对方正确,有时我正确,又或者都有考虑不周的地方,但只要双方都怀着最大的诚意,通过充分的沟通和探讨,也就是建设性的讨论,最终可以达成一致或互相理解。

特别需要注意的是,形而上学的问题并不总能达成一致,讨论双方都应当有这样的预期。

这种问题的特点是,各自的观点或结论不具有可证伪性,比如世界的本质是什么,人生的意义是什么,是否应该凡事都相信科学等等。

还有一些情况,比如“如何面试候选人”,现实中不具备可靠的方法来比较各自的观点的优劣,因此也不具备完全达成一致的基础,讨论中就应注意求同存异,而不是升级为对抗性的说服。


破坏性的反馈

另外有些人倾向于给出破坏性的反馈:一旦感觉和自己理念或逻辑有冲突,就上头开喷。

比如“废话太多,一句话掰开十句话说谁都会”,不考虑作者的用意,只凭自己喜好逞口舌之快;又或者“这个需求就是有病”,不考虑问题的背景和受到的现实约束。

他们试图从自己对文字的理解出发,用居高临下的视角来评论以体现自己的正确,因此也放弃了可能对自己有收获的部分。 

对这种反馈,最好的办法是不去理会,因为不太可能形成建设性的讨论。

还有些稍好,在反馈中会给出详细的意见,但是语气很重,或通篇透露着只有自己掌握了真理的自信。

理性一点看,这种反馈也有所助益。但人毕竟是感性的,在这种情况下,出于被认可的需要和避免自己认知失调,人的本能是进一步的带着情绪的反驳,因此后续讨论很容易会陷入互相攻讦,最终使得双方进一步分道扬镳。

想要克服这种反驳中夹带的情绪,得通过不断的自省和训练来达成。

这一点也是我还需要改善的地方。今年文章尽管都只有两三千字,但我每次都花费几个小时反复推敲措辞,才形成自己满意的版本。

对于轻佻、不负责任的评论,难免引起情绪的波动;但这都是在这条路上成长必然会遇到的,也给我提供了自省和训练的机会,其实也是很有意思的体验。


收尾

所幸禅宗终究是随着文字流传了下来,这也让人对文字的力量更有信心。

最近发出的文章,收到了很多点赞、收藏和感谢,相信这些读者或多或少从中得到了自己想要的内容,这也是我持续写作的一个动力来源。

最后用一句话自勉: 

唯一没有瑕疵的作家是那些从不写作的人。
Jul 6
点击在新窗口中浏览此图片

想了十天十夜不知道写些什么,那就写写面试题吧。

1

在面试应聘者的时候,我常常会问:

在 Linux 下,如何删除一个目录下的所有 log 文件?

不知道是不是我人畜无害的围笑给了应聘者我很好应付的错觉 

点击在新窗口中浏览此图片

以至于应聘者全都回答:`rm *.log`

追问:该目录下可能有很多子目录,如何把子目录里的 log 文件也删掉呢?
Jun 25

踩坑记#2:Go服务锁死 不指定

felix021 @ 2020-6-25 22:48 [IT » 程序设计] 评论(0) , 引用(0) , 阅读(1065) | Via 本站原创
接着 上一篇-内存暴涨坑 再挖个坟,讲讲去年踩的另一个坑。 

---

前方低能

那是去年7月的一天,被透过落地玻璃的宇宙中心五道口的夕阳照着的正在工位搬砖的我,突然听到一阵骚乱,转头一看,收到夺命连环call的D同学反馈,流量严重异常。

点开报警群,一串异常赫然在目: 
Jun 21

golang: bufio.Scanner 的坑 不指定

felix021 @ 2020-6-21 01:03 [IT » 程序设计] 评论(0) , 引用(0) , 阅读(1422) | Via 本站原创
之前从网上找的一段代码,按行读取文件:

inFile, err := os.Open("xxx.log")
if err != nil {
    fmt.Fprintf(os.Stderr, "open failed: %v\n", err)
    return
}
defer inFile.Close()

scanner := bufio.NewScanner(inFile)
for scanner.Scan() {
    line := scanner.Bytes()
    //do sth. with line
}


看起来没问题,用起来也没问题,直到踩了个坑:针对某个特定的文件,读取到某一行以后就不再继续了。

既然总能复现,那就好解决,我的一个常用方法是:制造一个总能复现的case,并不断缩小case的规模。

例如这个case,把那一行单独拿出来,通过二分找到出问题的位置。

原以为是该行有特殊字符导致触发了什么奇怪的逻辑,但经过不断尝试,发现临界点是该行长度 = 65536 的时候,正好会触发错误。

这么整的数字(2^16, 64KB)必然是代码里的特殊逻辑了,翻了一下 bufio 的源码,果然有一个

const (
  //...(一堆注释)...
  MaxScanTokenSize = 64 * 1024
)


搜索这个常量在代码里的引用:
func NewScanner(r io.Reader) *Scanner {
  return &Scanner{
    r:            r,
    split:        ScanLines,
    maxTokenSize: MaxScanTokenSize,
  }
}

...

func (s *Scanner) Scan() bool {
  ....
  if len(s.buf) >= s.maxTokenSize || len(s.buf) > maxInt/2 {
    s.setErr(ErrTooLong)
    return false
  }
  ...
}


在 for 循环后加上一句:
if scanner.Err() != nil {
  fmt.Fprintf(os.Stderr, "scan err: %v\n", scanner.Err())
}


实锤:
引用
scan err: bufio.Scanner: token too long



那怎么解决呢?

MaxScanTokenSize 上面的注释是这么写的:

引用
  // MaxScanTokenSize is the maximum size used to buffer a token
  // unless the user provides an explicit buffer with Scanner.Buffer.
  // The actual maximum token size may be smaller as the buffer
  // may need to include, for instance, a newline.


于是最终版的解决方案是这样:

...
scanner := bufio.NewScanner(inFile)
buf := make([]byte, 0, bufio.MaxScanTokenSize * 10) //根据自己的需要调整这个倍数
scanner.Buffer(buf, cap(buf))

for scanner.Scan() {
  line := scanner.Bytes()
  //do sth. with line
}

if scanner.Err() != nil {
  fmt.Fprintf(os.Stderr, "scan err: %v\n", scanner.Err())
}


真是丑陋的api啊。
May 24
系列更新:
* [译] C程序员该知道的内存知识 (1)
* [译] C程序员该知道的内存知识 (2)
* [译] C程序员该知道的内存知识 (3)

这是本系列的第4篇,也是最后一篇,含泪填完这个坑不容易,感谢阅读~

这个系列太干了,阅读量一篇比一篇少,但我仍然认为这个系列非常有价值,在翻译的过程中我也借机进行系统性的梳理、并学习了很多新知识,收获满满。希望你也能有收获(但肯定没我多)。

那,开始吧。 




# 理解内存消耗

工具箱:
* vmtouch [2] - portable virtual memory toucher

(译注:vmtouch这个工具用来诊断和控制系统对文件系统的缓存,例如查看某个文件被缓存了多少页,清空某个文件的缓存页,或将某个文件的页面锁定在内存中;基于这些功能可以实现很多有意思的应用;详情参考该工具的文档。) 

然而共享内存的概念导致传统方案 —— 测量对内存的占用 —— 变得无效了,因为没有一个公正的方法可以测量你进程的独占空间。这会引起困惑甚至恐惧,可能是两方面的:

引用

用上了基于 mmap 的I/O操作后,我们的应用现在几乎不占用内存.
— CorporateGuy

求救!我这写入共享内存的进程有严重的内存泄漏!!!
— HeavyLifter666


页面有两种状态:清洁(clean)页和脏(dirty)页。区别是,脏页在被回收之前需要被写回到持久存储中(译注:写回文件实际存放的地方)。MADV_FREE 这个建议通过将脏标志位清零这种方式来实现更轻量的内存释放,而不是修改整个页表项(译注:page table entry,常缩写为PTE,记录页面的物理页号及若干标志位,如能否读写、是否脏页、是否在内存中等)。此外,每一页都可能是私有的或共享的,这正是导致困惑的源头。

前面引用的两个都是(部分)真实的,取决于视角。在系统缓冲区的页面需要计入进程的内存消耗里吗?如果进程修改了缓冲区里那些映射文件的那些页面呢?在这混乱中可以整出点有用的东西么? 

假设有一个进程,索伦之眼(the_eye)会写入对 魔都(mordor) 的共享映射(译注:指环王的梗)。写入共享内存不计入 RSS(resident set size,常驻内存集)的,对吧?

$ ps -p $$ -o pid,rss
  PID  RSS
17906  1574944 # <-- 什么鬼? 占用1.5GB?


(译注:$$ 是 bash 变量,保存了在执行当前script的shell的PID;这里应该是用来指代the_eye的PID)

呃,让我们回到小黑板。 

## PSS(Proportional Set Size) 

PSS(译注:Proportional 意思是 “比例的”) 计入了私有映射,以及按比例计入共享映射。这是我们能得到的最合理的内存计算方式了。关于“比例”,是指将共享内存除以共享它的进程数量。举个例子,有个应用需要读写某个共享内存映射:

$ cat /proc/$$/maps
00400000-00410000        r-xp 0000 08:03 1442958 /tmp/the_eye
00bda000-01a3a000        rw-p 0000 00:00 0      [heap]
7efd09d68000-7f0509d68000 rw-s 0000 08:03 4065561 /tmp/mordor.map
7f0509f69000-7f050a108000 r-xp 0000 08:03 2490410 libc-2.19.so
7fffdc9df000-7fffdca00000 rw-p 0000 00:00 0      [stack]
... 以下截断 ...


(译注:cat /proc/$PID/maps 是从内核中读取进程的所有内存映射)

这是个被简化并截断了的映射,第一列是地址范围,第二列是权限信息,其中 r 表示可读, w 表示可写,x 表示可执行 —— 这都是老知识点了 —— 然后 s 表示共享,p 表示私有。然后是映射文件的偏移量,设备号(OS分配的),inode号(文件系统上的),以及最后是文件的路径名。具体参见这个文档[3](译注:kernel.org 对 /proc 文件系统的说明文档),超级详细。 

我得承认我删掉了一些输出中一些不太有意思的信息。如果你对被私有映射的库感兴趣的话可以读一下 FAQ-为什么“strict overcommit”是个蠢主意[4](译注:根据这个FAQ,strict overcommit应该是指允许overcommmit、但要为申请的每一个虚拟页分配一个真实页,不管是用物理页还是swap,确实听起来很蠢……)。不过这里我们感兴趣的是魔都(mordor)这个映射:

$ grep -A12 mordor.map /proc/$$/smaps
Size:          33554432 kB
Rss:            1557632 kB
Pss:            1557632 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:  1557632 kB
Private_Dirty:        0 kB
Referenced:      1557632 kB
Anonymous:            0 kB
AnonHugePages:        0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:          4 kB
Locked:                0 kB
VmFlags: rd wr sh mr mw me ms sd


译注:这个文件大小 32GB,已加载了 1521MB 到内存中,因为只有这一个进程映射了它,所以在这个进程的PSS中占比是100%,也是 1521MB。

在共享映射里的私有页面 —— 搞得我像巫师一样?在Linux上,即使共享内存也会被认为是私有的,除非它真的被共享了(译注:不止一个进程创建共享映射)。让我们看看它是否在系统缓冲区里:

# 好像开头的那一块在内存中...
$ vmtouch -m 64G -v mordor.map
[OOo                    ] 389440/8388608

          Files: 1
    Directories: 0
  Resident Pages: 389440/8388608  1G/32G  4.64%
        Elapsed: 0.27624 seconds

# 将它全都载入到Cache!
$ cat mordor.map > /dev/null
$ vmtouch -m 64G -v mordor.map
[ooooooooo      oooOOOOO] 2919606/8388608

          Files: 1
    Directories: 0
  Resident Pages: 2919606/8388608  11G/32G  34.8%
        Elapsed: 0.59845 seconds


译注:

1.  “-m 64G” 表示允许 vmtouch 将小于 64G 的文件加载到内存中,应当是用于需要加载一个目录下的文件、但排除其中过大的文件,似乎不适用于这里;至少忽略这个参数不影响阅读
   
2.  o 表示这一块部分被加载,O 表示全部被加载。因为物理内存有限,虽然全量读取了文件,但只有部分内容被缓存

嗬,只是简单地读取一个文件就会把它缓存起来?先不管这,我们的进程呢?

$ ps -p $$ -o pid,rss
  PID  RSS
17906 286584 # <-- 等了足足一分钟


常见的误解是,映射文件会消耗内存,而通过文件API读取不会。实际上,无论哪一种方式,包含文件内容的页面都会被放进系统缓冲区。但还有个小的区别是,使用mmap的方式需要在进程的页表中创建对应的页表项(PTE),而这些包含文件内容的页面是可以被共享的。有趣的是,我们这个进程的RSS缩小了,因为系统 _需要_ 进程的页面了(译注:因为 mordor 太大,可用物理内存页不够,系统将 the_eye 的部分页面swap了;所以前述命令才会需要等一分钟,因为涉及到磁盘IO)。 

## 有时我们的所有想法都是错的

映射文件的内存总是可被回收的,区别只在于该页是否脏页 —— 脏页在回收前需要被清理(译注:写回底层存储)。所以当你在 top 命令发现有一个进程占用了大量内存时是否需要恐慌?当这个进程有很多匿名的脏页的时候才需要恐慌——因为这些页面无法被回收。如果你发现有个匿名映射段在增长,你可能就有麻烦了(而且是双倍的麻烦)。但是不要盲目相信 RSS 甚至 PSS 。

另一个常见错误是认为进程的虚拟内存和实际消耗内存之间总有某种关系,甚至认为所有内存映射都一样。任何可回收的内存,实际上都可以认为是空闲的。简而言之,它不会导致你下次内存分配失败,但_可能_会增加分配的延迟 —— 这点我会解释: 

内存管理器需要花很大功夫来决定哪些东西需要保存在物理内存里。它可能会决定将进程内存中的一部分调到swap,以便给系统缓存腾出空间,因此该进程下次访问这一块时需要再将这些页面调回到物理内存中。幸运的是这通常是可以配置的。例如,Linux 有一个叫做 swappiness[5] 的选项,用来指导内核何时开始将匿名映射的内存页调出到swap。当它取值为 0 是表示“直到绝对绝对有必要的时候”(译注:取值[0, 100],值越低,系统越倾向于先清理系统缓冲区的页面)。 

# 终章,一劳永逸地

如果你看到这里,向你致敬!我在工作之余写的这篇文章,希望能用一种更方便的方式,不仅能解释这些说过上千遍的概念,还能帮我整理这些思维,以及帮助其他人。我花了比预期更长的时间。远超预期。 

我对文章的作者们只有无尽的敬意,因为写作真是个冗长乏味、令人头秃的过程,需要永无止境的修改和重写。Jeff Atwood(译注:stack overflow的创始人)曾说过,最好的学编程书籍是教你盖房子的那本。我不记得在哪儿了,所以无法引用它。我只能说,第二好的是教你写作的那本。说到底,编程本质上就是写故事,简明扼要。 

EDIT:我修正了关于 alloca() 和 将 sizeof(char) 误写为 sizeof(char*) 的错误,多亏了 immibis 和 BonzaiThePenguin。感谢 sWvich 指出在 slab + sizeof(struct slab) 里漏了的类型转换。显然我应该用静态分析跑一下这篇文章,但并没有 —— 涨经验了。

开放问题 —— 有没有比 Markdown 代码块更好的实现?我希望能展示带注释的摘录,并且能下载整个代码块。 

写于 2015 年 2 月 20 日。







读到这里都是真爱,喜欢的话请留言支持,让更多人看到,感谢~

照例再贴下之前推送的几篇文章:

* 《踩坑记:go服务内存暴涨》 
* 《TCP:学得越多越不懂》 
* 《UTF-8:一些好像没什么用的冷知识
* 《关于RSA的一些趣事
* 《程序员面试指北:面试官视角





参考链接:
 
[1] What a C programmer should know about memory
https://marek.vavrusa.com/memory/

[2] vmtouch - the Virtual Memory Toucher
https://hoytech.com/vmtouch/

[3] kernel.org - THE /proc FILESYSTEM
https://www.kernel.org/doc/Documentation/filesystems/proc.txt

[4] FAQ (Why is “strict overcommit” a dumb idea?)
http://landley.net/writing/memory-faq.txt

[5] wikipedia - Paging - swapinness
https://en.wikipedia.org/wiki/Swappiness
May 16
续上篇:

* [译] C程序员该知道的内存知识 (1)
* [译] C程序员该知道的内存知识 (2)

这是本系列的第3篇,预计还会有1篇,感兴趣的同学记得关注,以便接收推送,等不及的推荐阅读原文。 

---

照例放图镇楼: 

点击在新窗口中浏览此图片

来源:Linux地址空间布局 - by Gustavo Duarte

关于图片的解释参见第一篇。

开始吧。


## 有趣的内存映射

工具箱: 
* sysconf() - 在运行时获取配置信息
* mmap() - 映射虚拟内存
* mincore() - 判断页是否在内存中
* shmat() - 共享内存操作

有些事情是内存分配器没法完成的,需要内存映射来救场。比如说,你无法选择分配的地址范围。为了这个,我们得牺牲一些舒适性 —— 接下来将和整页内存打交道了。注意,虽然一页通常是 4KB,但你不应该依赖这个“通常”,而是应该用 sysconf() 来获取的实际大小: 

long page_size = sysconf(_SC_PAGESIZE); /* Slice and dice. */


备注 —— 即使系统宣称使用统一的page size(译注:这里指sysconf的返回值),它在底层可能用了其他尺寸。例如Linux有个叫 transparent huge page(THP)[2]的概念,可以减少地址翻译的开销(译注:地址翻译指 虚拟地址->线性地址->物理地址,细节比较多,涉及到多级页表、MMU、TLB等,详情可参考知乎这篇文章《https://zhuanlan.zhihu.com/p/65298260" target="_blank">虚拟地址转换》[3])和连续内存块访问导致的page fault(译注:本来4KB一次,现在4MB一次,少了3个量级)。但这里还要打个问号,尤其是当物理内存碎片化,导致连续的大块内存较少的情况。一次page fault的开销也会随着页面大小提高,因此对于少量随机IO负载的情况,huge page的效率并不高。很不幸这对你是透明的,但Linux有一个专有的 mmap 选项 MAP_HUGETLB 允许你明确指定使用这个特性,因此你应该了解它的开销。

## 固定内存映射

举个栗子,假如你现在得为一个小可怜的进程间通信(IPC)建立一个固定映射(译注:两个进程都映射到相同的地址),你该如何选择映射的地址呢?这有个在 x86-32 上可能有点风险的提案,但是在 64 bit上,大约在 TASK_SIZE 2/3 位置的地址(用户空间最高的可用地址;译注:见镇楼图右上方)大致是安全的。你可以不用固定映射,但是就别想用指向共享内存的指针了(译注:不固定起始地址的话,共享内存中同一个对象在两个不同进程的地址就不一样了,这样的指针无法在两个进程中通用)。

#define TASK_SIZE 0x800000000000
#define SHARED_BLOCK (void *)(2 * TASK_SIZE / 3)

void *shared_cats = shmat(shm_key, SHARED_BLOCK, 0);
if(shared_cats == (void *)-1) {
    perror("shmat"); /* Sad :( */
}
[code]

译注:shmat是“shared memory attach”的缩写,表示将 shm_key 指定的共享内存映射到 SHARED_BLOCK 开始的虚拟地址上。shm_key 是由 `shmget(key, size, flag)` 创建的一块共享内存的标识。详细用法请google。

OKay,我知道,这是个几乎无法移植的例子,但是大意你应该能理解了。固定地址映射通常被认为至少是不安全的,因为它不检查那里是否已经映射了其他东西。有一个 mincore() 函数可以告诉你一个页面是否被映射了,但是在多线程环境里你可能不那么走运(译注:可能你刚检查的时候没被映射,但在你映射之前被另一个线程映射了;作者这里使用 mincore 可能不太恰当,因为它只检查页面是否在物理内存中,而一个页面可能被映射了、但是被换出到swap)。 

然而,固定地址映射不仅在未使用的地址范围上有用,而且对**已用的**地址范围也有用。还记得内存分配器如何使用 mmap() 来分配大块内存吗?由于按需调页机制,我们可以实现高效的稀疏数组。假设你创建了一个稀疏数组,然后现在你打算释放掉其中一些数据占用的空间,该怎么做呢?你不能 free() 它(译注:因为不是malloc分配的),而 mmap () 会让这段地址空间不可用(译注:因为这段地址空间属于稀疏数组,仍可能被访问到,不能被unmap)。你可以调用 `madvise()` ,用 MADV_FREE /  MADV_DONTNEED 将这些页面标记为空闲(译注:页面可被回收,但地址空间仍然可用),从性能上来讲这是最佳解决方案,因为这些页面可能不再会因触发 page fault 被载入,不过这些“建议”的语义可能根据具体的实现而变化(译注:换句话说就是虽然性能好,但可移植性不好,例如在Linux不同版本以及其他Unix-like系统这些建议的语义会有差别;关于这些建议的说明详见上一篇)。 

一种可移植的做法是在这货上面覆盖映射:
 
[code]
void *array = mmap(NULL, length, PROT_READ|PROT_WRITE,
                  MAP_ANONYMOUS, -1, 0);

/* ... 某些魔法玩脱了 ... */

/* Let's clear some pages. */
mmap(array + offset, length, MAP_FIXED|MAP_ANONYMOUS, -1, 0);


译注:如前文所述,开头用 mmap() 创建了一个稀疏数组 array;第四行应该是指代前述需要清理掉其中一部分数据;第7行用 mmap 重新映射从 array + offset 开始、长度为 length 字节的空间,注意这行的 length 应当是需要清理的数据长度,不同于第一行的length(整个稀疏数组的长度)。 


这等价于取消旧页面的映射,并将它们重新映射到那个**特殊页面**(译注:指上一篇说到的全 0 页面)。这会如何影响进程的内存消耗呢——进程仍然占用同样大小的虚拟内存,但是驻留在物理内存的尺寸减少了(译注:取消旧页面映射时,对应的真实页面被OS回收了)。这是我们能做到的最接近 *内存打洞* 的办法了。

## 基于文件的内存映射

工具箱: 
* msync() - 将映射到内存的文件内容同步到文件系统
* ftruncate() - 将文件截断到指定的长度
* vmsplice() - 将用户页面内容写入到管道

到这里我们已经知道关于匿名内存的所有知识了,但是在64bit地址空间中真正让人亮瞎眼的还是基于文件的内存映射,它可以提供智能的缓存、同步和写时复制(copy-on-write;译注:常缩写为COW)。是不是太多了点? 

引用

对于大多数人来说,相比直接使用文件系统,LMDB就像是魔法般的性能如雨点般撒落。

Baby_Food[4] on r/programming


译注:LMDB(Lightning Memory-mapped DataBase)是一个轻量级的、基于内存映射的kv数据库,由于可以直接返回指针、避免值拷贝,所以性能非常高;更多细节详见wikipedia。 

基于文件的共享内存映射使用一个新的模式 MAP_SHARED ,表示你对页面的修改会被写回到文件,从而可以和其他进程共享。具体何时同步取决于内存管理器,不过还好有个 msync() 可以强制将改动同步到底层存储。这对于数据库来说很重要,可以保证被写入数据的持久性(durability)。但不是谁都需要它,尤其是不需要持久化的场景下,完全不需要同步,你也不用担心丢失 写入数据的可见性(译注:这里应该是指修改后立即可读取)。这多亏了页面缓存,得益于此你也可以用内存映射来实现高效的进程间通信。 

/* Map the contents of a file into memory (shared). */
int fd = open(...);
void *db = mmap(NULL, file_size, PROT_READ|PROT_WRITE,
                MAP_SHARED, fd, 0);
if (db == (void *)-1) {
  /* Mapping failed */
}

/* Write to a page */
char *page = (char *)db;
strcpy(page, "bob");
/* This is going to be a durable page. */
msync(page, 4, MS_SYNC);
/* This is going to be a less durable page. */
page = page + PAGE_SIZE;
strcpy(page, "fred");
msync(page, 5, MS_ASYNC);


译注:MS_SYNC会等待写入底层存储后才返回;MS_ASYNC会立即返回,OS会异步写回存储,但期间如果系统异常崩溃就会导致数据丢失。 

注意,你不能映射比文件内容更长的内存,所以你无法通过这种方式增加或者减少文件的长度。不过你可以提前用 ftruncate() 来创建(或加长)一个稀疏文件(译注:稀疏文件是指,你可以创建一个很大的文件,但文件里只有少量数据;很多文件系统如ext\*、NTFS系列都支持只存储有数据的部分)。但稀疏文件的坏处是,会让紧凑的存储更困难,因为它同时要求文件系统和OS都支持才行。 

在Linux下,`fallocate(FALLOC_FL_PUNCH_HOLE)` 是最佳选项,但最适合移植(也最简单的)方法是创建一个空文件:

/* Resize the file. */
int fd = open(...);
ftruncate(fd, expected_length);


一个文件被内存映射,并不意味着不能再以文件来用它。这对于需要区分不同访问情况的场景很有用,比如说你可以一边把这个文件用只读模式映射到内存中,一边用标准的文件API来写入它。这对于有安全要求的情况很有用,因为暴露的内存映射是有写保护的,但还有些需要注意的地方。msync() 的实现没有严格定义,所以 MS_SYNC 往往就是一系列同步的写操作。呸,这样的话速度还不如用标准文件API,异步的 pwrite() 写入,以及 fsync() 或 fdatasync() 完成同步或使缓存失效。(译注:`pwrite(fd, buf, count, offset)` 往fd的offset位置写入从buf开始的count个字节,适合多线程环境,不受fd当前offset的影响;fsync(fd)、fdatasync(fd) 用于将文件的改动同步写回到磁盘) 

照例这有个警告——系统应当有一个统一的缓冲和缓存(unified buffer cache)。历史上,页面缓存(page cache,按页缓存文件的内容)和块设备缓存(block device cache,缓存磁盘的原始block数据)是两个不同的概念。这意味着同时使用标准API写入文件和使用内存映射读文件,二者会产生不一致,除非你在每次写入之后都使缓存失效。摊手。不过,你通常不用担心,只要你不是在跑OpenBSD或低于2.4版本的Linux。 

### 写时复制(Copy-On-Write)

前面讲的都还是关于共享的内存映射,但其实还有另一种用法——映射文件的一份拷贝,且对它的修改不会影响原文件。注意这些页面不会立即被复制,因为这没啥意义,而是在你修改时才被复制(译注:一方面,通常来说大部分页面不会被修改,另一方面,延迟到写时才复制,可以降低STW导致的延时)。这不仅有助于创建新进程(译注:fork新进程的时候只需要拷贝页表)或者加载共享库的场景,也有助于处理来自多个进程的大数据集的场景。

int fd = open(...);

/* Copy-on-write mapping */
void *db = mmap(NULL, file_size, PROT_READ|PROT_WRITE,
                    MAP_PRIVATE, fd, 0);
if (db == (void *)-1) {
  /* Mapping failed */
}

/* This page will be copied as soon as we write to it */
char *page = (char *)db;
strcpy(page, "bob");


译注:MAP_PRIVATE 这个 flag 用于创建 copy-on-write 映射,对该映射的改动不影响其他进程,也不会写回到被映射的文件。当写入该映射时,会触发 page fault,内核的中断程序会拷贝一份该页,修改页表,然后再恢复进程的运行。

### 零拷贝串流(Zero-copy streaming)

由于(被映射的)文件本质上就是一块内存,你可以将它“串流”(stream)到管道(也包括socket),用零拷贝模式(译注:“零拷贝”不是指完全不拷贝,而是避免在内核空间和用户空间之间来回拷贝,其典型实现是先 read(src, buf, len) 再 write(dest, buf, len) )。和 splice() 不同的是,vmsplice 适用于 copy-on-write 版本的数据(译注:splice的源数据用fd指定,vmsplice的源数据用指针指定)。*免责声明:这只适用于使用Linux的老哥!*

int sock = get_client();
struct iovec iov = { .iov_base = cat_db, .iov_len = PAGE_SIZE };
int ret = vmsplice(sock, &iov, 1, 0);
if (ret != 0) {
  /* No streaming :( */
}


译注:vmsplice第二个参数 iov 是一个指针,上例只指向一个 struct iovec,实际上它可以是一个数组,数组的长度由第三个参数标明。 

译注:举几个具体的场景,例如 nginx 使用 sendfile(底层就是splice)来提高静态文件的性能;php也提供了一个 readfile() 方法来实现零拷贝发送文件;kafka将partition数据发送给consumer时也使用了零拷贝技术,consumer数量越多,节约的开销越显著。

### mmap不顶用的场景

还有些奇葩的场景,映射文件性能会比常规实现差得多。按理来说,处理page fault会比简单读取文件块要慢,因为除了读取文件还需要做其他事情(译注:修改页表等)。但实际上,基于映射的文件IO也可能更快,因为可以避免对数据的双重甚至三重缓存(译注:可能是指文件库的缓存,例如os本身会有缓存,c的fopen/fread还内建了缓存),并且可以在后台预读数据。但有时这也有害。一个例子是“小块随机读取大于可用内存的文件”(译注:如2G内存,4G的文件,每次从随机位置读取几个字节),在这个场景下,系统预读的块大概率不会被用上,而每一次访问都会触发page fault。当然你也可以用 madvise() 做一定程度的优化(译注:用上 MADV_RANDOM 这个建议,告诉OS预读没用)。 

还有 TLB 抖动(thrashing)的问题。将虚拟页的地址翻译到物理地址是有硬件辅助的,CPU会缓存最近的翻译 —— 这就是 TLB(Translation Lookaside Buffer;译注:可译作“后备缓冲器”,CPU中的MMU专用的缓存,用来加速地址翻译)。随机访问的页面数量超过缓存能力必然会导致**抖动(thrashing)**_,_因为(在缓存不顶用时)系统必须遍历页表才能完成地址翻译。对于其他场景可以考虑使用 huge page ,但这里行不通,因为仅仅为了访问几个字节而读取几MB的数据会让性能变得更糟。 




下一篇会继续翻译最后一节《Understanding memory consumption》,敬请关注~

以及照例再贴下之前推送的几篇文章:

* 《踩坑记:go服务内存暴涨》 
* 《TCP:学得越多越不懂》 
* 《UTF-8:一些好像没什么用的冷知识
* 《关于RSA的一些趣事
* 《程序员面试指北:面试官视角






**参考链接:**

[1] What a C programmer should know about memory
https://marek.vavrusa.com/memory/

[2] Linux - Transparent huge pages
https://lwn.net/Articles/423584/

[3] 虚拟地址转换
https://zhuanlan.zhihu.com/p/65298260

[4] Reddit - What every programmer should know about solid-state drives
https://www.reddit.com/r/programming/comments/2vyzer/what_every_programmer_should_know_about/comhq3s
分页: 3/102 第一页 上页 1 2 3 4 5 6 7 8 9 10 下页 最后页 [ 显示模式: 摘要 | 列表 ]