分类 软件开发 下的文章

Linux 跟踪很神奇!

跟踪器 tracer 是一个高级的性能分析和调试工具,如果你使用过 strace(1) 或者 tcpdump(8),你不应该被它吓到 ... 你使用的就是跟踪器。系统跟踪器能让你看到很多的东西,而不仅是系统调用或者数据包,因为常见的跟踪器都可以跟踪内核或者应用程序的任何东西。

有大量的 Linux 跟踪器可供你选择。由于它们中的每个都有一个官方的(或者非官方的)的吉祥物,我们有足够多的选择给孩子们展示。

你喜欢使用哪一个呢?

我从两类读者的角度来回答这个问题:大多数人和性能/内核工程师。当然,随着时间的推移,这也可能会发生变化,因此,我需要及时去更新本文内容,或许是每年一次,或者更频繁。(LCTT 译注:本文最后更新于 2015 年)

对于大多数人

大多数人(开发者、系统管理员、运维人员、网络可靠性工程师(SRE)…)是不需要去学习系统跟踪器的底层细节的。以下是你需要去了解和做的事情:

1. 使用 perf\_events 进行 CPU 剖析

可以使用 perf\_events 进行 CPU 剖析 profiling 。它可以用一个 火焰图 来形象地表示。比如:

git clone --depth 1 https://github.com/brendangregg/FlameGraph
perf record -F 99 -a -g -- sleep 30
perf script | ./FlameGraph/stackcollapse-perf.pl | ./FlameGraph/flamegraph.pl > perf.svg

Linux 的 perf\_events(即 perf,后者是它的命令)是官方为 Linux 用户准备的跟踪器/分析器。它位于内核源码中,并且维护的非常好(而且现在它的功能还在快速变强)。它一般是通过 linux-tools-common 这个包来添加的。

perf 可以做的事情很多,但是,如果我只能建议你学习其中的一个功能的话,那就是 CPU 剖析。虽然从技术角度来说,这并不是事件“跟踪”,而是 采样 sampling 。最难的部分是获得完整的栈和符号,这部分在我的 Linux Profiling at Netflix 中针对 Java 和 Node.js 讨论过。

2. 知道它能干什么

正如一位朋友所说的:“你不需要知道 X 光机是如何工作的,但你需要明白的是,如果你吞下了一个硬币,X 光机是你的一个选择!”你需要知道使用跟踪器能够做什么,因此,如果你在业务上确实需要它,你可以以后再去学习它,或者请会使用它的人来做。

简单地说:几乎任何事情都可以通过跟踪来了解它。内部文件系统、TCP/IP 处理过程、设备驱动、应用程序内部情况。阅读我在 lwn.net 上的 ftrace 的文章,也可以去浏览 perf\_events 页面,那里有一些跟踪(和剖析)能力的示例。

3. 需要一个前端工具

如果你要购买一个性能分析工具(有许多公司销售这类产品),并要求支持 Linux 跟踪。想要一个直观的“点击”界面去探查内核的内部,以及包含一个在不同堆栈位置的延迟热力图。就像我在 Monitorama 演讲 中描述的那样。

我创建并开源了我自己的一些前端工具,虽然它是基于 CLI 的(不是图形界面的)。这样可以使其它人使用跟踪器更快更容易。比如,我的 perf-tools,跟踪新进程是这样的:

# ./execsnoop
Tracing exec()s. Ctrl-C to end.
 PID PPID ARGS
 22898 22004 man ls
 22905 22898 preconv -e UTF-8
 22908 22898 pager -s
 22907 22898 nroff -mandoc -rLL=164n -rLT=164n -Tutf8
[...]

在 Netflix 公司,我正在开发 Vector,它是一个实例分析工具,实际上它也是一个 Linux 跟踪器的前端。

对于性能或者内核工程师

一般来说,我们的工作都非常难,因为大多数人或许要求我们去搞清楚如何去跟踪某个事件,以及因此需要选择使用哪个跟踪器。为完全理解一个跟踪器,你通常需要花至少一百多个小时去使用它。理解所有的 Linux 跟踪器并能在它们之间做出正确的选择是件很难的事情。(我或许是唯一接近完成这件事的人)

在这里我建议选择如下,要么:

A)选择一个全能的跟踪器,并以它为标准。这需要在一个测试环境中花大量的时间来搞清楚它的细微差别和安全性。我现在的建议是 SystemTap 的最新版本(例如,从 源代码 构建)。我知道有的公司选择的是 LTTng ,尽管它并不是很强大(但是它很安全),但他们也用的很好。如果在 sysdig 中添加了跟踪点或者是 kprobes,它也是另外的一个候选者。

B)按我的 Velocity 教程中 的流程图。这意味着尽可能使用 ftrace 或者 perf\_events,eBPF 已经集成到内核中了,然后用其它的跟踪器,如 SystemTap/LTTng 作为对 eBPF 的补充。我目前在 Netflix 的工作中就是这么做的。

以下是我对各个跟踪器的评价:

1. ftrace

我爱 ftrace,它是内核黑客最好的朋友。它被构建进内核中,它能够利用跟踪点、kprobes、以及 uprobes,以提供一些功能:使用可选的过滤器和参数进行事件跟踪;事件计数和计时,内核概览; 函数流步进 function-flow walking 。关于它的示例可以查看内核源代码树中的 ftrace.txt。它通过 /sys 来管理,是面向单一的 root 用户的(虽然你可以使用缓冲实例以让其支持多用户),它的界面有时很繁琐,但是它比较容易 调校 hackable ,并且有个前端:ftrace 的主要创建者 Steven Rostedt 设计了一个 trace-cmd,而且我也创建了 perf-tools 集合。我最诟病的就是它不是 可编程的 programmable ,因此,举个例子说,你不能保存和获取时间戳、计算延迟,以及将其保存为直方图。你需要转储事件到用户级以便于进行后期处理,这需要花费一些成本。它也许可以通过 eBPF 实现可编程。

2. perf\_events

perf\_events 是 Linux 用户的主要跟踪工具,它的源代码位于 Linux 内核中,一般是通过 linux-tools-common 包来添加的。它又称为 perf,后者指的是它的前端,它相当高效(动态缓存),一般用于跟踪并转储到一个文件中(perf.data),然后可以在之后进行后期处理。它可以做大部分 ftrace 能做的事情。它不能进行函数流步进,并且不太容易调校(而它的安全/错误检查做的更好一些)。但它可以做剖析(采样)、CPU 性能计数、用户级的栈转换、以及使用本地变量利用 调试信息 debuginfo 进行 行级跟踪 line tracing 。它也支持多个并发用户。与 ftrace 一样,它也不是内核可编程的,除非 eBPF 支持(补丁已经在计划中)。如果只学习一个跟踪器,我建议大家去学习 perf,它可以解决大量的问题,并且它也相当安全。

3. eBPF

扩展的伯克利包过滤器 extended Berkeley Packet Filter (eBPF)是一个 内核内 in-kernel 的虚拟机,可以在事件上运行程序,它非常高效(JIT)。它可能最终为 ftrace 和 perf\_events 提供 内核内编程 in-kernel programming ,并可以去增强其它跟踪器。它现在是由 Alexei Starovoitov 开发的,还没有实现完全的整合,但是对于一些令人印象深刻的工具,有些内核版本(比如,4.1)已经支持了:比如,块设备 I/O 的 延迟热力图 latency heat map 。更多参考资料,请查阅 Alexei 的 BPF 演示,和它的 eBPF 示例

4. SystemTap

SystemTap 是一个非常强大的跟踪器。它可以做任何事情:剖析、跟踪点、kprobes、uprobes(它就来自 SystemTap)、USDT、内核内编程等等。它将程序编译成内核模块并加载它们 —— 这是一种很难保证安全的方法。它开发是在内核代码树之外进行的,并且在过去出现过很多问题(内核崩溃或冻结)。许多并不是 SystemTap 的过错 —— 它通常是首次对内核使用某些跟踪功能,并率先遇到 bug。最新版本的 SystemTap 是非常好的(你需要从它的源代码编译),但是,许多人仍然没有从早期版本的问题阴影中走出来。如果你想去使用它,花一些时间去测试环境,然后,在 irc.freenode.net 的 #systemtap 频道与开发者进行讨论。(Netflix 有一个容错架构,我们使用了 SystemTap,但是我们或许比起你来说,更少担心它的安全性)我最诟病的事情是,它似乎假设你有办法得到内核调试信息,而我并没有这些信息。没有它我实际上可以做很多事情,但是缺少相关的文档和示例(我现在自己开始帮着做这些了)。

5. LTTng

LTTng 对事件收集进行了优化,性能要好于其它的跟踪器,也支持许多的事件类型,包括 USDT。它的开发是在内核代码树之外进行的。它的核心部分非常简单:通过一个很小的固定指令集写入事件到跟踪缓冲区。这样让它既安全又快速。缺点是做内核内编程不太容易。我觉得那不是个大问题,由于它优化的很好,可以充分的扩展,尽管需要后期处理。它也探索了一种不同的分析技术。很多的“黑匣子”记录了所有感兴趣的事件,以便可以在 GUI 中以后分析它。我担心该记录会错失之前没有预料的事件,我真的需要花一些时间去看看它在实践中是如何工作的。这个跟踪器上我花的时间最少(没有特别的原因)。

6. ktap

ktap 是一个很有前途的跟踪器,它在内核中使用了一个 lua 虚拟机,不需要调试信息和在嵌入时设备上可以工作的很好。这使得它进入了人们的视野,在某个时候似乎要成为 Linux 上最好的跟踪器。然而,由于 eBPF 开始集成到了内核,而 ktap 的集成工作被推迟了,直到它能够使用 eBPF 而不是它自己的虚拟机。由于 eBPF 在几个月过去之后仍然在集成过程中,ktap 的开发者已经等待了很长的时间。我希望在今年的晚些时间它能够重启开发。

7. dtrace4linux

