Let’s talk a little about professional skills over here. In the past,I focused most of my efforts on coding skill, rather than engineeringskills, or let’s say, the business skills. This is because I love thehacker spirit, and am fascinated by intricate program structures andalgorithms. But your boss just need one who solve problems, them don’tcare about computer science. To solve a problem, you have to considermany things other than the computer - namely, the people around you. Andthat’s exactly what software engineering does - to use some engineeringmethods to prevent or eliminate mistakes made by humans. So when I focuson hacking, I must keep an open mind on other skills, especially thosemethodologies for problem-solving.
I finished learning Structure and Interpretation of ComputerPrograms (SICP), an amazing book. Its content comprised offunctional programming, layering of program and data structure, OOP,infinite streams, the metacircular evaluator, lazy evaluation,compilation principle, etc. I have to say, SICP opened the gateof computer science for me. Before then, I don’t really comprehend theessential of computer science.
I kept on doing LeetCode like the past few years. The grid looks notbad.
Now I’ve solved 1182 problems. Last year it’s 912, so I solved 270problems in 2023.
I kept learning English in 2023, as I did in the past few years.After continuously learning vocabulary on the APP baicizhan for2024 days, I found it might not be a very effective way to memorize newwords for me at the moment. Therefore, In November, I started learningMerriam-Webster’s Vocabulary Builder. This book organizes wordsby their roots, besides telling you how to use a word, its history, andrelated knowledge.
I also read another amazing vocabulary builder, Word Power MadeEasy. To me, this book like an introduction to the etymology and atthe same time teaches you how to memorize over 3,000 words and continuebuilding your vocabulary.
In addition, I kept leaning Japanese on Duolingo as I did in2022.
I only wrote 6 posts on the blog.
I created a repo
Hiking and seeing the skyline of the city at the summit is quitehappy, especially with the one you love.
At the end of a year, I always feel time flies by quickly. But afterI wrote the annual review, I found that the time of a year is quitelong, because you can literally do many things in a year and grow quitea bit in certain aspects (say, I found my English writing skill is muchbetter than the last year). So in the new year, keep learning andgrowing, and I believe we’ll get a better result. Happy New Year toeverybody.
]]>call/cc
call/cc
call/cc
我最近已经在读最后一章了,待读完本书后再看情况更新一些内容。这些内容的基础是Scheme 语言,我们从介绍 Scheme 语言开始。本文介绍的 Scheme语言主要目的是让不了解 Scheme的同学看完之后能看得懂后面几篇文章,因此不会涉及到一些很细节的内容。(特别细节的内容我也不懂,SICP也没有很深入介绍)如果要深入了解,可以阅读相关的文档。
Scheme 是一种 Lisp 的方言。而 Lisp是世界上第二古老的语言(第一古老的是Fortran),有着众多的方言。这些方言有着一个共同的特性——基于 S表达式 (S-expressions)。
S 表达式可以是原子表达式 (atom)或者列表。原子表达式可以是数字,如 1
,42
, 3.14
;可以是字符串,如"hello"
;可以是布尔值,如 #t
,#f
;也可以直接是符号,如 a
, if
,add
。而列表则是将若干个 S表达式放在一对括号里,用空格隔开:
1 |
|
下面给出了一些 S 表达式的例子:
1 |
|
前三个 S 表达式都是原子表达式。(add 1 2)
是一个长度为 3的列表,3 个元素分别是符号 add
、数字 1 和数字2。(display "Hello world")
是一个长度为 2的列表,第一个元素是符号 display
,第二个元素是字符串"Hello world"
。(list (list 1 2) (list "a" "b"))
是一个长度为 3 的列表,三个元素分别是符号 list
、列表(list 1 2)
、列表 (list "a" "b")
。
Scheme 全部是由 S 表达式组成的。在 Scheme中,复合表达式的第一个元素作为表达式的类型,剩余的元素则作为表达式的参数。
表达式类型决定这个表达式的语义和参数的含义。例如 if
表达式规定有三个参数,第一个参数为条件,第二个参数为条件为真时执行的表达式,第三个参数为条件为假时执行的表达式。由于S 表达式可以任意嵌套,因此利用它就可以构造出任意复杂的代码。下面就是一段Scheme 代码的例子:
1 |
|
可以看到 S表达式层层嵌套,形成了一个树状结构,这其实就是语法树。也就是说这个语言实际是把语法树明确的写出来。后面我们能看到这种做法的好处:代码可以直接表示为数据结构,代码极其容易解析、编译。
Scheme 作为 Lisp 的一种方言,它本身又有很多方言,例如 Chez Scheme,MIT Scheme, Racket 等。我们使用的环境是Racket,它功能强大,易于使用。我们可以到它的
打开 DrRacket,就可以开始 Scheme编程了。程序的第一行需要声明所使用的语言#lang racket
。编辑好了后点击 “Run” 便可执行代码。
有些同学可能不习惯这种全是括号的语言,阅读代码需要数括号,十分麻烦。但如果代码做好缩进与对齐,之间的嵌套关系是一目了然的。我们可以让参数另起一行,相对类型缩进两个空格:
1 |
|
或者第一个参数与类型同行,后续参数与第一个参数对齐:
1 |
|
如果第一个参数比较特殊,也可以让第一个参数与类型同行,剩余的参数另起一行,并缩进两个空格
1 |
|
基本上就这三种缩进风格。使用 DrRacket可以自动缩进;阅读代码时一般不需要关心括号,直接看代码缩进即可,就像Python 一样。
一个高级语言一定具备这三个要素:
我们说汇编语言不是高级语言,因为它有非常弱的组合能力和抽象能力。例如add $42 %eax
可以表示 eax + 42
,但是要想表示(eax + 42) * 3
就得写两条指令了,因为这个语言根本没有嵌套组合的能力。至于抽象能力,汇编中的函数(准确来说应该是subroutine)更像是个 goto。而 Scheme是非常高级的语言,因为它有非常强的组合能力和抽象能力,稍后我们可以看到。
原子表达式有这么几种:
10
、-12
;浮点数3.14
;有理数1/2
、-3/5
,形式是两个由 /
分隔的整数,注意中间不能有空格,因为这是一个原子。"Hello world"
。#t
和 #f
。pi
,值为3.141592653589793
;sqrt
,为一个内建函数。不同于很多语言,Scheme的符号不局限于字母、数字和下划线,例如reset!
、*important*
、+
、1st-item
都是有效的符号。Scheme 中的复合表达式有两种,特殊形 (special form) 和函数调用。Scheme函数调用的语法是 (function arg1 arg2 ...)
,让 S表达式的第一个元素为函数,剩余元素为函数参数。例如下面的几个表达式都是函数调用:
1 |
|
这里的 sqrt
,+
,*
都是函数名,分别执行平方根、加法和乘法操作。与其他大部分语言不同,Scheme没有运算符,加减乘除运算、比较运算等都是函数。
对于初学者来说可能有些奇怪,但这种语法有很大的好处。首先表达式关系明确无歧义,程序员不需要记忆运算符优先级、是左结合还是右结合,且程序容易解析编译。使用方式统一,不会像C 语言一样,乘法运算是 a * b
,指数运算却是pow(a, b)
。不需要 C++那样复杂的运算符重载规则,直接定义一个名为+
、*
的函数即可。
下面给出了一些常用函数和调用方式:
1 |
|
分号 ;
在 Scheme 中用作单行注释。
看到这里,你可能会以为表达式 (if (> a b) a b)
也是调用了一个 if
函数。但,实际上不是。对函数求值时,会先依次对各个参数求值,然后再调用函数。而对于if
来说,当 (> a b)
为真时,只应该对a
求值,不应该对 b
求值。反之,只应该对b
求值。因此 if
不能是函数,应该是一个特殊形。
S表达式就像是语法树的表示,而特殊形就是一种特定的语法,它定义这个语法有哪些子节点,含义分别是什么。下面给出了一些常用的特殊形和使用方式。
1 |
|
lambda
特殊形创建一个函数,形式为(lambda (arg1 arg2 ...) body ...)
。其中(arg1 arg2 ...)
为参数列表,剩下的 body ...
为函数体,可由多个表达式组成,函数的返回值为最后一个表达式的值。我们通常结合define
定义函数,下面给出了一个例子
1 |
|
这个函数实现欧几里得算法,求两个整数 a
和 b
的最大公约数。函数参数列表是 (a b)
,函数体只有一个if
表达式。if
表达式检查 b
是否为0,如果 b
为 0 则返回 a
,否则递归调用自身(gcd b (remainder a b))
。现在我们就可以调用gcd
了:
1 |
|
由于我们经常使用 define
和 lambda
定义函数,我们有一种简便的写法(define (fname args ...) body ...)
等价于(define fname (lambda (args ...) body ...))
。因此gcd
还可写成这样
1 |
|
函数可以嵌套定义。例如定义函数 prime?
判断一个数是否是质数,我们寻找能整除它的大于 1的整数。如果找不到能整除它的整数,则它是一个质数
1 |
|
prime?
所在的环境称为全局环境,iter
所在的环境为 prime?
的内部环境。define
执行的时候,会在它所处的环境中增加一个变量。当函数被调用时,会创建一个新环境,这个新环境继承函数定义时所在的环境;而函数的参数就在新环境中实例化。对表达式求值,会先在当前环境寻找变量的值,如果找不到则在上层环境寻找,依次类推。因此要考察一个函数的行为,必须考虑两个要素:这个函数的代码,和这个函数所在的环境。这两要素有时合在一起称为“闭包”。
当我们在全局环境中执行 (prime? 11)
时,会有这么几步:
prime?
变量,发现它是一个函数,调用它。n: 11
。prime?
的代码。(define (iter i) ...)
,在 E1 中添加变量iter
。iter
是一个函数,所在的环境指向E1。(iter 2)
,在 E1 中找到iter
,发现它是一个函数,调用它。i: 2
。iter
的代码。(> (* i i) n)
:*
,找不到;然后再 E1中找,还是找不到;最后在 G 中找到 *
是个内建函数。i
,找到 i: 2
。n
,找不到;然后在 E1 中找,找到n: 11
(iter (+ i 1))
,可在 E2 中找到i: 2
,在 E1 中找到 iter
。调用iter
。iter
所在的环境是 E1,因此创建一个继承 E1 的新环境E3。i: 3
。iter
的代码,以此类推。这便是 Scheme环境的运作机制。下一篇文章我们会实现这个机制,从而实现一个 Scheme解释器。
Scheme的函数是一等公民,我们可以将函数当作参数传递,也可以当成返回值返回。当函数被传递时,它所在的环境也将被传递。例如
1 |
|
函数 f
返回一个函数,这个函数便保存了调用 f
时创建的环境。因此我们可以通过这个函数获取到调用 f
时传的值。后面我们可以看到这个机制有趣的应用。
let
与 let*
当我们需要中间变量时,例如计算 let
特殊形。
1 |
|
let
的语法格式如下:
1 |
|
它其实是个语法糖,等价于使用 lambda
创建一个函数,然后立刻调用它:
1 |
|
let
有一个缺陷,就是定义后面的变量的值时不能引用前面的变量,也就是说(let ([a 1] [b (+ a 1)]) b)
是非法的。于是我们有let*
:
1 |
|
它也是一个语法糖,等价于
1 |
|
let*
通过嵌套 let
实现,因此允许引用前面的变量。
前面介绍了代码的组合和抽象,这一节介绍数据结构。这一系列文章只会用到非常简单的数据结构。
为了构造复合结构,我们用 cons
构造有序对(pair)。car
获取有序对的第一个元素,cdr
获取有序对的第二个元素。
1 |
|
有序对可以任意嵌套,如(cons (cons 1 2) (cons 3 4))
。因为可以任意嵌套,所以理论上仅靠有序对就可以构造出任意复杂的数据结构。如果将有序对依次连接,就得到了一个链式列表:
1 |
|
每个有序对的第一个元素 (car) 存储当前节点的值,第二个元素 (cdr)指向下一个节点。最后一个元素的 cdr 为 '()
,表示NIL,链表的结尾。使用 list
函数可以快速创建一个列表:
1 |
|
这样对于列表来说,car
用于获取列表的第一个元素,cdr
用于获取列表剩余的元素,而cons
在列表头部插入一个元素。
1 |
|
你可能会好奇 '()
和 '(2 3 4)
中的单引号'
是什么意思。回想一下第 1 节,S表达式可以是原子表达式或列表。是的,这里的说的列表与list
函数创建的列表是一个东西。也就是说,S 表达式
1 |
|
本身就是一个列表。但是这个表达式会被 Scheme 解释成调用函数1
,传入参数 2, 3, 4。为了表示列表本身,我们用quote
特殊形。quote
接受一个 S表达式作为参数,不对这个表达式求值,而是直接返回它。下面是一些使用例子。
1 |
|
由于 quote 十分常用,因此我们有一种简化形式。在任意 S表达式前加上单引号 '
表示对这个 S 表达式 quote。
1 |
|
这有这非常重要的意义——意味着代码可以当作数据解析。这是其它非 Lisp系语言不具备的能力。我们会在下一篇文章中大量使用它,这里我们先看一些简单的例子:
1 |
|
这里的 cadr
和 caddr
是快捷函数。(cadr x)
等价于(car (cdr x))
,(caddr x)
等价于(car (cdr (cdr x)))
。这种命名也很容易记忆:中间的a
和 d
分布表示依次调用 car
和cdr
。
我们知道列表由有序对构成。S表达式使用括号表示列表,那么对于有序对这种更基础的元素,它如何表示呢?我们可以试验下:
1 |
|
如果括号里的两个元素用 .
隔开,则表示这是一个有序对。但如果有序对的第二个元素被括号包裹,则会省略掉.
和第二个元素的括号:
1 |
|
因此 (cons 1 (cons 2 (cons 3 (cons 4 '()))))
的结果是'(1 2 3 4)
,看上去像是个列表了。这种语法的好处是,既能体现列表是由有序对构成的(可以显式写成(+ . (2 . (3 . ())))
),又能让列表看上去很舒服(一般写作(+ 2 3)
)。
Scheme还提供了一对方便我们构造特定列表的特殊形:quasiquote
与unquote
。它们同样接受一个 S表达式作为参数。(quasiquote exp)
可简写为`exp
,(unquote exp)
可简写为,exp
。与 quote
类似,quasiquote
也原样返回 S 表达式,但会对其中 unquote
的部分求值。
1 |
|
还有一个类似的语法是unquote-splicing
,接受一个列表作为参数,(unquote-splicing list)
简写为 ,@list
。它会对列表求值并展开:
1 |
|
这里介绍一些操作列表的常用函数。
pair?
判断是否是有序对
1 |
|
list?
判断是否是列表
1 |
|
symbol?
判断是否是符号
1 |
|
null?
判断列表是否为空。
1 |
|
memq
在列表中找到 car 等于给定值的有序对
1 |
|
assoc
假设列表的元素都是有序对,找到有序对的 car等于给定值的元素
1 |
|
append
连接两个列表
1 |
|
Scheme倡导函数式编程,除了函数是一等公民外,还有一点就是“非必要不赋值”。到现在为止,我们还没有介绍赋值语句。对于命令式编程来说,不使用赋值语句连个有限while 循环都写不出来。但是在函数式编程中,我们会熟练使用各种递归。
1 |
|
虽然无法通过赋值改变变量,但是我们在可以调用函数时改变参数的值。有人可能会说递归性能差,因为需要消耗栈空间。确实,上面的代码在调用(sum (cdr items))
之前需要将 (car items)
的值压栈,以便 sum
返回后计算两者之和。但是我们只需要稍微修改一下写法:
1 |
|
我们发现递归调用 (iter (cdr i) (+ s (car i)))
的返回值直接作为原函数 (iter i s)
的返回值,因此调用之前不需要压栈。这被称为尾递归。尾递归本质就是迭代,因为递归调用iter
的过程就是不断迭代更新变量 i
和s
的过程。
刚才我们定义了一个函数求所有元素之和。那么如果要求所有元素之积呢?我们可以定义一个product
函数
1 |
|
我们发现这个函数跟 sum
几乎一样。这两个函数都是给定一个初始值,依次与列表中的元素执行某个操作,然后依次迭代;只是初始值(一个是0 另一个是 1)和操作(一个是 +
另一个是*
)不同。在 Scheme 中,函数可以当作值传递,而+
和 *
都是函数。因此我们可以定义一个通用的函数,将初始值和操作作为参数传递进去:
1 |
|
与之类似的函数是 map
。map
将列表中的每个元素通过一个给定的函数映射成新值
1 |
|
map
还支持传多个列表,如(map proc list1 list2 ...)
。这些列表的长度要相等,并且列表的数量等于传入函数的参数数量。list1
的元素作为第一个参数传给 proc
,list2
的元素作为第二个元素传给 proc
,以此类推。
1 |
|
如何实现 map
呢?Scheme支持定义可变参数的函数。我们可以定义(define (map proc . lists))
,这种情况下 lists
便是一个包含剩余参数的列表。因为 (map proc list1 list2)
也可以写作 (map proc . (list1 list2))
(见 5.2节),因此不难理解这种写法。
反过来如果有 n 个参数存储在一个列表中,可以用 apply
将它们传给一个指定函数:
1 |
|
这样我们可以实现 map
函数:
1 |
|
从列表中过滤出符合要求的函数,可以用filter
。它接受一个返回布尔值的函数和一个列表作为参数,例如
1 |
|
我们同样可以实现 filter
:
1 |
|
思考题:你能把
map
和filter
改成迭代(尾递归)的形式吗?
虽然函数式编程不鼓励使用赋值,但是很多场景完全不使用赋值会非常不方便,并且有些场景适当地使用赋值可以提升代码性能,简化一些实现。Scheme使用 set!
特殊形执行赋值,使用格式是(set! var val)
。set!
先对 val
表达式求值,然后将值赋值给 var
。例如:
1 |
|
引入赋值会给系统增加很多不确定性。对于不使用赋值的函数,传入确定的参数必然得到确定的值,就像数学函数一样。而一旦引入赋值,就不一定了。可以看下面的例子:
1 |
|
这里 (account 10)
调用了两次,传入相同的参数但是返回不同的值。4.1节提到,当我们把函数当作值传递时,它所在的环境也会随之传递。因此我们可以把函数当作数据结构使用。上面的account
是一个函数,也可以认为是一个数据。
Racket中的有序对一旦构造好就不能修改。我们可以利用函数实现一个可修改的有序对:
1 |
|
上面的例子可以认为是 Scheme 中的“面向对象编程”。mcons
返回的函数可视为对象,它通过传入的参数决定执行的操作,因此这种写法又被称为消息传递模式(massage passing style)。mcons
可以称为构造器(constructor),调用构造器的返回值获取“成员”,(mpair 'mcar)
这样的表达式就类似于 Java 中的 mpair.mcar
。下面的代码展示了mcons
的一些用法。
1 |
|
这篇文章介绍 Scheme 的一些基础内容,包括 S表达式的构造、常用特殊形的用法、函数的调用与定义、对环境的理解、有序对与列表、常用函数的使用、赋值操作等内容。这些内容足以写出很多Scheme 程序了。下一篇文章我们将用 Scheme 实现一个 Scheme的解释器,实现本文介绍的绝大部分语言特性。
参考资料:
我希望有一种简单的事务系统,实现这样的效果:例如在下面的代码中,handler
函数处理业务逻辑。只要 handler
函数的任意位置抛出异常,那么handler
中所有修改,无论是_G.DB.last_update_time
、data.order
还是data.money
,都将回滚。
1 |
|
因为我们的程序是单线程的,因此不用考虑事务隔离性之类的问题。所以这个所谓的“事务系统”只是一种自动回滚机制。
其实我在以前见过类似的事务实现。它的做法是将需要修改的数据(如上面的data
)存储两份,一份是正式数据,一份是暂存数据。业务代码修改暂存数据,如果没有抛出异常,则让暂存数据覆盖正式数据(commit);否则让正式数据覆盖暂存数据(rollback)。暂存数据只是正式数据的浅拷贝,即使是这样,内存开销仍然非常大。而且由于是浅拷贝,这种机制对引用类型(如table)的字段无效。我认为这种做法并不够好。
最近我受到 SICP 4.3 节 Nondeterministic Computing的启发,想到其实回滚数据很简单——再改回去就好了。我们在修改数据的时候记录下数据在修改之前的值,如果捕获到异常,就把对应的数据改回修改之前的值。我们从pcall
开始动手:
1 |
|
由于 pcall
可以嵌套,i.e. pcall(function() pcall(function() end) end)
,我们使用栈保存事务的上下文,在begin
中压栈,commit
和 rollback
时弹出栈。因此栈顶就是当前事务的上下文。调用 set
执行修改操作,它会将数据的原始值保存在上下文中。
1 |
|
Commit时,当前事务的赋值操作全部生效,当前事务造成的副作用亦是上层事务的副作用,需要将当前事务记录的数据原始值移动到上层事务(如果有的话)的上下文中。回滚时,从后往前依次取出每次set
操作的原始值,将数据设置成修改前的值,完成回滚操作。
1 |
|
使用的时候不能直接赋值,需要调用set
。当然也可以做成元表,不过我不是很喜欢这样。
1 |
|
整个实现可以说是非常简单且行之有效,开销也并不大。代码是我随手写的,它还有优化空间:stack
中的旧数据存储可以使用更紧凑的数据结构;代码可以用 C实现提高性能等。
直到我接触到了 Neovim, 作为 Vim的改进版, 它使用 Lua (我最熟悉的语言之一) 作为配置文件,有着很现代化的特性, 解决了 Vim 的很多遗留问题. 于是花时间学习了解了Neovim, 并且调教成我喜欢的样子. 现在我使用 Neovim作为主力开发工具近三个月了, 这篇文章谈谈我的一些配置和使用体会. 我的Neovim 配置在仓库
我使用的插件有很多, 无法一一介绍. 这里介绍几个我认为比较重要的.
Neovim 可以在没有插件管理器的情况下安装插件.但是插件管理器可以带来很多好处: 可以根据配置的插件名自动安装插件,可以自动更新插件, 以及最重要的: 可以实现延迟加载. 如果安装了太多插件,会导致 Neovim 启动缓慢.使用插件管理器就可以在真正需要使用插件的时候加载它.
我目前使用的插件管理器是
Lazy.nvim 提供了一个管理界面, 可以展示插件的加载状态, 安装和更新插件,分析插件加载耗时等.
Profile 功能非常好用, 它展示各个插件时如何加载的, 耗时情况,有效帮助我们优化性能. 虽然我总共安装了 31 个插件, 但是启动时仅加载 3个插件; 其他插件都是延迟加载的. 启动时间仅 35 毫秒.
没人喜欢输入完整的文件名来打开编辑文件. 我比较习惯 vscode 和 sublime的方式, Ctrl-P 模糊查找文件. 我使用的插件是
Telescope 需要手动设置按键映射. 我将 leader + /设置为模糊搜索当前文件内容, Ctrl-P 设置为查找文件, Ctrl-O设置为打开当前文件的符号; gs在普通模式下设置为使用全词匹配搜索光标下的词 (通过参数word_match = '-w'
), visual模式下设置为不使用全词匹配搜索选定内容.
1 |
|
Telescope 可定制性很强. 例如全局搜索, 有的时候我们需要全词匹配,有时需要区分大小写, 有时需要开启正则表达式. 我的设置是利用 vim 的 count机制: 执行命令前可以先按一串数字表示这个命令重复多少遍. 对于自定义映射,我们自然可以获取到这串数字. 不过我的定义不是表示重复多少遍,而是用于设置选项: 如果按过 1, 则表示开启正则匹配; 如果按过 2,则表示开启全词匹配; 如果按过 3, 则表示区分大小写.
1 |
|
Vim / Neovim 有个缓冲区 (Buffer) 的概念. 每个打开的文件都是一个buffer, buffer 可以绑定在窗口上, 即使窗口关闭, 缓冲区依然存在.
这种做法改变了原生页签栏的逻辑, 但是却有很大的好处. 实际上我觉得 vim的 buffer 就近似等于诸如 vscode 和 sublime 之类编辑器的页签. 首先,打开文件时, 如果目标 buffer 不存在, 则会创建它, 否则切换到已存在的buffer. 跳转定义等操作时也是如此: 文件已打开则跳转, 否则在新页签中打开.这一操作逻辑与 vscode 相同.
由于窗格可以自由绑定 buffer, 这让多窗格的操作逻辑也很顺畅.
Bufferline 需要一些简单的配置. 我的配置为 leader 加数字键切换页签,leader 键为空格. 此外 leader + j 和 leader + k 用于切换相邻页签, Ctrl-j和 Ctrl-k 用于移动页签. ZZ 关闭当前页签 (实际上是删除当前 buffer)
1 |
|
LSP (Language Server Protocol) 是个很棒的发明. 过去我们需要 C++ 的IDE, Java 的 IDE, C++ 的 IDE… 不同的 IDE 操作都不同,而我们需要使用的功能基本上就是跳转定义, 查找引用, 错误检测, 自动补全等.LSP 则将这两部分分开了. 诸如跳转到定义, 自动补全之内的功能, 由一个称为language server 的独立进程完成, 它与编辑器之间使用 LSP 通信.这样各种语言只需要实现自己的 language server,开发者可以使用自己喜欢的编辑器开发.
Neovim 原生支持 LSP, 只需要简单几行配置就可以接入 language server.不过如果你像我一样连这几行代码都不愿写, 就可以使用
1 |
|
on_attach
用于指定当这个 language server接入时的回调函数. 在这里可以指定一些按键映射. 例如我将 gd映射成跳转到定义, gr 映射成查找引用, F2 重命名等.
1 |
|
此外还可以设置光标移动时变量名高亮, 输入时显示函数签名提示等.
1 |
|
Neovim 原生的补全也能用, 不过插件可以带来更好的体验. 我使用的是
hrsh7th/cmp-nvim-lsp
提供.hrsh7th/cmp-buffer
提供.quangnguyen30192/cmp-nvim-tags
提供.nvim-cmp 需要配合 snippet 插件实现 LSP 的代码片段补全. 我用的是saadparwaiz1/cmp_luasnip
. 只需要做一些简单的配置
1 |
|
LSP 和补全的效果可以参见本文开头的视频.
最后再介绍一个我很喜欢的插件:
视频中的例子是跳转单词的起始位置. 按下快捷键后,屏幕中每个单词的起始位置都会出现若干个字母,按下对应的字母即可跳转到对应的位置. Hop.nvim 支持多种跳转方式:
总之它的功能非常丰富. 对于我来说, 使用 “跳转全屏单词” 和“跳转本行单词” 这两种跳转方式就已经非常够用了. 我将它们分别映射成 leader+ h 和 leader + f.
Vim 有一套自己的剪切板系统, 复制的内容会放入 vim 的寄存器中.但是编辑器的剪切板与系统的剪切板不互通给我们带来了很多麻烦. 好在 Neovim对此有支持.
为了解耦, Neovim 并不直接提供剪切板支持, 而是依赖于独立的工具.当数据写入 +
或 *
寄存器时, Neovim会寻找系统中的剪切板工具, 如果有就会把数据同步给它们. 类似地, 当读取+
或 *
的时候, Neovim也会尝试从剪切板工具中读取数据. Neovim 原生支持 pbcopy
,pbpaste
, xclip
win32yank
tmux
等工具, 只要安装了这些工具就会自动启用.
在 Windows/WSL 中, 我们可以使用 win32yank. 它包含在 Windows 版的Neovim 中, 只要确保它在 PATH 下即可. 也就是说, 基本上只要安装好 Windows版的 Neovim, 剪切板就已经打通了. 我们只需要设置好按键映射,让复制粘贴使用 +
或 *
寄存器.
1 |
|
我将 visual 模式的 Ctrl-C 映射成复制; 将插入模式, 命令模式, visual模式, 终端模式的 Ctrl-V 映射成粘贴. 普通模式的 Ctrl-V还是要保留原本的行为, 用于进入 blockwise visual 模式.
WSL 中可以直接执行 exe, 所以只需要让 win32yank 在 WSL 的 PATH下即可打通剪切板.
对于 ssh 远程访问的 Neovim, 可以使用 tmux. tmux也有一套自己的剪切板系统, 叫做 buffer. tmux load-buffer
用于从文件中加载内容到 buffer, 相当于设置剪切板内容;tmux save-buffer
用于将 buffer 中的内容保存到文件,相当于读取剪切板内容. 最新版的 tmux (3.3a) 更是支持将 buffer的内容发送到目标客户端的剪切板中, 只需要执行 load-buffer
时带上 -w
选项即可. 这样就打通了远程 Neovim和本机的剪切板.
Neovim 原生支持 tmux 作为剪切板工具, 但是不支持 -w
参数.不过 Neovim 支持用户自定义剪切板工具,可以指定复制粘贴时访问寄存器的行为:
1 |
|
上面的配置表示当复制到 +
寄存器时, 会执行命令tmux load-buffer -w -
将复制的内容以标准输入的形式传递给tmux; 当从 +
寄存器粘贴内容时, 会执行命令tmux save-buffer -
从标准输出读取要粘贴的内容. 命令末尾的-
告诉 tmux 从标准输入/输出读写内容.
在 Vim 中按下 0
跳转到当前行开头, 而按下 ^
则跳转到当前行第一个非空白字符处. 但是 ^
键的位置正好处于双手中间, 又要按住 Shift 键, 按起来并不方便. 我觉得vscode 的交互处理很好: 按下 Home 键时, 会回到该行第一个非空白字符处;再次按 Home, 又会跳转到行首. 这样一个键就实现了两种操作, 很方便.
得益于 Neovim 的高度可定制性, 我们也可以写几行代码在 Neovim中实现这个功能.
1 |
|
实现起来很简单, 无非是判断下当前光标的位置, 然后再看情况跳转就行.
像 vscode 这样的编辑器可以在打开时恢复上次会话,自动打开上次打开的文件, 光标也会跳转到上次编辑的位置. Neovim也有这个功能, 不过它不会自动执行, 而是将执行的时机和方式交给用户,以保证充分的定制性. 我们只需要写几行代码即可实现类似的效果.
Neovim 使用 mksession
命令将当前会话状态保存成一个 .vim会话文件. 保存的内容由 sessionoptions
选项指定,可以是缓冲区, 当前目录, 窗口大小, 页签, 当前会话设置的全局变量,按键映射等. 默认保存路径为当前目录. 只需要 source 保存的 .vim会话文件即可还原会话状态.
我的做法是设置一个自动命令, 当退出 neovim 的时候保存当前会话状态.没有必要保存太多东西, 我设置 sessionoptions
只保存最必要的几项内容
1 |
|
保存在当前目录可能对版本控制不太友好, 因此我选择统一保存在固定的位置.我将当前路径的 sha1 值作为会话文件的文件名.
1 |
|
为了不拖慢启动速度, 并且也不是每次打开都想恢复上次会话,我选择设置一个自定义命令手动恢复会话. 恢复时只需要用 source命令加载会话文件即可.
1 |
|
文件的上次光标位置保存在 '"
标记中.创建一个自动命令在打开文件的时候检查这个标记并跳转,即可实现恢复光标的上次位置.
1 |
|
我这几个月使用体验主要有这几点
纯键盘交互, 使用方便. 使用 Neovim 之后,我使用鼠标的频率再次大大降低了. 之前使用 vscode + vscode-vim,时不时还是会用到鼠标的: 切换到终端, 切换不同的 vscode, 相互复制粘贴等.而 Neovim 可以配合 tmux, 在编辑器和终端之间随心所欲地切换,并且剪切板也是互通的. btw 我的键盘是 HHKB, 其键数少, 键位方便(特别是Ctrl 键和 ESC 键), 很适合这种交互逻辑.
可定制性极强. Neovim 的插件生态很丰富,你总能找当各种插件满足你的需求. 然而这不是最吸引人的, 因为 vscode的插件生态也非常丰富. 我认为 Vim/Neovim 最大的特点是它的配置文件不是json, 不是 ini, 而是一个完备的编程语言, 你可以随时在配置文件中写代码,以实现你的种种需求. 如果你要给 vscode 或者 sublime 添加功能,就必须动手写插件; 而 Neovim 却只需要在配置文件中写几行代码. Neovim 的API 极为丰富, 你甚至可以用它实现一个 web 服务器.
Neovim 是一款现代化的编辑器. 正在不断推陈出新的Vim 尚未过时, 何况是 Neovim. 事实上如果你的终端模拟器太古老,你可能甚至无法正常使用 Neovim, 因为需要特殊字体的渲染支持,真彩色终端的支持等. Neovim 有很多现代化特性, 如 LSP, Treesitter,异步任务, 多线程, 远程控制等等; 但是界面却是 TUI, 装作一个老古董的样子.总之它的功能之强大, 令我刚接触时为之惊叹.
客制化是灵魂. 要用好 Neovim,不应该复制别人的配置, 或者找一些 “开箱即用” 的配置, 一定要自己理解配置.我认为 Vim/Neovim 的定位就是高度可定制, 如果你追求开箱即用,那应该直接去用 vscode. Neovim 有丰富的帮助文档, 善用 help 命令,总能找到解答; 而一旦理解并上手了, 就会从中找到很多乐趣. 这就是 DIY的乐趣, 类似于客制化键盘, 组装电脑, 业余无线电…把刷抖音的时间用来做这个, 也未尝不可.
问题来自 matrix
, 返回一个新的矩阵ans
, 其中 ans[i][j]
是matrix[i][j]
的秩(rank).这里的秩不同于线性代数中的秩. 每个元素的秩是一个整数,表示它与其它元素之间的关系. 它是这样计算的:
p
和 q
处于同一行或同一列, 那么p < q
, 那么rank(p) < rank(q)
p > q
, 那么rank(p) > rank(q)
p == q
, 那么 rank(p) == rank(q)
我们先从简单的问题开始. 如果矩阵只有一列 (或者一行),那么对于每个元素, 只需要考虑多少个不同的元素比它小.
\[\begin{bmatrix}20 \\ 10 \\ 42 \\ 10\end{bmatrix} \Rightarrow \begin{bmatrix}2 \\ 1 \\ 3 \\ 1\end{bmatrix}\]
上面的例子中, 两个 10 都是最小的元素, 秩都为 1; 20 是第二大的元素,秩为 2; 42 的秩则为 3.
然而一旦将问题拓展到二维, 事情就变复杂了. 如下图所示,我们将矩阵扩展到 4 列. 这种情况下, 我们还能断定 20 的秩为 2 吗?
答案是否定的. 有两种情况:
这样看来, 要想求出一个元素的秩, 就要先求出同行和同列其他元素的秩;而这些元素的秩又依赖于它们所在行/列其它元素的秩. 这个问题中元素互相依赖,依赖关系错综复杂. 什么数据结构最适合处理依赖关系呢?答案是图.
我们进一步分析元素之间的依赖关系. 为了简单,我们先假设矩阵中没有重复元素. 我们发现,一个元素其实并没有依赖其同行和同列的其它所有元素,它只是依赖于同行和同列中大小仅次于它的元素. 对于元素x
, 记同行中大小仅次于它的元素为 a
,同列中大小仅次于它的元素为 b
, 则有
1 |
|
我们可以按照这个关系将问题抽象成图. 使用邻接列表表示图,我们只需要分别对每行和每列排序, 得到升序序列 S
, 然后将S[i]
加入 S[i-1]
的邻接列表中.
1 |
|
利用上面的算法我们就能构造出一张反映元素间依赖关系的图. 例如矩阵\(\begin{bmatrix} 5 & 1 \\ 3 & 2 \\ 4& 6 \end{bmatrix}\) 所对应的图如下所示:
注意我们让较大元素加入较小元素的邻接列表, 因此是较小元素指向较大元素.这么做的原因是较大元素依赖与较小元素, 我们需要先求出较小元素的秩,才能求出较大元素的秩.
有了依赖关系图后, 思路就变得明朗了. 首先将所有入度为 0 的节点的秩标为1, 因为它们是同行和同列中最小的元素. 接着就向下广搜,所有与之相邻的阶段的秩至少为 2, 以此类推.
注意一个节点可能有多个入度, 必须每个入度都计算过,这个节点的秩才算确定了. 如下图所示, 橙色的边表示计算过的边;只有当节点的所有入度都计算过, 节点才会标记为橙色, 表示它的秩已经确定了.否则灰色的节点表示秩待定.
1 |
|
上面是一个使用广搜的拓扑排序算法.前面建图的时候需要顺便求出每个节点的入度, 存放在 in
数组中.因为图中不会有环, 所以当队列 Q
为空时,所有节点的秩都已计算完毕.
前面我们讨论的情况都在假设矩阵中没有重复元素的前提下.实际上矩阵可能有重复元素, 如何处理这种情况呢?
因为同行和同列的相同元素需要有相同的秩, 我们不妨将其当作一个整体.也就是说, 同行同列中相同的元素视为图中的一个节点.
我们可以分别对每行每列排序, 遍历排序后的序列很容易获取相同元素,然后用并查集合并它们. 因为后面建图的时候也需要使用排序后的各行各列,这里我们可以将排序的结果存起来. 之后建图和广搜中,要查到元素在并查集中的根节点,因为只有根节点才会作为这些相同节点的代表加入图中. 完整代码如下:
1 |
|
2022 is a bit tough for many of us. The pandemic disturbed manypeople’s life. The Chinese internet industry is not that prosperous inthe year: many companies layoffs and many people are unemployed, manygame projects were aborted due to suspended issued publishing licenses,many companies’ share prices fell, and so on. I was infected withCOVID-19 at the end of 2022 and suffered from fever, body aches, andfatigue for a week.
Anyway, It’s worth celebrating at the beginning of 2023 as I’m stillalive, I still have a job, and I’ve done some meaningful work in theyear. We still hope that the world will be better and that all wishescome true.
I finished some of the new year’s resolutions for 2022.
On weekends, I usually spend time reading tech books.
This year I spent lots of time doing LeetCode, basically everyday.
Now I’ve done 912 exercises, I think it might be a small achievementthat’s not very easy to achieve. Doing LeetCode has somewhat improved mycoding skill and logical mind. it’s a good habit that’s worth keepingup.
English learning is not easy. I’ve spent plenty of time learningEnglish, including memorizing words and practicing listening andspeaking. I insist on memorizing words every day this year and untilnow, I’ve used Baicizhan to insist on memorizing words for 1709days.
I also spent lots of time on Open Language to practice listening andspeaking. It helped a lot at the beginning, but now I feel it doesn’thelp much. I think I’ve hit the English learning plateau.
In addition to English learning, I’ve been learning Japanese onDuolingo. I didn’t spend much time on that, basically two units a day.It’s just for fun.
This year I didn’t create much work in my spare time. I intended touse C++ to write a VPN software, but I only completed the most basicfeature and have not released an initial version. I just had somesporadic commit records on Github.
The happiest thing is that I’ve had a good time with the people Ilove. Thanks for her company to make me no longer feel lonely.
Not long ago I revisited My Life as McDull (麦兜故事), afamous Hongkong film I watched when I was a kid. But I didn’t understandits profound central idea at that age. I love what it said at theending:
Roasted chicken is easy to cook. The material is a chicken; themethod is to take a chicken and roast it, and then it’s done. If youwant it to be delicious, the secret is to cook it better.
Well, life might be a simple process from birth to death. It’ssimple, but it also can be complicated if you want. If you want it to bebeautiful, the secret is to be positive and try to make it better.
Although we might be going to face challenges in 2023, we stillbelieve that good things are about to happen. Happy New Year to us.
]]>如果使用 tcpdump --help
查看它的使用方法,总是会得到一大堆参数选项, 至于如何使用还是一头雾水. tcpdump的用法实际是这样的:
1 |
|
tcpdump 会读取网络中的数据, 解析协议, 然后与表达式相匹配.如果能匹配上, 则用指定的方式输出数据包的内容.选项则用于指定如何从网络中读取数据 (如指定网络接口)以及如何输出抓取到的数据.
在深入了解选项和表达式语法前, 先看个简单的例子. 选项 -A
表示用 ASCII 以文本的形式打印数据包的内容, -i
指定网络接口;表达式 tcp && port 80
表示抓取协议为tcp
, 且端口为 80
的数据包.
1 |
|
这个使用如果我们在这个机器上执行curl http://luyuhuang.tech
就可以看到 tcpdump 打印出:
1 |
|
上面是 TCP 的三次握手. 每个包会先打印一行基础信息, 称为 “dump line”,如当前时间, 通讯双方的 IP 地址和端口, TCP 的标志位, 序列号,以及选项等内容. 接下来是包体内容, 以文本形式打印. 如果不是 ASCII码则打印为 .
. 接下来就是 HTTP 请求:
1 |
|
我们可以看到文本形式的 HTTP 请求 GET / HTTP/1.1
,接着是服务器发的 ACK. 然后是服务器发的响应报文HTTP/1.1 301 Moved Permanently
, 最后是客户端发的 ACK.
tcpdump 的选项很多, 这里我们只介绍常用的一些选项.其它的等真正要用的时候再去查 manual 或者 Google 也不迟.
-i INTERFACE
指定网络接口. 使用 -i any
表示抓取所有网络接口的数据.
-A
用 ASCII 以文本的形式打印数据包的内容,不包括链路层头部. 适合抓取文本协议.
-X
同时以十六进制和文本的形式打印数据包的内容,不包括链路层头部. 类型这样的格式: 1
2
3
4
5
60x0000: 4500 0082 6aa1 4000 4006 27dd ac1d 392b E...j.@.@.'...9+
0x0010: 2b84 972b cf7e 0050 1b87 34d8 b04b de5b +..+.~.P..4..K.[
0x0020: 8018 01f6 a86c 0000 0101 080a 8cca db1f .....l..........
0x0030: 6df3 8eff 4745 5420 2f20 4854 5450 2f31 m...GET./.HTTP/1
0x0040: 2e31 0d0a 486f 7374 3a20 6c75 7975 6875 .1..Host:.luyuhu
0x0050: 616e 672e 7465 6368 0d0a 5573 6572 2d41 ang.tech..User-A
-XX
同时以十六进制和文本的形式打印数据包的内容,包括链路层头部.
-t
不在 dump line 打印时间.
-tt
以 UNIX 时间戳的格式打印时间.
-ttt
打印与上一个包之间的时间间隔.
-v
在 dump line 显示详细信息. 例如会显示 IP 分组的ttl, id, 总长度; TCP 段的校验和等信息.
-vv
显示更详细的信息.
-vvv
显示更更详细的信息.
-n
不将地址转换成名称.例如上面的例子显示服务器地址是 luyuhuang.tech.http
,如果指定 -n
就是显示 IP 地址和端口号 80.
-c COUNT
抓取指定数量的包,达到这个数量自动退出.
-s SNAPLEN
抓取包的前 SNAPLEN
个字节,默认为 262144. 根据需要适当调小这个值可以提升性能.
-#
打印出数据包的编号.
-w FILE
将原始包数据写入到指定的文件,而不是在终端打印它们. 文件扩展名通常是 .pcap
,保存的数据可以随后使用 tcpdump 分析.
-r FILE
读取分析指定的 pcap 文件,而不是抓取网络接口的数据. 下面是一个 -w
和 -r
的使用例子: 1
2
3
4
5
6
7
8
9
10
11$ tcpdump -i eth0 -w luyu.pcap 'tcp && port 80'
11 packets captured
11 packets received by filter
0 packets dropped by kernel
$ tcpdump -r luyu.pcap -# -ttt 'dst port 80' # 筛选发送到 80 端口的包
reading from file luyu.pcap, link-type EN10MB (Ethernet)
1 00:00:00.000000 IP 172.19.180.38.34716 > luyuhuang.tech.http: Flags [S], seq 3218407543, win 64240, options [mss 1460,sackOK,TS val 4127289318 ecr 0,nop,wscale 7], length 0
2 00:00:00.026788 IP 172.19.180.38.34716 > luyuhuang.tech.http: Flags [.], ack 1418465742, win 502, options [nop,nop,TS val 4127289345 ecr 1941966167], length 0
3 00:00:00.000199 IP 172.19.180.38.34716 > luyuhuang.tech.http: Flags [P.], seq 0:78, ack 1, win 502, options [nop,nop,TS val 4127289345 ecr 1941966167], length 78: HTTP: GET / HTTP/1.1
4 00:00:00.028462 IP 172.19.180.38.34716 > luyuhuang.tech.http: Flags [.], ack 368, win 501, options [nop,nop,TS val 4127289374 ecr 1941966194], length 0
值得一提的是 pcap 文件还可以被 -w
导出 pcap文件是个不错的选择.
表达式告诉 tcpdump 抓取哪些报文,它由一个或多个基本表达式组成, 支持用&&
, ||
这样的布尔运算符组合它们.基本表达式的格式为一个或多个修饰词 + ID. 修饰词是预定义的关键字, 如tcp
, host
, port
等; ID则是相应的值, 通常是数字, 地址或名字. 修饰词有三种
host
, net
, port
,portrange
等. 例如 host localhost
,net 128.3
, port 20
,portrange 6000-6008
. 如果没有指定类型, 则默认是host
.src
或 dst
. 因为类型字段通常会区分传输方向,例如 IP 分组中有源地址和目标地址, TCP 报文段中有源端口和目标端口.使用方向修饰词可以限定匹配那个方向的类型字段. 如果没有指定方向修饰词,则匹配双向的类型字段.tcp
,udp
, ip
, ip6
, arp
,ether
等. 因为一些协议有相同的类型字段, 例如 TCP 和 UDP都有端口. 使用协议修饰词可以限定抓取的协议. 如果没有指定协议修饰词,则会抓取所有有这个类型字段的协议.举几个基本表达式的例子
tcp
: 抓取所有 TCP 协议数据.port 20
: 抓取 TCP 和 UDP 协议源端口或目标端口为 20的数据. 因为没有指定协议, 且 TCP 和 UDP 有 port 字段, 于是抓取所有有port 字段的协议; 又因为没有指定方向, 于是抓取双向的数据.tcp dst port 80
: 抓取目标端口为 80 的 TCP 数据.这里有协议修饰词限定只会抓取 TCP, 方向修饰词 dst
限定匹配目标端口.基本表达式可以用逻辑运算符组合起来. tcpdump 的逻辑运算符有与, 或, 非,可以写作 &&
, ||
和 !
,或者 and
, or
和 not
.可以用括号改变运算优先级, 例如host luyuhuang.tech && (port 80 || port 443)
.
在组合表达式中, 有时可以省略修饰词. 如果一个基本表达式只提供 ID而没有修饰词, 则认为它的修饰词与前一个基本表达式相同. 例如表达式port 22 or 80 or 443
, 其中 80
和443
没有修饰词, 则认为它们的修饰词为 port
.因此这个表达式等价于 port 22 or port 80 or port 443
.
下面列出一些修饰词的用法
dst host HOST
: 匹配 IPv4 和 IPv6 协议目标地址为HOST
的分组. HOST
可以是 IP 地址或者名字.src host HOST
: 匹配 IPv4 和 IPv6 协议源地址为HOST
的分组.ip src host HOST
: 匹配 IPv4 协议源地址为HOST
的分组.host HOST
: 匹配 IPv4 和 IPv6 协议源地址或目标地址为HOST
的分组.ether host EHOST
: 匹配以太网协议源地址或目标地址为EHOST
的帧. 这里的 EHOST
是 MAC 地址.net NET/LEN
: 匹配 IPv4 和 IPv6协议源地址或目标地址的网络号为 NET/LEN
的分组. 例如net 192.168.1.1/16
匹配地址前缀 192.168
.tcp port PORT
: 匹配 TCP 协议源端口或目标端口为PORT
的报文.tcp src port PORT
: 匹配 TCP 协议源端口为PORT
的报文.port PORT
: 匹配 TCP 和 UDP 协议源端口或目标端口为PORT
的报文portrange PORT1-PORT2
: 匹配 TCP 和 UDP 协议端口范围在PORT1
和 PORT2
之间的报文.ip proto PROTOCOL
: 匹配 IPv4 的协议号为PROTOCOL
的分组. PROTOCOL
可以是一个表示协议号的数字, 例如 6 表示 TCP, 17 表示 UDP;或者是一个协议名, 可选的值有 icmp
, icmp6
,igmp
, igrp
, pim
, ah
,esp
, vrrp
, udp
, 或tcp
. 注意 icmp
, tcp
和udp
是关键字, 要使用反斜杠 \
转义, 如\tcp
.ip6 proto PROTOCOL
: 匹配 IPv6 的协议号 (在 IPv6中其实是 next header) 为 PROTOCOL
的分组.proto PROTOCOL
: 匹配 IPv4 和 IPv6 的协议号为PROTOCOL
的分组.tcp
, udp
和 icmp
: 其实是proto \tcp
, proto \udp
和proto \icmp
的简称. 因为这三个协议太常用了, tcpdump提供了这三个简称.可以认为一个基本表达式就是在表达某层协议的某个字段的值为多少.清楚这一点就很容易理解 tcpdump 的语法了.
1 |
|
tcpdump 还支持比较协议中的某些字节, 抓取满足条件的报文. tcpdump提供一种称为包数据访问器 (packet data accessor) 的语法,用于获取指定的字节:
1 |
|
PROTO
表示协议, 可以是 ether
,ppp
, ip
, arp
, rarp
,tcp
, udp
, icmp
, ip6
等; POS
表示自这层协议起始第几个字节; SIZE
表示在这个位置取几个字节, 其值可以是 1, 2 或 4. 若省略 SIZE
则表示取一个字节. 包数据访问器的值为一个 32 位无符号整数.
包数据访问器可以执行一些算术运算 (+
, -
,*
, /
, %
, &
,|
, ^
, <<
,>>
), 然后执行比较运算 (>
,<
, >=
, <=
,=
, !=
). 例如:
ip[0] & 0xf != 5
表示抓取所有没有选项的 IP分组. 因为 IPv4 协议的 4 至 7 位, 也就是第一个字节的低 4 位为首部长度,如果首部长度不为 5, 说明首部有选项数据.ip[6:2] & 0x1fff = 0
表示抓取分片偏移字段为 0 的 IP分组.tcp[((tcp[12] & 0xf0) >> 4) * 4] == 42
表示抓取 TCP 载荷的第一个字节等于 42 的报文段. 因为 TCP 首部的第 12字节的高 4 位为 Data offset 字段, 表示 TCP 首部有多少个字 (word).这里使用 tcp[12] & 0xf0) >> 4
取到 Data offset字段, 再乘以 4, 因为一个字为 4 字节. 这样tcp[((tcp[12] & 0xf0) >> 4) * 4]
就取到 TCP载荷的第一个字节.这里举一些常用的例子.
tcp port 80
抓取源端口或目标端口为 80 的 TCP报文.
tcp && host luyuhuang.tech && (port 80 || 443)
抓取源地址或目标地址为 luyuhuang.tech
, 且源端口或目标端口为80 或 443 的 TCP 报文.
icmp && src host 172.27.211.226 && dst host 172.27.208.1
抓取 172.27.211.226 发给 172.27.208.1 的 ICMP 包.
tcp && host 172.27.211.226 && ! port 22
抓取与 172.27.211.226 的 TCP 通信, 端口不为 22 的包.
tcp[((tcp[12] & 0xf0) >> 4) * 4 : 4] == 0x47455420 && tcp dst port 80
抓取 HTTP GET 请求.tcp[((tcp[12] & 0xf0) >> 4) * 4 : 4]
取到 TCP载荷的前 4 个字节, 而 0x47455420 其实是 "GET "
这四个字符:
1
0x47455420 == 'G' << 24 | 'E' << 16 | 'T' << 8 | ' '
以上的内容基本上足够举一反三, 了解 tcpdump 的使用.如果想知道更多选项的用法, 可以参考 man tcpdump
;如果需要深入学习表达式, 可以参考 man pcap-filter
.
为了实现一个线程安全的数据结构, 最简单的方法就是加锁. 对于队列来说,应该对入队和出队操作加锁.
1 |
|
这样的队列的问题是, 同一时间只能有一个线程执行入队和出队操作,这样队列的操作实际是串行化的. 如果有多个线程同时访问同一个队列,这个队列可能会成为并发的瓶颈. 为了解决这个问题,在一些场景下我们可以考虑使用无锁队列.
无锁数据接口可以分为三类:
下面我们介绍几种简单的无锁队列的实现.
单写单读的队列比较简单, 这里我们使用循环队列实现. 如下图所示,队列维护两个指针 head
和 tail
,分别指向队首和队尾. tail
始终指向 dummy 节点, 这样tail == head
表示队列为空,(tail + 1) % Cap == head
表示队列已满, 不用维护size
成员.
入队的时候移动 tail
指针, 而出队的时候移动head
指针, 两个操作并无冲突. 不过, 出队前需要读取tail
指针, 判断 tail != head
确认队列不为空;同理入队时也要判断 (tail + 1) % Cap != head
以确认队列不满.由于存在多个线程读写这两个指针, 因此它们都应该是原子变量.
此外, 由于两个操作在不同线程中执行, 我们还需考虑内存顺序.如果初始队列为空, 线程 a 先执行入队操作, 线程 b 后执行出队操作, 则线程 a入队操作的内容要对线程 b 可见.
为了做到这一点, 需要有 a(2) “happens-before” b(3). 而 a(3) 和 b(2)分别修改了读取了 tail
, 所以应该利用原子变量同步, 使得 a(3)“synchronizes-with” b(2). 可以在 a(3) 写入 tail
的操作中使用 release, b(2) 读取 tail
的操作中使用 acquire实现同步. 不熟悉内存顺序的同学可以参考
同理, 如果初始队列满, 线程 a 先执行出队操作, 线程 b 后执行入队操作,则线程 a 出队操作的结果要对线程 b 可见.出队的时候需要调用出队元素的析构函数,要保证出队元素正常销毁后才能在那个位置写入新元素, 否则会导致内存损坏.可以在出队写入 head
的操作中使用 release, 入队读取head
的操作中使用 acquire 实现出队 “synchronizes-with”入队.
1 |
|
这种单写单读的无锁队列的两种操作可以同时执行,且两种操作都只需要执行确定数量的指令, 因此数据 wait-free 结构,性能很高.
CAS (compare and swap) 是一种原子操作,在一个不可被中断的过程中执行比较和交换. C++ 的 std::atomic
中有两种 CAS 操作, compare_exchange_weak
和compare_exchange_strong
1 |
|
这两种 CAS 操作基本上是相同的: 如果原子变量与 expected
相等, 则将其赋值为 desired
并返回 true
; 否则expected
赋值成原子变量当前的值并返回 false
.下面是 compare_exchange_strong
的一个伪实现
1 |
|
当然实际的实现不可能是这样的. 在 x86 下compare_exchange_*
会被编译成一条 cmpxchgl
指令, 因此操作是原子且无锁的.
1 |
|
x86-64 下 -O2 编译成:
1 |
|
compare_exchange_weak
和compare_exchange_strong
的区别在于,compare_exchange_weak
有可能在当前值与expected
相等时仍然不执行交换并返回 false
;compare_exchange_strong
则不会有这个问题. weak版本能让编译器在一些平台下生成一些更优的代码, 在 x86 下是没区别的.
compare_exchange_*
支持指定两个内存顺序:成功时的内存顺序和失败时的内存顺序.
1 |
|
我们可以利用 CAS 操作实现很多无锁数据结构.下面我们来看如何实现多写多读的队列.
为了说明前面实现的单写单读队列无法执行多写多读, 我们来看一个例子.
1 |
|
假设有两个线程 a 和 b 同时调用 pop
, 执行顺序是 a(1),b(1), b(2) a(2). 这种情况下, 线程 a 和线程 b 都读到相同的head
指针, 存储在变量 h
中. 当 a(2) 尝试读取data[h]
时, 其中的数据已经在 b(2) 中被 move 走了.因此这样的队列不允许多个线程同时执行 pop 操作.
可以看到, 整个 pop
函数是一个非原子过程,一旦这个过程别其他线程抢占, 就会出问题. 如何解决这个问题呢?在无锁数据结构中, 一种常用的做法是不断重试.具体的做法是, 在非原子过程的最后一步设计一个 CAS 操作,如果过程被其他线程抢占, 则 CAS 操作失败, 并重新执行整个过程. 否则 CAS操作成功, 完成整个过程的最后一步.
1 |
|
首先注意到我们不再使用 std::move
和allocator::destroy
, 而是直接复制,使得循环体内的操作不会修改队列本身. (3) 是整个过程的最后一步,也是唯一会修改队列的一步, 我们使用了一个 CAS 操作. 只有当head
的值等于第 (1) 步获取的值的时候, 才会移动head
指针, 并且返回 true
跳出循环;否则就不断重试.
这样如果多个线程并发执行 pop
, 则只有成功执行 (3)的线程被视为成功执行了整个过程, 其它的线程都会因为被抢占, 导致执行 (3)的时候 head
被修改, 因而与局部变量 h
不相等,导致 CAS 操作失败. 这样它们就要重试整个过程.
类似的思路也可以用在 push
上.看看如果我们用同样的方式修改 push
会怎样:
1 |
|
与 pop
操作不同, push
操作的第 (2) 步需要对data[t]
赋值, 导致循环体内的操作会修改队列. 假设 a, b两个线程的执行顺序是 a(1), a(2), b(1), b(2), a(3). a 可以成功执行到 (3),但是入队的值却被 b(2) 覆盖掉了.
我们尝试将赋值操作 data[t] = val
移到循环的外面,这样循环体内的操作就不会修改队列了. 当循环退出时, 能确保tail
向后移动了一格, 且 t
指向tail
移动前的位置.这样并发的时候就不会有其他线程覆盖我们写入的值.
1 |
|
但是这样做的问题是, 我们先移动 tail
指针再对data[t]
赋值, 会导致 push
与 pop
并发不正确. 回顾下 pop
的代码:
1 |
|
同样假设有两个线程 a 和 b. 假设队列初始为空
push
, 执行 a(1), a(2). tail
被更新, 然后切换到线程 bpop
, 执行 b(4). 因为 tail
被更新, 因此判断队列不为空为了实现 push
与 pop
的并发,push
对 data[t]
的写入必须 “happens-before”pop
对 data[h]
的读取. 因此这就要求push
操作先对 data[t]
赋值, 再移动tail
指针. 可是前面为了实现 push
与push
的并发我们又让 push
操作先移动tail
再对 data[t]
赋值.如何解决这一矛盾呢?
解决办法是引入一个新的指针 write
, 用于push
与 pop
同步. 它表示 push
操作写到了哪个位置.
1 |
|
push
操作的基本步骤是:
tail
;data[t]
赋值, t
等于 tail
移动前的位置;write
. write
移动后等于tail
.而 pop
操作使用 write
指针判断队列中是否有元素. 因为有 (3) “synchronizes-with” (4), 所以 (2)“happens-before” (5), pop
能读到 push
写入的值. 在 push
函数中, 只有在当前的 write
等于 t
时才将 write
移动一格, 能确保最终write
等于 tail
.
这种多写多读的无锁队列的两种操作可以同时执行,但是每种操作都有可能要重试, 因此属于 lock-free 结构.
前面例子使用默认的内存顺序, 也就是 memory_order_seq_cst .为了优化性能, 可以使用更宽松的内存顺序. 而要考虑内存顺序, 就要找出其中的happens-before 的关系.
前面分析了, push
中的赋值操作 data[t] = val
要 “happens-before” pop
中的读取操作val = data[h]
, 这是通过 write
原子变量实现的:push
中对 write
的修改要 “synchronizes-with”pop
中对 write
的读取. 因此 push
修改 write
的 CAS 操作应该使用 release, pop
读取 write
时则应使用 acquire.
同理, 当队列初始为满的时候, 先运行 pop
在运行push
, 要保证 pop
中的读取操作val = data[h]
“happens-before” push
中的赋值操作 data[t] = val
. 这是通过 head
原子变量实现的: pop
中对 head
的修改要“synchronizes-with” push
中对 head
的读取.因此 pop
修改 head
的 CAS 操作应该使用release, push
读取 head
时则应使用acquire.
1 |
|
push
与 push
并发移动 tail
指针的时候, 只影响到 tail
本身. 因此 (1) 和 (3) 对tail
读写使用 relaxed 就可以了. 同样 push
与push
并发移动 write
指针时,也不需要利用它做同步, 因此 (5) 处的做法是
1 |
|
成功时使用 release, 为了与 pop
同步; 而失败时使用relaxed 就可以了.
同理, pop
与 pop
并发移动 head
时, 也影响到 head
本身. 因此 (6) 读取 head
使用 relaxed 即可. 而 (9) 处为了与 push
同步, 成功时要使用release, 失败时使用 relaxed 即可.
设置不同的生产者和消费者线程数量, 每个生产者向依次队列里插入 10000个元素. 下面是测试结果, “XpYc” 表示 X个生产者 Y 个消费者. 纵坐标为耗时.
可以看到无锁队列并不总是最快, 当生产者数量增多时, 性能开始下降,因为入队的时候需要抢占 tail
和 write
.实际应用中需要具体情况具体分析.
参考资料:
std::mutex
, 原子变量 std::atomic
等标准库.对于原子变量的操作, 有一个很重要的概念就是内存顺序 (memoryorder), 其中涉及到的概念很多, 理解起来可能会有些困难.本文我们来谈谈这个话题.本文可能有些长, 涉及到的概念有些多. 其中 3.4 节和 3.5 节标注了星号,它们的实际应用较少, 不感兴趣的同学可以先跳过, 或者读完全文后再阅读.
我们不能在两个线程中同时访问修改一个变量, 这会导致数据竞争的问题.程序的结果是未定义的. 从实现上来说, 我们不能保证读写操作是原子的, 例如32 位机器上, 修改一个 64 位变量可能需要两条指令;或者变量有可能只是在寄存器里, 对其的修改要在稍后才会写入内存.解决数据竞争的方式除了使用 std::mutex
加锁,还可以使用原子变量.
1 |
|
上面的例子展示了原子变量最简单的用法. 使用原子变量不用担心数据竞争,对它的操作都是原子的. 除此之外, 原子变量的操作可以指定内存顺序,帮助我们实现线程同步, 这也是本文的重点. 上面的代码中, 线程 1将值写入原子变量 a
, 线程 2 则读取 a
中的值.这便是原子变量最基础的两种操作.
对原子变量的操作可以分为三种
fetch_add
, 交换操作 exchange
(返回变量当前的值并写入指定值) 等.每个原子操作都需要指定一个内存顺序 (memory order).不同的内存顺序有不同的语义, 会实现不同的顺序模型 (order model),性能也各不相同. C++ 中有六种内存顺序
1 |
|
这六种内存顺序相互组合可以实现三种顺序模型 (ordering model)
稍后我们会详细讨论这六种内存顺序. atomic::store
和atomic::load
函数都有一个内存顺序的参数, 默认为memory_order_seq_cst
. 它们的声明如下
1 |
|
此外 std::atomic
重载了运算符,我们可以像使用普通变量一样读写原子变量.例如上面代码中两个线程的读写操作分别调用的是std::atomic<int>::operator=(int)
和std::atomic<int>::operator int()
.此时会使用默认的内存顺序, 也就是 memory_order_seq_cst
.
在开始讲这六种内存顺序之前, 有必要先了解一下几个最基础的概念.
对一个原子变量的所有修改操作总是存在一定的先后顺序,且所有线程都认可这个顺序, 即使这些修改操作是在不同的线程中执行的.这个所有线程一致同意的顺序就称为修改顺序 (modificationorder). 这意味着
无论使用哪种内存顺序, 原子变量的操作总能满足修改顺序一致性,即使是最松散的 memory_order_relaxed
. 我们来看一个例子
1 |
|
上面的代码创建了 4 个线程. thread1
和thread2
分别将偶数和奇数依次写入原子变量 a
,thread3
和 thread4
则读取它们. 最后输出thread3
和 thread4
每次读取到的值.程序运行的结果可能是这样的
1 |
|
虽然每次运行的修改顺序不同, 各个线程也不太可能看到每次修改的结果,但是它们看到的修改顺序是一致的. 例如 thread3
看到 8 先于 9,thread4
也会看到 8 先于 9, 反之亦然.
Happens-before 是一个非常重要的概念. 如果操作 a“happens-before” 操作 b, 则操作 a 的结果对于操作 b 可见. happens-before的关系可以建立在用一个线程的两个操作之间,也可以建立在不同的线程的两个操作之间.
单线程的情况很容易理解. 函数的语句按顺序依次执行, 前面的语句先执行,后面的后执行. 正式地说, 前面的语句总是“sequenced-before” 后面的语句. 显然, 根据定义,sequenced-before 具有传递性:
Sequenced-before 可以直接构成 happens-before 的关系. 如果操作 a“sequenced-before” 操作 b, 则操作 a “happens-before” 操作 b. 例如
1 |
|
语句 (1) 在语句 (2) 的前面, 因此语句 (1) “sequenced-before” 语句 (2),也就是 (1) “happens-before” 语句 (2). 所以 (2) 可以打印出 (1)赋值的结果.
多线程的情况就稍微复杂些. 一般来说多线程都是并发执行的,如果没有正确的同步操作, 就无法保证两个操作之间有 happens-before 的关系.如果我们通过一些手段, 让不同线程的两个操作同步, 我们称这两个操作之间有synchronizes-with 的关系.稍后我们会详细讨论如何组合使用 6 种内存顺序, 让两个操作达成synchronizes-with 的关系.
如果线程 1 中的操作 a “synchronizes-with” 线程 2 中的操作 b, 则操作 a“inter-thread happens-before” 操作 b. 此外synchronizes-with 还可以 “后接” 一个 sequenced-before 关系组合成inter-thread happens-before 的关系:
Inter-thread happens-before 关系则可以 “前接” 一个 sequenced-before关系以延伸它的范围; 而且 inter-thread happens-before 关系具有传递性:
正如它的名字暗示的, 如果操作 a “inter-thread happens-before” 操作 b,则操作 a “happens-before” 操作 b. 下图展示了这几个概念之间的关系:
注意, 虽然 sequenced-before 和 inter-thread happens-before都有传递性, 但是 happens-before 没有传递性.后面我们会在 3.5 节中看到这个性质的重要性, 以及 C++为什么要定义这么多概念.
现在我们来看一个例子. 假设下面的代码中 unlock()
操作“synchronizes-with” lock()
操作.
1 |
|
假设直到 thread1
执行到 (2) 之前, thread2
都会阻塞在 (3) 处的 lock()
中. 那么可以推导出:
因此 (4) 可以读到 (1) 对变量 a
的修改.
需要说明的是, happens-before 是 C++ 语义层面的概念, 它并不代表指令在CPU 中实际的执行顺序. 为了优化性能,编译器会在不破坏语义的前提下对指令重排. 例如
1 |
|
虽然有 a++;
“happens-before” b++;
,但编译器实际生成的指令可能是先加载 a
, b
两个变量到寄存器, 接着分别执行 “加一” 操作, 然后再执行a + b
, 最后才将自增的结果写入内存.
1 |
|
上面展示了 x86-64 下的一种可能的编译结果. 可以看到 C++的一条语句可能产生多条指令, 这些指令都是交错执行的.其实编译器甚至还有可能先自增 b
再自增 a
.这样的重排并不会影响语义, 两个自增操作的结果仍然对return a + b;
可见.
前面我们提到 C++ 的六种内存顺序相互组合可以实现三种顺序模型.现在我们来具体看看如何使用这六种内存顺序, 以及怎样的组合可以实现synchronizes-with 的关系.
memory_order_seq_cst
可以用于 store, load 和read-modify-write 操作, 实现 sequencial consistent 的顺序模型.在这个模型下, 所有线程看到的所有操作都有一个一致的顺序,即使这些操作可能针对不同的变量, 运行在不同的线程. 2.1节中我们介绍了修改顺序 (modification order),即单一变量的修改顺序在所有线程看来都是一致的. Sequencial consistent则将这种一致性扩展到了所有变量. 例如
1 |
|
thread1
和 thread2
分别修改原子变量x
和 y
. 运行过程中, 有可能先执行 (1) 再执行(2), 也有可能先执行 (2) 后执行 (1). 但无论如何,所有线程中看到的顺序都是一致的. 因此如果我们这样测试这段代码:
1 |
|
(7) 处的断言永远不会失败. 因为 x
和 y
的修改顺序是全局一致的, 如果先执行 (1) 后执行 (2), 则read_y_then_x
中循环 (5) 退出时, 能保证 y
为true
, 此时 x
也必然为 true
, 因此(6) 会被执行; 同理, 如果先执行 (2) 后执行 (1), 则循环 (3) 退出时y
也必然为 true
, 因此 (4) 会被执行. 无论如何,z
最终都不会等于 0.
Sequencial consistent 可以实现 synchronizes-with 的关系. 如果一个memory_order_seq_cst
的 load 操作在某个原子变量上读到了一个memory_order_seq_cst
的 store 操作在这个原子变量中写入的值,则 store 操作 “synchronizes-with” load 操作. 在上面的例子中, 有 (1)“synchronizes-with” (3) 和 (2) “synchronizes-with” (5).
实现 sequencial consistent 模型有一定的开销. 现代 CPU 通常有多核,每个核心还有自己的缓存. 为了做到全局顺序一致,每次写入操作都必须同步给其他核心. 为了减少性能开销,如果不需要全局顺序一致, 我们应该考虑使用更加宽松的顺序模型.
memory_order_relaxed
可以用于 store, load 和read-modify-write 操作, 实现 relaxed 的顺序模型. 这种模型下,只能保证操作的原子性和修改顺序 (modification order) 一致性, 无法实现synchronizes-with 的关系. 例如
1 |
|
thread1
对不同的变量执行 store 操作. 那么在某些线程看来,有可能是 x
先变为 true
, y 后变为true
; 另一些线程看来, 又有可能是 y
先变为true
, x
后变为 true
.如果这样测试这段代码:
1 |
|
(4) 处的断言就有可能失败. 因为 (2) 与 (3) 之间没有 synchronizes-with的关系, 所以就不能保证 (1) “happens-before” (4). 因此 (4) 就有可能读到false
. 至于 relaxed 顺序模型能保证的修改顺序一致性的例子,2.1 节中已经讨论过了, 这里就不多赘述了.
Relaxed 顺序模型的开销很小. 在 x86 架构下,memory_order_relaxed
的操作不会产生任何其他的指令,只会影响编译器优化, 确保操作是原子的. Relaxed模型可以用在一些不需要线程同步的场景, 但是使用时要小心. 例如std::shared_ptr
增加引用计数时用的就是memory_order_relaxed
, 因为不需要同步;但是减小应用计数不能用它, 因为需要与析构操作同步.
在 acquire-release 模型中, 会使用 memory_order_acquire
,memory_order_release
和 memory_order_acq_rel
这三种内存顺序. 它们的用法具体是这样的:
memory_order_acquire
内存顺序. 这称为 acquire 操作.memory_order_release
内存顺序. 这称为 release 操作.memory_order_acquire
, memory_order_release
和memory_order_acq_rel
:memory_order_acquire
, 则作为 acquire操作;memory_order_release
, 则作为 release操作;memory_order_acq_rel
, 则同时为两者.Acquire-release 可以实现 synchronizes-with 的关系. 如果一个 acquire操作在同一个原子变量上读取到了一个 release 操作写入的值, 则这个 release操作 “synchronizes-with” 这个 acquire 操作. 我们来看一个例子:
1 |
|
在上面的例子中, 语句 (2) 使用 memory_order_release
在y
中写入 true
, 语句 (3) 中使用memory_order_acquire
从 y
中读取值. 循环 (3)退出时, 它已经读取到了 y
的值为 true
,也就是读取到了操作 (2) 中写入的值. 因此有 (2) “synchronizes-with” (3).根据 2.2 节介绍的规则我们可以推导出:
所以 (1) “happens-before” (4). 因此 (4) 能读取到 (1) 中写入的值,断言永远不会失败. 即使 (1) 和 (4) 用的是memory_order_relaxed
.
3.1 节我们提到 sequencial consistent 模型可以实现 synchronizes-with关系. 事实上, 内存顺序为 memory_order_seq_cst
的 load操作和 store 操作可以分别视为 acquire 操作和 release 操作.因此对于两个指定了 memory_order_seq_cst
的 store 操作和load 操作, 如果后者读到了前者写入的值, 则前者 “synchronizes-with”后者.
为了实现 synchronizes-with 关系, acquire 操作和 release操作应该成对出现. 如果 memory_order_acquire
的 load 读到了memory_order_relaxed
的 store 写入的值, 或者memory_order_relaxed
的 load 读到了memory_order_release
的 store 写入的值, 都不能实现synchronizes-with 的关系.
虽然 sequencial consistent 模型能够像 acquire-release 一样实现同步,但是反过来 acquire-release 模型不能像 sequencial consistent一样提供全局顺序一致性. 如果将 3.1 节的例子中的memory_order_seq_cst
换成 memory_order_acquire
和 memory_order_release
1 |
|
则最终不能保证 z
不为 0. 在同一次运行中,read_x_then_y
有可能看到先 (1) 后 (2), 而read_y_then_x
有可能看到先 (2) 后 (1). 这样有可能 (4) 和(6) 的 load 的结果都为 false
, 导致最后 z
仍然为 0.
Acquire-release 的开销比 sequencial consistent 小. 在 x86 架构下,memory_order_acquire
和 memory_order_release
的操作不会产生任何其他的指令, 只会影响编译器的优化: 任何指令都不能重排到acquire 操作的前面, 且不能重排到 release 操作的后面; 否则会违反acquire-release 的语义. 因此很多需要实现 synchronizes-with关系的场景都会使用 acquire-release.
到目前为止我们看到的, 无论是 sequencial consistent 还是acquire-release, 要想实现 synchronizes-with 的关系, acquire操作必须在同一个原子变量上读到 release 操作的写入的值. 如果 acquire操作没有读到 release 操作写入的值, 那么它俩之间通常没有synchronizes-with 的关系. 例如
1 |
|
上面的例子中, 只要 y
的值非 0 循环 (4) 就会退出.当它退出时, 有可能读到 (2) 写入的值, 也有可能读到 (3) 写入的值.如果是后者, 则只能保证 (3) “synchronizes-with” (4), 不能保证与 (2) 与(4) 之间有同步关系. 因此 (5) 处的断言就有可能失败.
但并不是只有在 acquire 操作读取到 release 操作写入的值时才能构成synchronizes-with 关系. 为了说这种情况, 我们需要引入 releasesequence 这个概念.
针对一个原子变量 M 的 release 操作 A 完成后, 接下来 M上可能还会有一连串的其他操作. 如果这一连串操作是由
这两种构成的, 则称这一连串的操作为以 release 操作 A 为首的release sequence. 这里的写操作和 read-modify-write操作可以使用任意内存顺序.
如果一个 acquire 操作在同一个原子变量上读到了一个 release操作写入的值, 或者读到了以这个 release 操作为首的 release sequence写入的值, 那么这个 release 操作 “synchronizes-with” 这个 acquire 操作.我们来看个例子
1 |
|
上面的例子中, (3) 处的 compare_exchange_strong
是一种read-modify-write 操作, 它判断原子变量的值是否与期望的值 (第一个参数)相等, 如果相等则将原子变量设置成目标值 (第二个参数) 并返回true
, 否则将第一个参数 (引用传递)设置成原子变量当前值并返回 false
. 操作 (3) 会一直循环检查,当 flag
当值为 1 时, 将其替换成 2. 所以 (3) 属于 (2) 的release sequence. 而循环 (4) 退出时, 它已经读到了 (3) 写入的值, 也就是release 操作 (2) 为首的 release sequence 写入的值. 所以有 (2)“synchronizes-with” (4). 因此 (1) “happens-before” (5), (5)处的断言不会失败.
注意 (3) 处的 compare_exchange_strong
的内存顺序是memory_order_relaxed
, 所以 (2) 与 (3) 并不构成synchronizes-with 的关系. 也就是说, 当循环 (3) 退出时, 并不能保证thread2
能读到 data.at(0)
为 42. 但是 (3) 属于(2) 的 release sequence, 当 (4) 以 memory_order_acquire
的内存顺序读到 (2) 的 release sequence 写入的值时, 可以与 (2) 构成synchronizes-with 的关系.
memory_order_consume
其实是 acquire-release模型的一部分, 但是它比较特殊, 它涉及到数据间相互依赖的关系.为此我们又要提出两个新概念: carries dependency 和dependency-ordered before.
如果操作 a “sequenced-before” b, 且 b 依赖 a 的数据, 则 a “carries adependency into” b. 一般来说, 如果 a 的值用作 b 的一个操作数, 或者 b读取到了 a 写入的值, 都可以称为 b 依赖于 a. 例如
1 |
|
有 (1) “sequenced-before” (2) “sequenced-before” (3); (1) 和 (2)的值作为 (3) 的下标运算符 []
的操作数, 所以有 (1) “carriesa dependency into” (3) 和 (2) “carries a dependency into” (3). 但是 (1)和 (2) 并没有相互依赖, 它们之间没有 carries dependency 的关系. 类似于sequenced-before, carries dependency 关系具有传递性.
memory_order_consume
可以用于 load 操作. 使用memory_order_consume
的 load 称为 consume 操作. 如果一个consume 操作在同一个原子变量上读到了一个 release 操作写入的值,或以其为首的 release sequence 写入的值, 则这个 release 操作“dependency-ordered before” 这个 consume 操作.
Dependency-ordered before 可以 “后接” 一个 carries dependency的关系以延伸它的范围: 如果 a “dependency-ordered before” k 且 k “carriesa dependency into” b, 则 a “dependency-ordered before” b.Dependency-ordered before 可以直接构成 inter-thread happens-before的关系: 如果 a “dependency-ordered before” b 则 a “inter-threadhappens-before” b.
概念很复杂, 但是基本思路是:
我们来看一个例子:
1 |
|
(4) 处的循环退出时, consume 操作 (4) 读取到 release 操作 (3)写入的值, 因此 (3) “dependency-ordered before” (4). 由此可以推导出:
p2
的值作为 (5) 的操作数, 因此 (4) “carries adependency into” (5);所以 (1) “happens-before” (5). 因此 (5) 可以读到 (1) 写入的值, 断言(5) 不会失败. 但是操作 (6) 并不依赖于 (4), 所以 (3) 和 (6) 之间没有inter-thread happens-before 的关系, 因此断言 (6) 就有可能失败. 回想 2.2节强调过的, happens-before 没有传递性. 所以不能说因为 (3)“happens-before” (4) 且 (4) “happens-before” (6) 所以 (2)“happens-before” (6).
与 acquire-release 类似, 在 x86 下使用memory_order_consume
的操作不会产生任何其他的指令,只会影响编译器优化. 与 consume 操作有依赖关系的指令都不会重排到 consume操作前面. 它对重排的限制比 acquire 宽松些, acquire要求所有的指令都不能重排到它的前面, 而 consume只要求有依赖关系的指令不能重排到它的前面. 因此在某些情况下, consume的性能可能会高一些.
前面讲了很多概念和理论, 现在我们来看两个实际的例子来加深理解.
在一些场景下, 如果锁被占用的时间很短, 我们会选择自旋锁,以减少上下文切换的开销. 锁一般用来保护临界数据的读写,我们希望同一时间只有一个线程能获取到锁, 且获取到锁后,被锁保护的数据总是最新的. 前者通过原子操作即可保证,而后者就需要考虑内存顺序了.
1 |
|
两个线程并发运行, thread1
往队列里写入数据,thread2
从队列里读出数据. 入队操作 (2) 可能需要复制数据,移动指针, 甚至 resize 队列, 因此我们要保证获取到锁时,这些操作的结果完全可见. 出队操作也是同理. 所以自旋锁要保证 unlock 操作“synchronizes-with” lock 操作, 保证锁保护的数据是完整的.
我们可以用 acquire-release 模型实现自旋锁. 下面是一个简单的实现:
1 |
|
上面的实现中, (1) 处加锁用到的 exchange
是一种read-modify-write 操作, 它将目标值 (第一个参数) 写入原子变量,并返回写入前的值. 在这个实现中, 锁被占用时 flag
为true
. 如果锁被占用, (1) 处的 exchange 操作会一直返回true
, 线程阻塞在循环中; 直到锁被释放, flag
为false
, exchange 操作将 flag
重新置为true
以抢占锁, 并且返回其原来的值 false
,循环退出, 加锁成功. 解锁则很简单, 将 flag
置为false
即可.
由于解锁操作使用 memory_order_release
且加锁操作使用memory_order_acquire
,所以能保证加锁成功时与上一次解锁操作构成 “synchronizes-with” 的关系,也就是 unlock 操作 “synchronizes-with” lock 操作.
加锁时的 exchange 操作是一个 read-modify-write 操作, 它既读又写.当它使用 memory_order_acquire
时, 只能保证它读的部分是一个acquire 操作. 如果有两个线程抢占同一个锁
1 |
|
(1) 和 (2) 之间没有任何同步关系, 假设先执行操作 (1) 后执行操作 (2),那么 thread1
中 (1) 之前的操作结果不一定对thread2
可见. 但能确定的是, 只会有一个线程得到锁,这是由原子变量的修改顺序 (modification order) 所保证的. 要么thread1
先将 flag
置为 true
, 要么thread2
先将 flag
置为 true
,这个顺序是全局一致的.
单例模式是一种很常用的设计模式.我们通常用一个静态成员指针存储这个类的唯一实例,然后用一个静态成员函数获取它, 如果指针为空则创建.
1 |
|
但是这种做法在多线程下有并发的问题. 解决这个问题最简单的办法就是加锁.但是给整个函数加锁是没有必要的,因为只有在初始创建对象时才会有并发的问题, 后续则只需要返回指针,此时的锁会造成不必要的性能负担. 更好的做法是仅在要创建对象的时候加锁,我们可以这样实现:
1 |
|
在上面的实现中, 如果发现 instance
指针为空,则加锁并创建对象. 获取到锁后, 还需要再判断一下 instance
是否为空, 以免在判断 (1) 之后, 锁获取到之前, 有其他线程创建了对象.但是这种做法是有问题的: (1) 并没有在锁的保护下, 它有可能与 (2) 并发,导致数据竞争.
我们可以使用原子变量解决这个问题, 将 instance
指针改成原子类型 std::atomic<App*>
. 那么在读取和写入instance
时, 应该使用什么内存顺序呢,memory_order_relaxed
可以吗?
1 |
|
假设线程 1 调用了 get_instance
, 发现对象没有创建,然后成功获取到锁并且创建对象, 接着执行 (3) 将新对象的指针写入instance
中; 随后线程 2 也调用 get_instance
执行 (1) 读取到线程 1 在操作 (3) 中写入的值, 此时能保证得到的指针p
是有效的吗?
注意线程 1 执行 p = new App
时需要调用构造函数初始化App
中的成员. 因为 (1) 和 (3) 都是memory_order_relaxed
的内存顺序, 它们之间没有任何synchronizes-with 的关系. 所以当线程 2 在操作 (1) 读取到线程 1 在操作(3) 写入的指针时, 不能保证 App
成员的初始化结果对线程 2可见. 这会导致线程 2 得到的对象数据不完整, 造成非常意外的结果.
正确的做法是使用 acquire-release 模型:
1 |
|
这样当线程 2 在操作 (1) 读取到线程 1 在操作 (3) 写入的指针时, 有 (3)“synchronizes-with” (1). 因此线程 1 初始化 App
成员的结果对线程 2 可见, 此时线程 2 返回 p
不会有任何问题.
操作 (2) 仍然使用 memory_order_relaxed
,因为它在锁的保护下, 锁可以保证线程同步, 因此没有问题.
总结一下这几种内存顺序模型:
memory_order_relaxed
: 最宽松的内存顺序,只保证操作的原子性和修改顺序 (modificationorder).memory_order_acquire
, memory_order_release
和 memory_order_acq_rel
: 实现 acquire操作和 release 操作, 如果 acquire 操作读到了release 操作写入的值, 或其 release sequence 写入的值, 则构成synchronizes-with 关系, 进而可以推导出happens-before 的关系.memory_order_consume
: 实现 consume操作, 能实现数据依赖相关的同步关系. 如果 consume 操作读到了release 操作写入的值, 或其 release sequence 写入的值, 则构成dependency-ordered before 的关系,对于有数据依赖的操作可以进而推导出 happens-before的关系.memory_order_seq_cst
: 加强版的 acquire-release 模型,除了可以实现 synchronizes-with 关系,还保证全局顺序一致.对于有些概念, 如 sequenced-before 和 carries dependency,本文只描述了其中几种简单的情况, 并没有给出严谨的定义.在实际应用中我们一般不用考虑它们的严格定义,但如果你需要了解或对其感兴趣, 可以参考 cppreference.com.
关于内存顺序, C++ 还有一些本文没提到的功能, 如内存栅栏 (memoryfences). C++ 的原子变量提供的操作也很多, 本文只提到了其中一部分.对其感兴趣或需要了解的同学可以参考 C++ Concurrency in Action 和cppreference.com. 限于篇幅, 本文的有些内容可能解释地不够详尽,或者定义不够严谨. 如果想了解概念的严格定义, 可以参考 cppreference.com;如果需要详细的讲解, 可以参考 C++ Concurrency in Action.
参考资料:
const
标识一个类型不可变. 这其实很容易理解.不过, 对于 C++ 而言, 简单的概念也有很多可以讨论的. 我们来看一个问题.我们知道 const
可以用于修饰成员函数,标识这个函数不能修改这个类的数据. 假设一个类有一个指针类型的成员T *p
, 我们希望通过 get()
方法获取p
所指向的对象的引用. 如果 get()
被const
修饰, 它应该返回什么类型, 是 T&
还是const T&
呢?
1 |
|
可能很多同学很自然地认为应该返回 const T&
, 因为get()
不应该改变数据. 的确, 很多类就是这样处理的.例如标准库的顺序容器都有 front
方法,返回容器中第一个元素的引用. 如vector<int>::front()
1 |
|
可以看到非 const 版本返回的是 int&
, 而 const版本返回的是 const int&
.
我们看另一个例子. 标准库的迭代器, 例如vector<int>::iterator
, 会重载解引用运算符operator*()
. 那么它的返回类型是什么呢?
1 |
|
它返回了 int&
而不是 const int&
,即使这个 operator*()
是 const 版本的.
首先我们知道, C++的类型分为值类型和引用类型.对于引用类型而言, 例如指针, 它有两层 const: 顶层 (top-level)const 和底层 (low-level) const. 顶层 const表示这个变量本身不可变.
1 |
|
而底层 const 表示这个变量引用的值不可变.
1 |
|
对变量赋值或初始化时, 顶层 const 可以隐式加上或去除, 底层 const可以隐式加上, 却不能去除.
1 |
|
如果一个类的成员函数被 const
修饰, 则这个函数的this
指针是底层 const 的, 也就是const T *this
. 那么通过 this
指针访问到的所有成员, 也就是这个函数能访问到的所有成员, 都是顶层 const的.
以本文开头的例子, get()
被 const
修饰,get()
中访问到的 p
的类型应该是T *const p
. 编译器并不阻止我们在 const成员函数里修改指针成员指向的值, 那为什么有些类要禁止修改,而有些类允许修改呢?
如果一个类有一个指针类型的成员 T *p
,那么我们在拷贝这个类的对象时,是复制这个指针本身还是复制指针指向的值呢?
1 |
|
C++ 允许开发者控制对象拷贝时的行为. 我们可以仅拷贝指针,让拷贝前后指向同一个对象; 也可以拷贝指针指向的值,向用户隐藏这个类存在引用成员这一事实.
当我们拷贝指针指向的值时, 这个类看起来就是个值类型.例如 std::vector
, 它的内存是动态分配的, vector
对象本身只记录指向分配内存的指针. 但是我们在拷贝 vector
时,会复制其包含的所有对象. 因此对于用户来说它就是个值类型.
既然是值类型, 就只有一层 const, 也就是顶层 const. 因此当一个vector
是 const 的时候, vector::front()
也应该返回 const 的引用. 类需要负责将顶层的 const 传递到底层.
当我们仅拷贝指针本身时, 这个类看起来就是个引用类型.例如 vector::iterator
, 它包含一个指向 vector
中元素的指针. 当拷贝迭代器时, 仅会拷贝指针本身,拷贝前后的迭代器指向同一个元素. 因此对于用户来说它就是个引用类型.
既然是引用类型, 就应该区分底层 const 和底层 const.因此即使迭代器本身是 const 的, operator*()
也不会返回 const的引用, 因为顶层 const 不会传递到底层. 怎样设置迭代器的底层 const?vector
提供了两个类, vector::iterator
和vector::const_iterator
. 后者无论迭代器本身是否是 const,operator*()
始终返回 const 的引用, 因为它是底层 const的.
回到本文开头的问题. 标准答案是, 返回 const T&
还是T&
取决于我们如何定义这个类. 如果 class C
的拷贝控制函数拷贝 (或移动) 了 p
指向的值, 则应当返回const T&
; 如果只是拷贝指针本身, 则应当返回T&
.
更一般地总结一下, 对于包含引用类型成员 (如指针, 智能指针) 的类来说,如果要将其视为值类型, 则
1 |
|
反之, 如果将其视为引用类型, 则
1 |
|
当然, 如果这个类是诸如某某管理器之类的单例类或不可拷贝的类,就不需要考虑这么多了, 根据需求处理即可.
与其他语言 (Java, Go, Python) 不同, C++ 的类既可以是值类型,又可以是引用类型, 这取决于开发者怎样设计. C++希望开发者可以像用内置类型一样使用自定义类型, 因此它提供了运算符重载,拷贝控制等一系列的机制, 这让 C++ 的类很强大, 同时也比较复杂.这就要求我们能够理解这些概念, 而不是只是记住 const
有哪几种用法.