dtrace4linux 主要由一个人(Paul Fox)利用业务时间将 Sun DTrace 移植到 Linux 中的。它令人印象深刻,一些 供应器 provider 可以工作,还不是很完美,它最多应该算是实验性的工具(不安全)。我认为对于许可证的担心,使人们对它保持谨慎:它可能永远也进入不了 Linux 内核,因为 Sun 是基于 CDDL 许可证发布的 DTrace;Paul 的方法是将它作为一个插件。我非常希望看到 Linux 上的 DTrace,并且希望这个项目能够完成,我想我加入 Netflix 时将花一些时间来帮它完成。但是,我一直在使用内置的跟踪器 ftrace 和 perf\_events。

8. OL DTrace

Oracle Linux DTrace 是将 DTrace 移植到 Linux (尤其是 Oracle Linux)的重大努力。过去这些年的许多发布版本都一直稳定的进步,开发者甚至谈到了改善 DTrace 测试套件,这显示出这个项目很有前途。许多有用的功能已经完成:系统调用、剖析、sdt、proc、sched、以及 USDT。我一直在等待着 fbt(函数边界跟踪,对内核的动态跟踪),它将成为 Linux 内核上非常强大的功能。它最终能否成功取决于能否吸引足够多的人去使用 Oracle Linux(并为支持付费)。另一个羁绊是它并非完全开源的:内核组件是开源的,但用户级代码我没有看到。

9. sysdig

sysdig 是一个很新的跟踪器,它可以使用类似 tcpdump 的语法来处理 系统调用 syscall 事件,并用 lua 做后期处理。它也是令人印象深刻的,并且很高兴能看到在系统跟踪领域的创新。它的局限性是,它的系统调用只能是在当时,并且,它转储所有事件到用户级进行后期处理。你可以使用系统调用来做许多事情,虽然我希望能看到它去支持跟踪点、kprobes、以及 uprobes。我也希望看到它支持 eBPF 以查看内核内概览。sysdig 的开发者现在正在增加对容器的支持。可以关注它的进一步发展。

深入阅读

我自己的工作中使用到的跟踪器包括:

不好意思,没有更多的跟踪器了! … 如果你想知道为什么 Linux 中的跟踪器不止一个,或者关于 DTrace 的内容,在我的 从 DTrace 到 Linux 的演讲中有答案,从 第 28 张幻灯片 开始。

感谢 Deirdre Straughan 的编辑,以及跟踪小马的创建(General Zoi 是小马的创建者)。


via: http://www.brendangregg.com/blog/2015-07-08/choosing-a-linux-tracer.html

作者:Brendan Gregg 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

这是我写的并发网络服务器系列文章的第五部分。在前四部分中我们讨论了并发服务器的结构,这篇文章我们将去研究一个在生产系统中大量使用的服务器的案例—— Redis

Redis logo

Redis 是一个非常有魅力的项目,我关注它很久了。它最让我着迷的一点就是它的 C 源代码非常清晰。它也是一个高性能、大并发的内存数据库服务器的非常好的例子,它是研究网络并发服务器的一个非常好的案例,因此,我们不能错过这个好机会。

我们来看看前四部分讨论的概念在真实世界中的应用程序。

本系列的所有文章有:

事件处理库

Redis 最初发布于 2009 年,它最牛逼的一件事情大概就是它的速度 —— 它能够处理大量的并发客户端连接。需要特别指出的是,它是用一个单线程来完成的,而且还不对保存在内存中的数据使用任何复杂的锁或者同步机制。

Redis 之所以如此牛逼是因为,它在给定的系统上使用了其可用的最快的事件循环,并将它们封装成由它实现的事件循环库(在 Linux 上是 epoll,在 BSD 上是 kqueue,等等)。这个库的名字叫做 ae。ae 使得编写一个快速服务器变得很容易,只要在它内部没有阻塞即可,而 Redis 则保证 注1 了这一点。

在这里,我们的兴趣点主要是它对文件事件的支持 —— 当文件描述符(如网络套接字)有一些有趣的未决事情时将调用注册的回调函数。与 libuv 类似,ae 支持多路事件循环(参阅本系列的第三节第四节)和不应该感到意外的 aeCreateFileEvent 信号:

int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
                      aeFileProc *proc, void *clientData);

它在 fd 上使用一个给定的事件循环,为新的文件事件注册一个回调(proc)函数。当使用的是 epoll 时,它将调用 epoll_ctl 在文件描述符上添加一个事件(可能是 EPOLLINEPOLLOUT、也或许两者都有,取决于 mask 参数)。ae 的 aeProcessEvents 功能是 “运行事件循环和发送回调函数”,它在底层调用了 epoll_wait

处理客户端请求

我们通过跟踪 Redis 服务器代码来看一下,ae 如何为客户端事件注册回调函数的。initServer 启动时,通过注册一个回调函数来读取正在监听的套接字上的事件,通过使用回调函数 acceptTcpHandler 来调用 aeCreateFileEvent。当新的连接可用时,这个回调函数被调用。它调用 accept 注2 ,接下来是 acceptCommonHandler,它转而去调用 createClient 以初始化新客户端连接所需要的数据结构。

createClient 的工作是去监听来自客户端的入站数据。它将套接字设置为非阻塞模式(一个异步事件循环中的关键因素)并使用 aeCreateFileEvent 去注册另外一个文件事件回调函数以读取事件 —— readQueryFromClient。每当客户端发送数据,这个函数将被事件循环调用。

readQueryFromClient 就让我们期望的那样 —— 解析客户端命令和动作,并通过查询和/或操作数据来回复。因为客户端套接字是非阻塞的,所以这个函数必须能够处理 EAGAIN,以及部分数据;从客户端中读取的数据是累积在客户端专用的缓冲区中,而完整的查询可能被分割在回调函数的多个调用当中。

将数据发送回客户端

在前面的内容中,我说到了 readQueryFromClient 结束了发送给客户端的回复。这在逻辑上是正确的,因为 readQueryFromClient 准备要发送回复,但它不真正去做实质的发送 —— 因为这里并不能保证客户端套接字已经准备好写入/发送数据。我们必须为此使用事件循环机制。

Redis 是这样做的,它注册一个 beforeSleep 函数,每次事件循环即将进入休眠时,调用它去等待套接字变得可以读取/写入。beforeSleep 做的其中一件事情就是调用 handleClientsWithPendingWrites。它的作用是通过调用 writeToClient 去尝试立即发送所有可用的回复;如果一些套接字不可用时,那么套接字可用时,它将注册一个事件循环去调用 sendReplyToClient。这可以被看作为一种优化 —— 如果套接字可用于立即发送数据(一般是 TCP 套接字),这时并不需要注册事件 ——直接发送数据。因为套接字是非阻塞的,它从不会去阻塞循环。

为什么 Redis 要实现它自己的事件库?

第四节 中我们讨论了使用 libuv 来构建一个异步并发服务器。需要注意的是,Redis 并没有使用 libuv,或者任何类似的事件库,而是它去实现自己的事件库 —— ae,用 ae 来封装 epoll、kqueue 和 select。事实上,Antirez(Redis 的创建者)恰好在 2011 年的一篇文章 中回答了这个问题。他的回答的要点是:ae 只有大约 770 行他理解的非常透彻的代码;而 libuv 代码量非常巨大,也没有提供 Redis 所需的额外功能。

现在,ae 的代码大约增长到 1300 多行,比起 libuv 的 26000 行(这是在没有 Windows、测试、示例、文档的情况下的数据)来说那是小巫见大巫了。libuv 是一个非常综合的库,这使它更复杂,并且很难去适应其它项目的特殊需求;另一方面,ae 是专门为 Redis 设计的,与 Redis 共同演进,只包含 Redis 所需要的东西。

这是我 前些年在一篇文章中 提到的软件项目依赖关系的另一个很好的示例:

依赖的优势与在软件项目上花费的工作量成反比。

在某种程度上,Antirez 在他的文章中也提到了这一点。他提到,提供大量附加价值(在我的文章中的“基础” 依赖)的依赖比像 libuv 这样的依赖更有意义(它的例子是 jemalloc 和 Lua),对于 Redis 特定需求,其功能的实现相当容易。

Redis 中的多线程

在 Redis 的绝大多数历史中,它都是一个不折不扣的单线程的东西。一些人觉得这太不可思议了,有这种想法完全可以理解。Redis 本质上是受网络束缚的 —— 只要数据库大小合理,对于任何给定的客户端请求,其大部分延时都是浪费在网络等待上,而不是在 Redis 的数据结构上。

然而,现在事情已经不再那么简单了。Redis 现在有几个新功能都用到了线程:

  1. “惰性” 内存释放
  2. 在后台线程中使用 fsync 调用写一个 持久化日志
  3. 运行需要执行一个长周期运行的操作的用户定义模块。

对于前两个特性,Redis 使用它自己的一个简单的 bio(它是 “Background I/O" 的首字母缩写)库。这个库是根据 Redis 的需要进行了硬编码,它不能用到其它的地方 —— 它运行预设数量的线程,每个 Redis 后台作业类型需要一个线程。

而对于第三个特性,Redis 模块 可以定义新的 Redis 命令,并且遵循与普通 Redis 命令相同的标准,包括不阻塞主线程。如果在模块中自定义的一个 Redis 命令,希望去执行一个长周期运行的操作,这将创建一个线程在后台去运行它。在 Redis 源码树中的 src/modules/helloblock.c 提供了这样的一个示例。

有了这些特性,Redis 使用线程将一个事件循环结合起来,在一般的案例中,Redis 具有了更快的速度和弹性,这有点类似于在本系统文章中 第四节 讨论的工作队列。

  • 注1: Redis 的一个核心部分是:它是一个 内存中 数据库;因此,查询从不会运行太长的时间。当然了,这将会带来各种各样的其它问题。在使用分区的情况下,服务器可能最终路由一个请求到另一个实例上;在这种情况下,将使用异步 I/O 来避免阻塞其它客户端。
  • 注2: 使用 anetAcceptanet 是 Redis 对 TCP 套接字代码的封装。

via: https://eli.thegreenplace.net/2017/concurrent-servers-part-5-redis-case-study/

作者:Eli Bendersky 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

“如果你不知道编译器是怎么工作的,那你就不知道电脑是怎么工作的。如果你不能百分百确定,那就是不知道它们是如何工作的。” --Steve Yegge

就是这样。想一想。你是萌新还是一个资深的软件开发者实际上都无关紧要:如果你不知道 编译器 compiler 解释器 interpreter 是怎么工作的,那么你就不知道电脑是怎么工作的。就这么简单。

所以,你知道编译器和解释器是怎么工作的吗?我是说,你百分百确定自己知道他们怎么工作吗?如果不知道。

或者如果你不知道但你非常想要了解它。

不用担心。如果你能坚持跟着这个系列做下去,和我一起构建一个解释器和编译器,最后你将会知道他们是怎么工作的。并且你会变成一个自信满满的快乐的人。至少我希望如此。

为什么要学习编译器和解释器?有三点理由。

  1. 要写出一个解释器或编译器,你需要有很多的专业知识,并能融会贯通。写一个解释器或编译器能帮你加强这些能力,成为一个更厉害的软件开发者。而且,你要学的技能对编写软件非常有用,而不是仅仅局限于解释器或编译器。
  2. 你确实想要了解电脑是怎么工作的。通常解释器和编译器看上去很魔幻。你或许不习惯这种魔力。你会想去揭开构建解释器和编译器那层神秘的面纱,了解它们的原理,把事情做好。
  3. 你想要创建自己的编程语言或者特定领域的语言。如果你创建了一个,你还要为它创建一个解释器或者编译器。最近,兴起了对新的编程语言的兴趣。你能看到几乎每天都有一门新的编程语言横空出世:Elixir,Go,Rust,还有很多。

好,但什么是解释器和编译器?

解释器编译器 的任务是把用高级语言写的源程序翻译成其他的格式。很奇怪,是不是?忍一忍,稍后你会在这个系列学到到底把源程序翻译成什么东西。

这时你可能会奇怪解释器和编译器之间有什么区别。为了实现这个系列的目的,我们规定一下,如果有个翻译器把源程序翻译成机器语言,那它就是 编译器。如果一个翻译器可以处理并执行源程序,却不用把它翻译器机器语言,那它就是 解释器。直观上它看起来像这样:

我希望你现在确信你很想学习构建一个编译器和解释器。你期望在这个教程里学习解释器的哪些知识呢?

你看这样如何。你和我一起为 Pascal 语言的一个大子集做一个简单的解释器。在这个系列结束的时候你能做出一个可以运行的 Pascal 解释器和一个像 Python 的 pdb 那样的源代码级别的调试器。

你或许会问,为什么是 Pascal?一方面,它不是我为了这个系列而提出的一个虚构的语言:它是真实存在的一门编程语言,有很多重要的语言结构。有些陈旧但有用的计算机书籍使用 Pascal 编程语言作为示例(我知道对于选择一门语言来构建解释器,这个理由并不令人信服,但我认为学一门非主流的语言也不错 :))。

这有个 Pascal 中的阶乘函数示例,你将能用自己的解释器解释代码,还能够用可交互的源码级调试器进行调试,你可以这样创造:

program factorial;

function factorial(n: integer): longint;
begin
    if n = 0 then
        factorial := 1
    else
        factorial := n * factorial(n - 1);
end;

var
    n: integer;

begin
    for n := 0 to 16 do
        writeln(n, '! = ', factorial(n));
end.

这个 Pascal 解释器的实现语言会使用 Python,但你也可以用其他任何语言,因为这里展示的思想不依赖任何特殊的实现语言。好,让我们开始干活。准备好了,出发!

你会从编写一个简单的算术表达式解析器,也就是常说的计算器,开始学习解释器和编译器。今天的目标非常简单:让你的计算器能处理两个个位数相加,比如 3+5。下面是你的计算器的源代码——不好意思,是解释器:

# 标记类型
#
# EOF (end-of-file 文件末尾)标记是用来表示所有输入都解析完成
INTEGER, PLUS, EOF = 'INTEGER', 'PLUS', 'EOF'


class Token(object):
    def __init__(self, type, value):
        # token 类型: INTEGER, PLUS, MINUS, or EOF
        self.type = type
        # token 值: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, '+', 或 None
        self.value = value

    def __str__(self):
        """String representation of the class instance.

        Examples:
            Token(INTEGER, 3)
            Token(PLUS '+')
        """
        return 'Token({type}, {value})'.format(
            type=self.type,
            value=repr(self.value)
        )

    def __repr__(self):
        return self.__str__()


class Interpreter(object):
    def __init__(self, text):
        # 用户输入字符串, 例如 "3+5"
        self.text = text
        # self.pos 是 self.text 的索引
        self.pos = 0
        # 当前标记实例
        self.current_token = None

    def error(self):
        raise Exception('Error parsing input')

    def get_next_token(self):
        """词法分析器(也说成扫描器或者标记器)

        该方法负责把一个句子分成若干个标记。每次处理一个标记
        """
        text = self.text

        # self.pos 索引到达了 self.text 的末尾吗?
        # 如果到了,就返回 EOF 标记,因为没有更多的
        # 能转换成标记的输入了
        if self.pos > len(text) - 1:
            return Token(EOF, None)

        # 从 self.pos 位置获取当前的字符,
        # 基于单个字符判断要生成哪种标记
        current_char = text[self.pos]
        # 如果字符是一个数字,就把他转换成一个整数,生成一个 INTEGER # 标记,累加 self.pos 索引,指向数字后面的下一个字符,
        # 并返回 INTEGER 标记
        if current_char.isdigit():
            token = Token(INTEGER, int(current_char))
            self.pos += 1
            return token

        if current_char == '+':
            token = Token(PLUS, current_char)
            self.pos += 1
            return token

        self.error()

    def eat(self, token_type):
        # 将当前的标记类型与传入的标记类型作比较,如果他们相匹配,就
        # “eat” 掉当前的标记并将下一个标记赋给 self.current_token,
        # 否则抛出一个异常
        if self.current_token.type == token_type:
            self.current_token = self.get_next_token()
        else:
            self.error()

    def expr(self):
        """expr -> INTEGER PLUS INTEGER"""
        # 将输入中的第一个标记设置成当前标记
        self.current_token = self.get_next_token()

        # 我们期望当前标记是个位数。
        left = self.current_token
        self.eat(INTEGER)

        # 期望当前标记是 ‘+’ 号
        op = self.current_token
        self.eat(PLUS)

        # 我们期望当前标记是个位数。
        right = self.current_token
        self.eat(INTEGER)

        # 上述操作完成后,self.current_token 被设成 EOF 标记
        # 这时成功找到 INTEGER PLUS INTEGER 标记序列
        # 这个方法就可以返回两个整数相加的结果了,
        # 即高效的解释了用户输入
        result = left.value + right.value
        return result


def main():
    while True:
        try:
            # 要在 Python3 下运行,请把 ‘raw_input’ 换成 ‘input’
            text = raw_input('calc> ')
        except EOFError:
            break
        if not text:
            continue
        interpreter = Interpreter(text)
        result = interpreter.expr()
        print(result)


if __name__ == '__main__':
    main()

把上面的代码保存到 calc1.py 文件,或者直接从 GitHub 上下载。在你深入研究代码前,在命令行里面运行它看看效果。试一试!这是我笔记本上的示例会话(如果你想在 Python3 下运行,你要把 raw_input 换成 input):

$ python calc1.py
calc> 3+4
7
calc> 3+5
8
calc> 3+9
12
calc>

要让你的简易计算器正常工作,不抛出异常,你的输入要遵守以下几个规则:

  • 只允许输入个位数
  • 此时支持的唯一一个运算符是加法
  • 输入中不允许有任何的空格符号

要让计算器变得简单,这些限制非常必要。不用担心,你很快就会让它变得很复杂。

好,现在让我们深入它,看看解释器是怎么工作,它是怎么评估出算术表达式的。

当你在命令行中输入一个表达式 3+5,解释器就获得了字符串 “3+5”。为了让解释器能够真正理解要用这个字符串做什么,它首先要把输入 “3+5” 分到叫做 token(标记)的容器里。 标记 token 是一个拥有类型和值的对象。比如说,对字符 “3” 而言,标记的类型是 INTEGER 整数,对应的值是 3。

把输入字符串分成标记的过程叫 词法分析 lexical analysis 。因此解释器的需要做的第一步是读取输入字符,并将其转换成标记流。解释器中的这一部分叫做 词法分析器 lexical analyzer ,或者简短点叫 lexer。你也可以给它起别的名字,诸如 扫描器 scanner 或者 标记器 tokenizer 。它们指的都是同一个东西:解释器或编译器中将输入字符转换成标记流的那部分。

Interpreter 类中的 get_next_token 方法就是词法分析器。每次调用它的时候,你都能从传入解释器的输入字符中获得创建的下一个标记。仔细看看这个方法,看看它是如何完成把字符转换成标记的任务的。输入被存在可变文本中,它保存了输入的字符串和关于该字符串的索引(把字符串想象成字符数组)。pos 开始时设为 0,指向字符 ‘3’。这个方法一开始检查字符是不是数字,如果是,就将 pos 加 1,并返回一个 INTEGER 类型的标记实例,并把字符 ‘3’ 的值设为整数,也就是整数 3:

现在 pos 指向文本中的 ‘+’ 号。下次调用这个方法的时候,它会测试 pos 位置的字符是不是个数字,然后检测下一个字符是不是个加号,就是这样。结果这个方法把 pos 加 1,返回一个新创建的标记,类型是 PLUS,值为 ‘+’。

pos 现在指向字符 ‘5’。当你再调用 get_next_token 方法时,该方法会检查这是不是个数字,就是这样,然后它把 pos 加 1,返回一个新的 INTEGER 标记,该标记的值被设为整数 5:

因为 pos 索引现在到了字符串 “3+5” 的末尾,你每次调用 get_next_token 方法时,它将会返回 EOF 标记:

自己试一试,看看计算器里的词法分析器的运行:

>>> from calc1 import Interpreter
>>>
>>> interpreter = Interpreter('3+5')
>>> interpreter.get_next_token()
Token(INTEGER, 3)
>>>
>>> interpreter.get_next_token()
Token(PLUS, '+')
>>>
>>> interpreter.get_next_token()
Token(INTEGER, 5)
>>>
>>> interpreter.get_next_token()
Token(EOF, None)
>>>

既然你的解释器能够从输入字符中获取标记流,解释器需要对它做点什么:它需要在词法分析器 get_next_token 中获取的标记流中找出相应的结构。你的解释器应该能够找到流中的结构:INTEGER -> PLUS -> INTEGER。就是这样,它尝试找出标记的序列:整数后面要跟着加号,加号后面要跟着整数。

负责找出并解释结构的方法就是 expr。该方法检验标记序列确实与期望的标记序列是对应的,比如 INTEGER -> PLUS -> INTEGER。成功确认了这个结构后,就会生成加号左右两边的标记的值相加的结果,这样就成功解释你输入到解释器中的算术表达式了。

expr 方法用了一个助手方法 eat 来检验传入的标记类型是否与当前的标记类型相匹配。在匹配到传入的标记类型后,eat 方法会获取下一个标记,并将其赋给 current_token 变量,然后高效地 “吃掉” 当前匹配的标记,并将标记流的虚拟指针向后移动。如果标记流的结构与期望的 INTEGER -> PLUS -> INTEGER 标记序列不对应,eat 方法就抛出一个异常。

让我们回顾下解释器做了什么来对算术表达式进行评估的:

  • 解释器接受输入字符串,比如说 “3+5”
  • 解释器调用 expr 方法,在词法分析器 get_next_token 返回的标记流中找出结构。这个结构就是 INTEGER -> PLUS -> INTEGER 这样的格式。在确认了格式后,它就通过把两个整型标记相加来解释输入,因为此时对于解释器来说很清楚,它要做的就是把两个整数 3 和 5 进行相加。

恭喜。你刚刚学习了怎么构建自己的第一个解释器!

现在是时候做练习了。

看了这篇文章,你肯定觉得不够,是吗?好,准备好做这些练习:

  1. 修改代码,允许输入多位数,比如 “12+3”
  2. 添加一个方法忽略空格符,让你的计算器能够处理带有空白的输入,比如 “12 + 3”
  3. 修改代码,用 ‘-’ 号而非 ‘+’ 号去执行减法比如 “7-5”

检验你的理解

  1. 什么是解释器?
  2. 什么是编译器
  3. 解释器和编译器有什么差别?
  4. 什么是标记?
  5. 将输入分隔成若干个标记的过程叫什么?
  6. 解释器中进行词法分析的部分叫什么?
  7. 解释器或编译器中进行词法分析的部分有哪些其他的常见名字?

在结束本文前,我衷心希望你能留下学习解释器和编译器的承诺。并且现在就开始做。不要把它留到以后。不要拖延。如果你已经看完了本文,就开始吧。如果已经仔细看完了但是还没做什么练习 —— 现在就开始做吧。如果已经开始做练习了,那就把剩下的做完。你懂得。而且你知道吗?签下承诺书,今天就开始学习解释器和编译器!

本人, \_\_\_\_\_\_,身体健全,思想正常,在此承诺从今天开始学习解释器和编译器,直到我百分百了解它们是怎么工作的!

签字人:

日期:

签字,写上日期,把它放在你每天都能看到的地方,确保你能坚守承诺。谨记你的承诺:

“承诺就是,你说自己会去做的事,在你说完就一直陪着你的东西。” —— Darren Hardy

好,今天的就结束了。这个系列的下一篇文章里,你将会扩展自己的计算器,让它能够处理更复杂的算术表达式。敬请期待。


via: https://ruslanspivak.com/lsbasi-part1/

作者:Ruslan Spivak 译者:BriFuture 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

这是并发网络服务器系列文章的第四部分。在这一部分中,我们将使用 libuv 再次重写我们的服务器,并且也会讨论关于使用一个线程池在回调中去处理耗时任务。最终,我们去看一下底层的 libuv,花一点时间去学习如何用异步 API 对文件系统阻塞操作进行封装。

本系列的所有文章:

使用 libuv 抽象出事件驱动循环

第三节 中,我们看到了基于 selectepoll 的服务器的相似之处,并且,我说过,在它们之间抽象出细微的差别是件很有吸引力的事。许多库已经做到了这些,所以在这一部分中我将去选一个并使用它。我选的这个库是 libuv,它最初设计用于 Node.js 底层的可移植平台层,并且,后来发现在其它的项目中也有使用。libuv 是用 C 写的,因此,它具有很高的可移植性,非常适用嵌入到像 JavaScript 和 Python 这样的高级语言中。

虽然 libuv 为了抽象出底层平台细节已经变成了一个相当大的框架,但它仍然是以 事件循环 思想为中心的。在我们第三部分的事件驱动服务器中,事件循环是显式定义在 main 函数中的;当使用 libuv 时,该循环通常隐藏在库自身中,而用户代码仅需要注册事件句柄(作为一个回调函数)和运行这个循环。此外,libuv 会在给定的平台上使用更快的事件循环实现,对于 Linux 它是 epoll,等等。

libuv loop

libuv 支持多路事件循环,因此事件循环在库中是非常重要的;它有一个句柄 —— uv_loop_t,以及创建/杀死/启动/停止循环的函数。也就是说,在这篇文章中,我将仅需要使用 “默认的” 循环,libuv 可通过 uv_default_loop() 提供它;多路循环大多用于多线程事件驱动的服务器,这是一个更高级别的话题,我将留在这一系列文章的以后部分。

使用 libuv 的并发服务器

为了对 libuv 有一个更深的印象,让我们跳转到我们的可靠协议的服务器,它通过我们的这个系列已经有了一个强大的重新实现。这个服务器的结构与第三部分中的基于 selectepoll 的服务器有一些相似之处,因为,它也依赖回调。完整的 示例代码在这里;我们开始设置这个服务器的套接字绑定到一个本地端口:

int portnum = 9090;
if (argc >= 2) {
  portnum = atoi(argv[1]);
}
printf("Serving on port %d\n", portnum);

int rc;
uv_tcp_t server_stream;
if ((rc = uv_tcp_init(uv_default_loop(), &server_stream)) < 0) {
  die("uv_tcp_init failed: %s", uv_strerror(rc));
}

struct sockaddr_in server_address;
if ((rc = uv_ip4_addr("0.0.0.0", portnum, &server_address)) < 0) {
  die("uv_ip4_addr failed: %s", uv_strerror(rc));
}

if ((rc = uv_tcp_bind(&server_stream, (const struct sockaddr*)&server_address, 0)) < 0) {
  die("uv_tcp_bind failed: %s", uv_strerror(rc));
}

除了它被封装进 libuv API 中之外,你看到的是一个相当标准的套接字。在它的返回中,我们取得了一个可工作于任何 libuv 支持的平台上的可移植接口。

这些代码也展示了很认真负责的错误处理;多数的 libuv 函数返回一个整数状态,返回一个负数意味着出现了一个错误。在我们的服务器中,我们把这些错误看做致命问题进行处理,但也可以设想一个更优雅的错误恢复。

现在,那个套接字已经绑定,是时候去监听它了。这里我们运行首个回调注册:

// Listen on the socket for new peers to connect. When a new peer connects,
// the on_peer_connected callback will be invoked.
if ((rc = uv_listen((uv_stream_t*)&server_stream, N_BACKLOG, on_peer_connected)) < 0) {
  die("uv_listen failed: %s", uv_strerror(rc));
}

uv_listen 注册一个事件回调,当新的对端连接到这个套接字时将会调用事件循环。我们的回调在这里被称为 on_peer_connected,我们一会儿将去查看它。

最终,main 运行这个 libuv 循环,直到它被停止(uv_run 仅在循环被停止或者发生错误时返回)。

// Run the libuv event loop.
uv_run(uv_default_loop(), UV_RUN_DEFAULT);

// If uv_run returned, close the default loop before exiting.
return uv_loop_close(uv_default_loop());

注意,在运行事件循环之前,只有一个回调是通过 main 注册的;我们稍后将看到怎么去添加更多的回调。在事件循环的整个运行过程中,添加和删除回调并不是一个问题 —— 事实上,大多数服务器就是这么写的。

这是一个 on_peer_connected,它处理到服务器的新的客户端连接:

void on_peer_connected(uv_stream_t* server_stream, int status) {
  if (status < 0) {
    fprintf(stderr, "Peer connection error: %s\n", uv_strerror(status));
    return;
  }

  // client will represent this peer; it's allocated on the heap and only
  // released when the client disconnects. The client holds a pointer to
  // peer_state_t in its data field; this peer state tracks the protocol state
  // with this client throughout interaction.
  uv_tcp_t* client = (uv_tcp_t*)xmalloc(sizeof(*client));
  int rc;
  if ((rc = uv_tcp_init(uv_default_loop(), client)) < 0) {
    die("uv_tcp_init failed: %s", uv_strerror(rc));
  }
  client->data = NULL;

  if (uv_accept(server_stream, (uv_stream_t*)client) == 0) {
    struct sockaddr_storage peername;
    int namelen = sizeof(peername);
    if ((rc = uv_tcp_getpeername(client, (struct sockaddr*)&peername,
                                 &namelen)) < 0) {
      die("uv_tcp_getpeername failed: %s", uv_strerror(rc));
    }
    report_peer_connected((const struct sockaddr_in*)&peername, namelen);

    // Initialize the peer state for a new client: we start by sending the peer
    // the initial '*' ack.
    peer_state_t* peerstate = (peer_state_t*)xmalloc(sizeof(*peerstate));
    peerstate->state = INITIAL_ACK;
    peerstate->sendbuf[0] = '*';
    peerstate->sendbuf_end = 1;
    peerstate->client = client;
    client->data = peerstate;

    // Enqueue the write request to send the ack; when it's done,
    // on_wrote_init_ack will be called. The peer state is passed to the write
    // request via the data pointer; the write request does not own this peer
    // state - it's owned by the client handle.
    uv_buf_t writebuf = uv_buf_init(peerstate->sendbuf, peerstate->sendbuf_end);
    uv_write_t* req = (uv_write_t*)xmalloc(sizeof(*req));
    req->data = peerstate;
    if ((rc = uv_write(req, (uv_stream_t*)client, &writebuf, 1,
                       on_wrote_init_ack)) < 0) {
      die("uv_write failed: %s", uv_strerror(rc));
    }
  } else {
    uv_close((uv_handle_t*)client, on_client_closed);
  }
}

这些代码都有很好的注释,但是,这里有一些重要的 libuv 语法我想去强调一下:

  • 传入自定义数据到回调中:因为 C 语言还没有闭包,这可能是个挑战,libuv 在它的所有的处理类型中有一个 void* data 字段;这些字段可以被用于传递用户数据。例如,注意 client->data 是如何指向到一个 peer_state_t 结构上,以便于 uv_writeuv_read_start 注册的回调可以知道它们正在处理的是哪个客户端的数据。
  • 内存管理:在带有垃圾回收的语言中进行事件驱动编程是非常容易的,因为,回调通常运行在一个与它们注册的地方完全不同的栈帧中,使得基于栈的内存管理很困难。它总是需要传递堆分配的数据到 libuv 回调中(当所有回调运行时,除了 main,其它的都运行在栈上),并且,为了避免泄漏,许多情况下都要求这些数据去安全释放(free())。这些都是些需要实践的内容 注1

这个服务器上对端的状态如下:

typedef struct {
  ProcessingState state;
  char sendbuf[SENDBUF_SIZE];
  int sendbuf_end;
  uv_tcp_t* client;
} peer_state_t;

它与第三部分中的状态非常类似;我们不再需要 sendptr,因为,在调用 “done writing” 回调之前,uv_write 将确保发送它提供的整个缓冲。我们也为其它的回调使用保持了一个到客户端的指针。这里是 on_wrote_init_ack

void on_wrote_init_ack(uv_write_t* req, int status) {
  if (status) {
    die("Write error: %s\n", uv_strerror(status));
  }
  peer_state_t* peerstate = (peer_state_t*)req->data;
  // Flip the peer state to WAIT_FOR_MSG, and start listening for incoming data
  // from this peer.
  peerstate->state = WAIT_FOR_MSG;
  peerstate->sendbuf_end = 0;

  int rc;
  if ((rc = uv_read_start((uv_stream_t*)peerstate->client, on_alloc_buffer,
                          on_peer_read)) < 0) {
    die("uv_read_start failed: %s", uv_strerror(rc));
  }

  // Note: the write request doesn't own the peer state, hence we only free the
  // request itself, not the state.
  free(req);
}

然后,我们确信知道了这个初始的 '*' 已经被发送到对端,我们通过调用 uv_read_start 去监听从这个对端来的入站数据,它注册一个将被事件循环调用的回调(on_peer_read),不论什么时候,事件循环都在套接字上接收来自客户端的调用:

void on_peer_read(uv_stream_t* client, ssize_t nread, const uv_buf_t* buf) {
  if (nread < 0) {
    if (nread != uv_eof) {
      fprintf(stderr, "read error: %s\n", uv_strerror(nread));
    }
    uv_close((uv_handle_t*)client, on_client_closed);
  } else if (nread == 0) {
    // from the documentation of uv_read_cb: nread might be 0, which does not
    // indicate an error or eof. this is equivalent to eagain or ewouldblock
    // under read(2).
  } else {
    // nread > 0
    assert(buf->len >= nread);

    peer_state_t* peerstate = (peer_state_t*)client->data;
    if (peerstate->state == initial_ack) {
      // if the initial ack hasn't been sent for some reason, ignore whatever
      // the client sends in.
      free(buf->base);
      return;
    }

    // run the protocol state machine.
    for (int i = 0; i < nread; ++i) {
      switch (peerstate->state) {
      case initial_ack:
        assert(0 && "can't reach here");
        break;
      case wait_for_msg:
        if (buf->base[i] == '^') {
          peerstate->state = in_msg;
        }
        break;
      case in_msg:
        if (buf->base[i] == '$') {
          peerstate->state = wait_for_msg;
        } else {
          assert(peerstate->sendbuf_end < sendbuf_size);
          peerstate->sendbuf[peerstate->sendbuf_end++] = buf->base[i] + 1;
        }
        break;
      }
    }

    if (peerstate->sendbuf_end > 0) {
      // we have data to send. the write buffer will point to the buffer stored
      // in the peer state for this client.
      uv_buf_t writebuf =
          uv_buf_init(peerstate->sendbuf, peerstate->sendbuf_end);
      uv_write_t* writereq = (uv_write_t*)xmalloc(sizeof(*writereq));
      writereq->data = peerstate;
      int rc;
      if ((rc = uv_write(writereq, (uv_stream_t*)client, &writebuf, 1,
                         on_wrote_buf)) < 0) {
        die("uv_write failed: %s", uv_strerror(rc));
      }
    }
  }
  free(buf->base);
}

这个服务器的运行时行为非常类似于第三部分的事件驱动服务器:所有的客户端都在一个单个的线程中并发处理。并且类似的,一些特定的行为必须在服务器代码中维护:服务器的逻辑实现为一个集成的回调,并且长周期运行是禁止的,因为它会阻塞事件循环。这一点也很类似。让我们进一步探索这个问题。

在事件驱动循环中的长周期运行的操作

单线程的事件驱动代码使它先天就容易受到一些常见问题的影响:长周期运行的代码会阻塞整个循环。参见如下的程序:

void on_timer(uv_timer_t* timer) {
  uint64_t timestamp = uv_hrtime();
  printf("on_timer [%" PRIu64 " ms]\n", (timestamp / 1000000) % 100000);

  // "Work"
  if (random() % 5 == 0) {
    printf("Sleeping...\n");
    sleep(3);
  }
}

int main(int argc, const char** argv) {
  uv_timer_t timer;
  uv_timer_init(uv_default_loop(), &timer);
  uv_timer_start(&timer, on_timer, 0, 1000);
  return uv_run(uv_default_loop(), UV_RUN_DEFAULT);
}

它用一个单个注册的回调运行一个 libuv 事件循环:on_timer,它被每秒钟循环调用一次。回调报告一个时间戳,并且,偶尔通过睡眠 3 秒去模拟一个长周期运行。这是运行示例:

$ ./uv-timer-sleep-demo
on_timer [4840 ms]
on_timer [5842 ms]
on_timer [6843 ms]
on_timer [7844 ms]
Sleeping...
on_timer [11845 ms]
on_timer [12846 ms]
Sleeping...
on_timer [16847 ms]
on_timer [17849 ms]
on_timer [18850 ms]
...

on_timer 忠实地每秒执行一次,直到随机出现的睡眠为止。在那个时间点,on_timer 不再被调用,直到睡眠时间结束;事实上,没有其它的回调 会在这个时间帧中被调用。这个睡眠调用阻塞了当前线程,它正是被调用的线程,并且也是事件循环使用的线程。当这个线程被阻塞后,事件循环也被阻塞。

这个示例演示了在事件驱动的调用中为什么回调不能被阻塞是多少的重要。并且,同样适用于 Node.js 服务器、客户端侧的 Javascript、大多数的 GUI 编程框架、以及许多其它的异步编程模型。

但是,有时候运行耗时的任务是不可避免的。并不是所有任务都有一个异步 API;例如,我们可能使用一些仅有同步 API 的库去处理,或者,正在执行一个可能的长周期计算。我们如何用事件驱动编程去结合这些代码?线程可以帮到你!

“转换” 阻塞调用为异步调用的线程

一个线程池可以用于转换阻塞调用为异步调用,通过与事件循环并行运行,并且当任务完成时去由它去公布事件。以阻塞函数 do_work() 为例,这里介绍了它是怎么运行的:

  1. 不在一个回调中直接调用 do_work() ,而是将它打包进一个 “任务”,让线程池去运行这个任务。当任务完成时,我们也为循环去调用它注册一个回调;我们称它为 on_work_done()
  2. 在这个时间点,我们的回调就可以返回了,而事件循环保持运行;在同一时间点,线程池中的有一个线程运行这个任务。
  3. 一旦任务运行完成,通知主线程(指正在运行事件循环的线程),并且事件循环调用 on_work_done()

让我们看一下,使用 libuv 的工作调度 API,是怎么去解决我们前面的计时器/睡眠示例中展示的问题的:

void on_after_work(uv_work_t* req, int status) {
  free(req);
}

void on_work(uv_work_t* req) {
  // "Work"
  if (random() % 5 == 0) {
    printf("Sleeping...\n");
    sleep(3);
  }
}

void on_timer(uv_timer_t* timer) {
  uint64_t timestamp = uv_hrtime();
  printf("on_timer [%" PRIu64 " ms]\n", (timestamp / 1000000) % 100000);

  uv_work_t* work_req = (uv_work_t*)malloc(sizeof(*work_req));
  uv_queue_work(uv_default_loop(), work_req, on_work, on_after_work);
}

int main(int argc, const char** argv) {
  uv_timer_t timer;
  uv_timer_init(uv_default_loop(), &timer);
  uv_timer_start(&timer, on_timer, 0, 1000);
  return uv_run(uv_default_loop(), UV_RUN_DEFAULT);
}

通过一个 work_req 注2 类型的句柄,我们进入一个任务队列,代替在 on_timer 上直接调用 sleep,这个函数在任务中(on_work)运行,并且,一旦任务完成(on_after_work),这个函数被调用一次。on_work 是指 “work”(阻塞中的/耗时的操作)进行的地方。注意在这两个回调传递到 uv_queue_work 时的一个关键区别:on_work 运行在线程池中,而 on_after_work 运行在事件循环中的主线程上 —— 就好像是其它的回调一样。

让我们看一下这种方式的运行:

$ ./uv-timer-work-demo
on_timer [89571 ms]
on_timer [90572 ms]
on_timer [91573 ms]
on_timer [92575 ms]
Sleeping...
on_timer [93576 ms]
on_timer [94577 ms]
Sleeping...
on_timer [95577 ms]
on_timer [96578 ms]
on_timer [97578 ms]
...

即便在 sleep 函数被调用时,定时器也每秒钟滴答一下,睡眠现在运行在一个单独的线程中,并且不会阻塞事件循环。

一个用于练习的素数测试服务器

因为通过睡眠去模拟工作并不是件让人兴奋的事,我有一个事先准备好的更综合的一个示例 —— 一个基于套接字接受来自客户端的数字的服务器,检查这个数字是否是素数,然后去返回一个 “prime" 或者 “composite”。完整的 服务器代码在这里 —— 我不在这里粘贴了,因为它太长了,更希望读者在一些自己的练习中去体会它。

这个服务器使用了一个原生的素数测试算法,因此,对于大的素数可能花很长时间才返回一个回答。在我的机器中,对于 2305843009213693951,它花了 ~5 秒钟去计算,但是,你的方法可能不同。

练习 1:服务器有一个设置(通过一个名为 MODE 的环境变量)要么在套接字回调(意味着在主线程上)中运行素数测试,要么在 libuv 工作队列中。当多个客户端同时连接时,使用这个设置来观察服务器的行为。当它计算一个大的任务时,在阻塞模式中,服务器将不回复其它客户端,而在非阻塞模式中,它会回复。

练习 2:libuv 有一个缺省大小的线程池,并且线程池的大小可以通过环境变量配置。你可以通过使用多个客户端去实验找出它的缺省值是多少?找到线程池缺省值后,使用不同的设置去看一下,在重负载下怎么去影响服务器的响应能力。

在非阻塞文件系统中使用工作队列

对于只是呆板的演示和 CPU 密集型的计算来说,将可能的阻塞操作委托给一个线程池并不是明智的;libuv 在它的文件系统 API 中本身就大量使用了这种能力。通过这种方式,libuv 使用一个异步 API,以一个轻便的方式显示出它强大的文件系统的处理能力。

让我们使用 uv_fs_read(),例如,这个函数从一个文件中(表示为一个 uv_fs_t 句柄)读取一个文件到一个缓冲中 注3,并且当读取完成后调用一个回调。换句话说, uv_fs_read() 总是立即返回,即使是文件在一个类似 NFS 的系统上,而数据到达缓冲区可能需要一些时间。换句话说,这个 API 与这种方式中其它的 libuv API 是异步的。这是怎么工作的呢?

在这一点上,我们看一下 libuv 的底层;内部实际上非常简单,并且它是一个很好的练习。作为一个可移植的库,libuv 对于 Windows 和 Unix 系统在它的许多函数上有不同的实现。我们去看一下在 libuv 源树中的 src/unix/fs.c

这是 uv_fs_read 的代码:

int uv_fs_read(uv_loop_t* loop, uv_fs_t* req,
               uv_file file,
               const uv_buf_t bufs[],
               unsigned int nbufs,
               int64_t off,
               uv_fs_cb cb) {
  if (bufs == NULL || nbufs == 0)
    return -EINVAL;

  INIT(READ);
  req->file = file;

  req->nbufs = nbufs;
  req->bufs = req->bufsml;
  if (nbufs > ARRAY_SIZE(req->bufsml))
    req->bufs = uv__malloc(nbufs * sizeof(*bufs));

  if (req->bufs == NULL) {
    if (cb != NULL)
      uv__req_unregister(loop, req);
    return -ENOMEM;
  }

  memcpy(req->bufs, bufs, nbufs * sizeof(*bufs));

  req->off = off;
  POST;
}

第一次看可能觉得很困难,因为它延缓真实的工作到 INITPOST 宏中,以及为 POST 设置了一些本地变量。这样做可以避免了文件中的许多重复代码。

这是 INIT 宏:

#define INIT(subtype)                                                         \
  do {                                                                        \
    req->type = UV_FS;                                                        \
    if (cb != NULL)                                                           \
      uv__req_init(loop, req, UV_FS);                                         \
    req->fs_type = UV_FS_ ## subtype;                                         \
    req->result = 0;                                                          \
    req->ptr = NULL;                                                          \
    req->loop = loop;                                                         \
    req->path = NULL;                                                         \
    req->new_path = NULL;                                                     \
    req->cb = cb;                                                             \
  }                                                                           \
  while (0)

它设置了请求,并且更重要的是,设置 req->fs_type 域为真实的 FS 请求类型。因为 uv_fs_read 调用 INIT(READ),它意味着 req->fs_type 被分配一个常数 UV_FS_READ

这是 POST 宏:

#define POST                                                                  \
  do {                                                                        \
    if (cb != NULL) {                                                         \
      uv__work_submit(loop, &req->work_req, uv__fs_work, uv__fs_done);        \
      return 0;                                                               \
    }                                                                         \
    else {                                                                    \
      uv__fs_work(&req->work_req);                                            \
      return req->result;                                                     \
    }                                                                         \
  }                                                                           \
  while (0)

它做什么取决于回调是否为 NULL。在 libuv 文件系统 API 中,一个 NULL 回调意味着我们真实地希望去执行一个 同步 操作。在这种情况下,POST 直接调用 uv__fs_work(我们需要了解一下这个函数的功能),而对于一个非 NULL 回调,它把 uv__fs_work 作为一个工作项提交到工作队列(指的是线程池),然后,注册 uv__fs_done 作为回调;该函数执行一些登记并调用用户提供的回调。

如果我们去看 uv__fs_work 的代码,我们将看到它使用很多宏按照需求将工作分发到实际的文件系统调用。在我们的案例中,对于 UV_FS_READ 这个调用将被 uv__fs_read 生成,它(最终)使用普通的 POSIX API 去读取。这个函数可以在一个 阻塞 方式中很安全地实现。因为,它通过异步 API 调用时被置于一个线程池中。

在 Node.js 中,fs.readFile 函数是映射到 uv_fs_read 上。因此,可以在一个非阻塞模式中读取文件,甚至是当底层文件系统 API 是阻塞方式时。


  • 注1: 为确保服务器不泄露内存,我在一个启用泄露检查的 Valgrind 中运行它。因为服务器经常是被设计为永久运行,这是一个挑战;为克服这个问题,我在服务器上添加了一个 “kill 开关” —— 一个从客户端接收的特定序列,以使它可以停止事件循环并退出。这个代码在 theon_wrote_buf 句柄中。
  • 注2: 在这里我们不过多地使用 work_req;讨论的素数测试服务器接下来将展示怎么被用于去传递上下文信息到回调中。
  • 注3: uv_fs_read() 提供了一个类似于 preadv Linux 系统调用的通用 API:它使用多缓冲区用于排序,并且支持一个到文件中的偏移。基于我们讨论的目的可以忽略这些特性。

via: https://eli.thegreenplace.net/2017/concurrent-servers-part-4-libuv/

作者:Eli Bendersky 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

假设现在的上下文(LCTT 译注:context,计算机术语,此处意为业务情景)是这样的:一个 zip 文件被上传到一个Web 服务中,然后 Python 需要解压这个 zip 文件然后分析和处理其中的每个文件。这个特殊的应用查看每个文件各自的名称和大小,并和已经上传到 AWS S3 上的文件进行比较,如果文件(和 AWS S3 上的相比)有所不同或者文件本身更新,那么就将它上传到 AWS S3。

Uploads today

挑战在于这些 zip 文件太大了。它们的平均大小是 560MB 但是其中一些大于 1GB。这些文件中大多数是文本文件,但是其中同样也有一些巨大的二进制文件。不同寻常的是,每个 zip 文件包含 100 个文件但是其中 1-3 个文件却占据了多达 95% 的 zip 文件大小。

最开始我尝试在内存中解压文件,并且每次只处理一个文件。在各种内存爆炸和 EC2 耗尽内存的情况下,这个方法壮烈失败了。我觉得这个原因是这样的。最开始你有 1GB 文件在内存中,然后你现在解压每个文件,在内存中大约就要占用 2-3GB。所以,在很多次测试之后,解决方案是将这些 zip 文件复制到磁盘上(在临时目录 /tmp 中),然后遍历这些文件。这次情况好多了但是我仍然注意到了整个解压过程花费了巨量的时间。是否可能有方法优化呢?

原始函数

首先是下面这些模拟对 zip 文件中文件实际操作的普通函数:

def _count_file(fn):
 with open(fn, 'rb') as f:
   return _count_file_object(f)

def _count_file_object(f):
 # Note that this iterates on 'f'.
 # You *could* do 'return len(f.read())'
 # which would be faster but potentially memory
 # inefficient and unrealistic in terms of this
 # benchmark experiment.
 total = 0
 for line in f:
   total += len(line)
 return total

这里是可能最简单的另一个函数:

def f1(fn, dest):
 with open(fn, 'rb') as f:
   zf = zipfile.ZipFile(f)
   zf.extractall(dest)

 total = 0
 for root, dirs, files in os.walk(dest):
   for file_ in files:
     fn = os.path.join(root, file_)
     total += _count_file(fn)
 return total

如果我更仔细地分析一下,我将会发现这个函数花费时间 40% 运行 extractall,60% 的时间在遍历各个文件并读取其长度。

第一步尝试

我的第一步尝试是使用线程。先创建一个 zipfile.ZipFile 的实例,展开其中的每个文件名,然后为每一个文件开始一个线程。每个线程都给它一个函数来做“实质工作”(在这个基准测试中,就是遍历每个文件然后获取它的名称)。实际业务中的函数进行的工作是复杂的 S3、Redis 和 PostgreSQL 操作,但是在我的基准测试中我只需要制作一个可以找出文件长度的函数就好了。线程池函数:

def f2(fn, dest):

    def unzip_member(zf, member, dest):
        zf.extract(member, dest)
        fn = os.path.join(dest, member.filename)
        return _count_file(fn)

    with open(fn, 'rb') as f:
        zf = zipfile.ZipFile(f)
        futures = []
        with concurrent.futures.ThreadPoolExecutor() as executor:
            for member in zf.infolist():
                futures.append(
                    executor.submit(
                        unzip_member,
                        zf,
                        member,
                        dest,
                    )
                )
            total = 0
            for future in concurrent.futures.as_completed(futures):
                total += future.result()
    return total

结果:加速 ~10%

第二步尝试

所以可能是 GIL(LCTT 译注:Global Interpreter Lock,一种全局锁,CPython 中的一个概念)阻碍了我。最自然的想法是尝试使用多线程在多个 CPU 上分配工作。但是这样做有缺点,那就是你不能传递一个非可 pickle 序列化的对象(LCTT 译注:意为只有可 pickle 序列化的对象可以被传递),所以你只能发送文件名到之后的函数中:

def unzip_member_f3(zip_filepath, filename, dest):
    with open(zip_filepath, 'rb') as f:
        zf = zipfile.ZipFile(f)
        zf.extract(filename, dest)
    fn = os.path.join(dest, filename)
    return _count_file(fn)



def f3(fn, dest):
    with open(fn, 'rb') as f:
        zf = zipfile.ZipFile(f)
        futures = []
        with concurrent.futures.ProcessPoolExecutor() as executor:
            for member in zf.infolist():
                futures.append(
                    executor.submit(
                        unzip_member_f3,
                        fn,
                        member.filename,
                        dest,
                    )
                )
            total = 0
            for future in concurrent.futures.as_completed(futures):
                total += future.result()
    return total

结果: 加速 ~300%

这是作弊

使用处理器池的问题是这样需要存储在磁盘上的原始 .zip 文件。所以为了在我的 web 服务器上使用这个解决方案,我首先得要将内存中的 zip 文件保存到磁盘,然后调用这个函数。这样做的代价我不是很清楚但是应该不低。

好吧,再翻翻看又没有损失。可能,解压过程加速到足以弥补这样做的损失了吧。

但是一定记住!这个优化取决于使用所有可用的 CPU。如果一些其它的 CPU 需要执行在 gunicorn 中的其它事务呢?这时,这些其它进程必须等待,直到有 CPU 可用。由于在这个服务器上有其他的事务正在进行,我不是很确定我想要在进程中接管所有其他 CPU。

结论

一步一步地做这个任务的这个过程感觉挺好的。你被限制在一个 CPU 上但是表现仍然特别好。同样地,一定要看看在f1f2 两段代码之间的不同之处!利用 concurrent.futures 池类你可以获取到允许使用的 CPU 的个数,但是这样做同样给人感觉不是很好。如果你在虚拟环境中获取的个数是错的呢?或者可用的个数太低以致无法从负载分配获取好处并且现在你仅仅是为了移动负载而支付营运开支呢?

我将会继续使用 zipfile.ZipFile(file_buffer).extractall(temp_dir)。这个工作这样做已经足够好了。

想试试手吗?

我使用一个 c5.4xlarge EC2 服务器来进行我的基准测试。文件可以从此处下载:

wget https://www.peterbe.com/unzip-in-parallel/hack.unzip-in-parallel.py
wget https://www.peterbe.com/unzip-in-parallel/symbols-2017-11-27T14_15_30.zip

这里的 .zip 文件有 34MB。和在服务器上的相比已经小了很多。

hack.unzip-in-parallel.py 文件里是一团糟。它包含了大量可怕的修正和丑陋的代码,但是这只是一个开始。


via: https://www.peterbe.com/plog/fastest-way-to-unzip-a-zip-file-in-python

作者:Peterbe 译者:Leemeans 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

在你的日常工作中,不可能每天都从头开始去开发一个新的应用程序。而真实的情况是,在日常工作中,我们大多数时候所面对的都是遗留下来的一个代码库,去修改一些特性的内容或者现存的一些代码行,这是我们在日常工作中很重要的一部分。而这也就是分布式版本控制系统 git 的价值所在。现在,我们来深入了解怎么去使用 git 的历史以及如何很轻松地去浏览它的历史。

Git 历史

首先和最重要的事是,什么是 git 历史?正如其名字一样,它是一个 git 仓库的提交历史。它包含一堆提交信息,其中有它们的作者的名字、该提交的哈希值以及提交日期。查看一个 git 仓库历史的方法很简单,就是一个 git log 命令。

旁注:为便于本文的演示,我们使用 Ruby on Rails 的仓库的 master 分支。之所以选择它的理由是因为,Rails 有良好的 git 历史,漂亮的提交信息、引用以及对每个变更的解释。如果考虑到代码库的大小、维护者的年龄和数量,Rails 肯定是我见过的最好的仓库。当然了,我并不是说其它的 git 仓库做的不好,它只是我见过的比较好的一个仓库。

那么,回到 Rails 仓库。如果你在 Ralis 仓库上运行 git log。你将看到如下所示的输出:

commit 66ebbc4952f6cfb37d719f63036441ef98149418
Author: Arthur Neves <[email protected]>
Date:   Fri Jun 3 17:17:38 2016 -0400

    Dont re-define class SQLite3Adapter on test

    We were declaring  in a few tests, which depending of    the order load will cause an error, as the super class could change.

    see https://github.com/rails/rails/commit/ac1c4e141b20c1067af2c2703db6e1b463b985da#commitcomment-17731383

commit 755f6bf3d3d568bc0af2c636be2f6df16c651eb1
Merge: 4e85538 f7b850e
Author: Eileen M. Uchitelle <[email protected]>
Date:   Fri Jun 3 10:21:49 2016 -0400

    Merge pull request #25263 from abhishekjain16/doc_accessor_thread

    [skip ci] Fix grammar

commit f7b850ec9f6036802339e965c8ce74494f731b4a
Author: Abhishek Jain <[email protected]>
Date:   Fri Jun 3 16:49:21 2016 +0530

    [skip ci] Fix grammar

commit 4e85538dddf47877cacc65cea6c050e349af0405
Merge: 082a515 cf2158c
Author: Vijay Dev <[email protected]>
Date:   Fri Jun 3 14:00:47 2016 +0000

    Merge branch 'master' of github.com:rails/docrails

    Conflicts:
        guides/source/action_cable_overview.md

commit 082a5158251c6578714132e5c4f71bd39f462d71
Merge: 4bd11d4 3bd30d9
Author: Yves Senn <[email protected]>
Date:   Fri Jun 3 11:30:19 2016 +0200

    Merge pull request #25243 from sukesan1984/add_i18n_validation_test

    Add i18n_validation_test

commit 4bd11d46de892676830bca51d3040f29200abbfa
Merge: 99d8d45 e98caf8
Author: Arthur Nogueira Neves <[email protected]>
Date:   Thu Jun 2 22:55:52 2016 -0400

    Merge pull request #25258 from alexcameron89/master

    [skip ci] Make header bullets consistent in engines.md

commit e98caf81fef54746126d31076c6d346c48ae8e1b
Author: Alex Kitchens <[email protected]>
Date:   Thu Jun 2 21:26:53 2016 -0500

    [skip ci] Make header bullets consistent in engines.md

正如你所见,git log 展示了提交的哈希、作者及其 email 以及该提交创建的日期。当然,git 输出的可定制性很强大,它允许你去定制 git log 命令的输出格式。比如说,我们只想看提交信息的第一行,我们可以运行 git log --oneline,它将输出一个更紧凑的日志:

66ebbc4 Dont re-define class SQLite3Adapter on test
755f6bf Merge pull request #25263 from abhishekjain16/doc_accessor_thread
f7b850e [skip ci] Fix grammar4e85538 Merge branch 'master' of github.com:rails/docrails
082a515 Merge pull request #25243 from sukesan1984/add_i18n_validation_test
4bd11d4 Merge pull request #25258 from alexcameron89/master
e98caf8 [skip ci] Make header bullets consistent in engines.md
99d8d45 Merge pull request #25254 from kamipo/fix_debug_helper_test
818397c Merge pull request #25240 from matthewd/reloadable-channels
2c5a8ba Don't blank pad day of the month when formatting dates
14ff8e7 Fix debug helper test

如果你想看 git log 的全部选项,我建议你去查阅 git log 的 man 页面,你可以在一个终端中输入 man git-log 或者 git help log 来获得。

小提示:如果你觉得 git log 看起来太恐怖或者过于复杂,或者你觉得看它太无聊了,我建议你去寻找一些 git 的 GUI 或命令行工具。在之前,我使用过 GitX ,我觉得它很不错,但是,由于我看命令行更“亲切”一些,在我尝试了 tig 之后,就再也没有去用过它。

寻找尼莫

现在,我们已经知道了关于 git log 命令的一些很基础的知识之后,我们来看一下,在我们的日常工作中如何使用它更加高效地浏览历史。

假如,我们怀疑在 String#classify 方法中有一个预期之外的行为,我们希望能够找出原因,并且定位出实现它的代码行。

为达到上述目的,你可以使用的第一个命令是 git grep,通过它可以找到这个方法定义在什么地方。简单来说,这个命令输出了匹配特定模式的那些行。现在,我们来找出定义它的方法,它非常简单 —— 我们对 def classify 运行 grep,然后看到的输出如下:

➜  git grep 'def classify'

activesupport/lib/active_support/core_ext/string/inflections.rb:    def classifyactivesupport/lib/active_support/inflector/methods.rb:    def classify(table_name)tools/profile:    def classify

现在,虽然我们已经看到这个方法是在哪里创建的,但是,并不能够确定它是哪一行。如果,我们在 git grep 命令上增加 -n 标志,git 将提供匹配的行号:

➜  git grep -n 'def classify'

activesupport/lib/active_support/core_ext/string/inflections.rb:205:  def classifyactivesupport/lib/active_support/inflector/methods.rb:186:    def classify(table_name)tools/profile:112:    def classify

更好看了,是吧?考虑到上下文,我们可以很轻松地找到,这个方法在 activesupport/lib/active_support/core_ext/string/inflections.rb 的第 205 行的 classify 方法,它看起来像这样,是不是很容易?

# Creates a class name from a plural table name like Rails does for table names to models.
# Note that this returns a string and not a class. (To convert to an actual class
# follow +classify+ with +constantize+.)
#
#   'ham_and_eggs'.classify # => "HamAndEgg"
#   'posts'.classify        # => "Post"
    def classify
        ActiveSupport::Inflector.classify(self)
    end

尽管我们找到的这个方法是在 String 上的一个常见的调用,它调用了 ActiveSupport::Inflector 上的另一个同名的方法。根据之前的 git grep 的结果,我们可以很轻松地发现结果的第二行, activesupport/lib/active_support/inflector/methods.rb 在 186 行上。我们正在寻找的方法是这样的:

# Creates a class name from a plural table name like Rails does for table
# names to models. Note that this returns a string and not a Class (To
# convert to an actual class follow +classify+ with constantize).
#
#   classify('ham_and_eggs') # => "HamAndEgg"
#   classify('posts')        # => "Post"
#
# Singular names are not handled correctly:
#
#   classify('calculus')     # => "Calculus"
def classify(table_name)
    # strip out any leading schema name
    camelize(singularize(table_name.to_s.sub(/.*\./, ''.freeze)))
end

酷!考虑到 Rails 仓库的大小,我们借助 git grep 找到它,用时都没有超越 30 秒。

那么,最后的变更是什么?

现在,我们已经找到了所要找的方法,现在,我们需要搞清楚这个文件所经历的变更。由于我们已经知道了正确的文件名和行数,我们可以使用 git blame。这个命令展示了一个文件中每一行的最后修订者和修订的内容。我们来看一下这个文件最后的修订都做了什么:

git blame activesupport/lib/active_support/inflector/methods.rb

虽然我们得到了这个文件每一行的最后的变更,但是,我们更感兴趣的是对特定方法(176 到 189 行)的最后变更。让我们在 git blame 命令上增加一个选项,让它只显示那些行的变化。此外,我们将在命令上增加一个 -s (忽略)选项,去跳过那一行变更时的作者名字和修订(提交)的时间戳:

git blame -L 176,189 -s activesupport/lib/active_support/inflector/methods.rb

9fe8e19a 176)   #Creates a class name from a plural table name like Rails does for table
5ea3f284 177)   # names to models. Note that this returns a string and not a Class (To
9fe8e19a 178)   # convert to an actual class follow +classify+ with #constantize).
51cd6bb8 179)   #
6d077205 180)   #   classify('ham_and_eggs') # => "HamAndEgg"
9fe8e19a 181)   #   classify('posts')        # => "Post"
51cd6bb8 182)   #
51cd6bb8 183)   # Singular names are not handled correctly:
5ea3f284 184)   #
66d6e7be 185)   #   classify('calculus')     # => "Calculus"
51cd6bb8 186)   def classify(table_name)
51cd6bb8 187)     # strip out any leading schema name
5bb1d4d2 188)     camelize(singularize(table_name.to_s.sub(/.*\./, ''.freeze)))
51cd6bb8 189)     end

现在,git blame 命令的输出展示了指定行的全部内容以及它们各自的修订。让我们来看一下指定的修订,换句话说就是,每个变更都修订了什么,我们可以使用 git show 命令。当指定一个修订哈希(像 66d6e7be)作为一个参数时,它将展示这个修订的全部内容。包括作者名字、时间戳以及完整的修订内容。我们来看一下 188 行最后的修订都做了什么?

git show 5bb1d4d2

你亲自做实验了吗?如果没有做,我直接告诉你结果,这个令人惊叹的 提交 是由 Schneems 完成的,他通过使用 frozen 字符串做了一个非常有趣的性能优化,这在我们当前的场景中是非常有意义的。但是,由于我们在这个假设的调试会话中,这样做并不能告诉我们当前问题所在。因此,我们怎么样才能够通过研究来发现,我们选定的方法经过了哪些变更?

搜索日志

现在,我们回到 git 日志,现在的问题是,怎么能够看到 classify 方法经历了哪些修订?

git log 命令非常强大,因此它提供了非常多的列表选项。我们尝试使用 -p 选项去看一下保存了这个文件的 git 日志内容,这个选项的意思是在 git 日志中显示这个文件的完整补丁:

git log -p activesupport/lib/active_support/inflector/methods.rb

这将给我们展示一个很长的修订列表,显示了对这个文件的每个修订。但是,正如下面所显示的,我们感兴趣的是对指定行的修订。对命令做一个小的修改,只显示我们希望的内容:

git log -L 176,189:activesupport/lib/active_support/inflector/methods.rb

git log 命令接受 -L 选项,它用一个行的范围和文件名做为参数。它的格式可能有点奇怪,格式解释如下:

git log -L <start-line>,<end-line>:<path-to-file>

当我们运行这个命令之后,我们可以看到对这些行的一个修订列表,它将带我们找到创建这个方法的第一个修订:

commit 51xd6bb829c418c5fbf75de1dfbb177233b1b154
Author: Foo Bar <[email protected]>
Date:   Tue Jun 7 19:05:09 2011 -0700

    Refactor

diff--git a/activesupport/lib/active_support/inflector/methods.rb b/activesupport/lib/active_support/inflector/methods.rb
--- a/activesupport/lib/active_support/inflector/methods.rb
+++ b/activesupport/lib/active_support/inflector/methods.rb
@@ -58,0 +135,14 @@
+    # Create a class name from a plural table name like Rails does for table names to models.
+    # Note that this returns a string and not a Class. (To convert to an actual class
+    # follow +classify+ with +constantize+.)
+    #
+    # Examples:
+    #   "egg_and_hams".classify # => "EggAndHam"
+    #   "posts".classify        # => "Post"
+    #
+    # Singular names are not handled correctly:
+    #   "business".classify     # => "Busines"
+    def classify(table_name)
+      # strip out any leading schema name
+      camelize(singularize(table_name.to_s.sub(/.*\./, '')))
+    end

现在,我们再来看一下 —— 它是在 2011 年提交的。git 可以让我们重回到这个时间。这是一个很好的例子,它充分说明了足够的提交信息对于重新了解当时的上下文环境是多么的重要,因为从这个提交信息中,我们并不能获得足够的信息来重新理解当时的创建这个方法的上下文环境,但是,话说回来,你不应该对此感到恼怒,因为,你看到的这些项目,它们的作者都是无偿提供他们的工作时间和精力来做开源工作的。(向开源项目贡献者致敬!)

回到我们的正题,我们并不能确认 classify 方法最初实现是怎么回事,考虑到这个第一次的提交只是一个重构。现在,如果你认为,“或许、有可能、这个方法不在 176 行到 189 行的范围之内,那么就你应该在这个文件中扩大搜索范围”,这样想是对的。我们看到在它的修订提交的信息中提到了“重构”这个词,它意味着这个方法可能在那个文件中是真实存在的,而且是在重构之后它才存在于那个行的范围内。

但是,我们如何去确认这一点呢?不管你信不信,git 可以再次帮助你。git log 命令有一个 -S 选项,它可以传递一个特定的字符串作为参数,然后去查找代码变更(添加或者删除)。也就是说,如果我们执行 git log -S classify 这样的命令,我们可以看到所有包含 classify 字符串的变更行的提交。

如果你在 Ralis 仓库上运行上述命令,首先你会发现这个命令运行有点慢。但是,你应该会发现 git 实际上解析了在那个仓库中的所有修订来匹配这个字符串,其实它的运行速度是非常快的。在你的指尖下 git 再次展示了它的强大之处。因此,如果去找关于 classify 方法的第一个修订,我们可以运行如下的命令:

git log -S 'def classify'

它将返回所有这个方法的引用和修改的地方。如果你一直往下看,你将看到日志中它的最后的提交:

commit db045dbbf60b53dbe013ef25554fd013baf88134
Author: David Heinemeier Hansson <[email protected]>
Date:   Wed Nov 24 01:04:44 2004 +0000
    Initial
    git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@4 5ecf4fe2-1ee6-0310-87b1-e25e094e27de

很酷!是吧?它初次被提交到 Rails,是由 DHH 在一个 svn 仓库上做的!这意味着 classify 大概在一开始就被提交到了 Rails 仓库。现在,我们去看一下这个提交的所有变更信息,我们运行如下的命令:

git show db045dbbf60b53dbe013ef25554fd013baf88134

非常好!我们终于找到它的根源了。现在,我们使用 git log -S 'def classify' 的输出,结合 git log -L 命令来跟踪这个方法都发生了哪些变更。

下次见

当然,我们并没有真的去修改任何 bug,因为我们只是去尝试使用一些 git 命令,来演示如何查看 classify 方法的演变历史。但是不管怎样,git 是一个非常强大的工具,我们必须学好它、用好它。我希望这篇文章可以帮助你掌握更多的关于如何使用 git 的知识。

你喜欢这些内容吗?


作者简介:

后端工程师,对 Ruby、Go、微服务、构建弹性架构来解决大规模部署带来的挑战很感兴趣。我在阿姆斯特丹的 Rails Girls 担任顾问,维护着一个小而精的列表,并且经常为开源做贡献。

那个列表是我写的关于软件开发、编程语言以及任何我感兴趣的东西。


via: https://ieftimov.com/learn-your-tools-navigating-git-history

作者:Ilija Eftimov 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出