兔子先生

探寻计算机的历史与哲学密码

本篇为策略模式的上篇,我以传统的严格意义上的面向对象语言 Java为例来说明此模式;我会在下一篇用非严格意义上的OO语言 Go基于同样的例子进行说明。

有一个游戏

假设我们在设计一款鸭子游戏。玩家可以通过按钮选择任意一款鸭子,使得对应的鸭子可以在屏幕上展现,并且做相应的动作。为此我们设计了如下的高层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Game {
Duck duck;
public Game(String type){
if (type.equals("picnic")) {
duck = new MallardDuck();
} else if (type.equals("hunting")) {
duck = new DecoyDuck();
} else if(type.equals("inBathTub")) {
duck = new RubberDuck();
}
}

public play() {
duck.display();
duck.swim();
}
}

我们希望Duck是一个超类,并且在这里起到多态的作用。通过type输入参数来模拟用户通过按钮选择鸭子这一行为,并且在构造函数中初始化相应的鸭子实例,以便于后续调用play函数来在屏幕上展示。

我们假定每种鸭子都有独一无二的外观,并且每种鸭子都有相同的游泳方式。记住我们的这两个假设,因为我们马上要据此设计超类。

超类

我们之前假设了鸭子的两种特征:

  1. 每种鸭子都有独一无二的外观
  2. 每种鸭子的游泳方式都相同

我们把Duck设计为一个抽象类,因为外观是独一无二的,所以display设计为抽象方法,具体的外观由每一个具体的子类去实现;swim的行为每种鸭子都一样,所以我们在抽象类里将其实现,以达到所有子类共享的目的。

但是,我们忽然又意识到鸭子还有quack叫的特征,但是目前又不确定quack是不是有变化,所以暂时将其也在抽象类里实现。我们的超类代码大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class Duck {

public abstract void display();

public void quack() {
System.out.println("quack quack quack!");
}

public void swim() {
System.out.println("All ducks float, even decoys!");
}

}

我们来总结一下如此设计的目的:

  1. 抽象类Duck用于实现多态
  2. 抽象方法display用于统一接口,让子类分别实现
  3. quackswim分别用于继承,以达到代码复用的目的

我们用一张图来表示这种继承关系:

duck

我们的设计是利用继承达到多态和代码复用的目的。绿头鸭和红头鸭分别是具体的实现,它们继承了quackswim代码,又各自实现了display的方法。目前来看,我们的设计还算完美,如果不是一只橡皮鸭子出现的话。

现在要让鸭子飞

​ 现在游戏上线了一种新的鸭子—橡皮鸭,橡皮鸭与之前的鸭子不同的是,它不会“呱呱”叫,只会“吱吱”叫。我们所有种类的鸭子都是继承自超类Duck,所以我们可以在橡皮鸭的子类中覆盖Duckquack方法,向下面这样:

1
2
3
4
5
6
7
8
9
10
11
public class RubberDuck extends Duck{

public void display(){
System.out.println("I am a rubber duck!");
}

public void quack() {
System.out.println("squeak squeak squeak!");
}

}

因为橡皮鸭子不会呱呱叫,所以我们覆写了quack方法。子类重写虽然能解决当前的问题,但也势必会引入新的问题,我们接着往下看。

现在有一个新的需求要加入到游戏当中去,那就是我们需要让鸭子展示飞行的动作。基于我们目前的设计,很容易想到的是:在Duck中加入fly()方法:

fly

那么,问题来了,我们是将fly()设计成抽象方法呢,还是将其在超类里实现呢?在超类里实现就会出现橡皮鸭子会飞的情况,我们自然会想到子类重写,就像重写quack函数一样。但是,如果以后又加入了其它的不会飞也不会叫的鸭子怎么办?比如诱饵鸭是木头鸭,不会飞也不会叫;橡皮鸭不会飞但是会叫。长此以往,我们的代码会充斥着各种子类、各种重写的方法,这显然是有问题的。换句话说,我们之前的重写quack是不明智之举。

其实,问题的本质是继承不适用于目前的场景。

使用继承的问题:

  • 如果是抽象的方法,代码在多个子类中重复,即代码无法复用。试想一下:一部分鸭子的飞行代码相同,而又有很多种类的鸭子飞行行为各有特色。
  • 每次新增行为都要修改抽象类和子类,包括以后面临的修改,这都违反了开闭原则,且不可能预知全部的行为。
  • 如果超类实现子类继承,复写子类方法同样无法达到代码复用,因为可能有多个种类的鸭子飞行行为相同,但又不同于超类里的实现。如果一旦涉及到修改,势必会带来维护上的噩梦。
  • 改变会牵一发而动全身,造成其他鸭子不想要的改变。

接口能解决问题么

Java为我们提供了interface来实现多态。既然不能使用继承和重写来实现对应的功能,那么我们很容易想到用定义接口的方式让子类分别实现quackfly接口:

duck interface

像图中那样将quackfly作为单独的接口去实现。这可以解决部分问题,也就是不会有鸭子和行为不符合的情况,但依然面临严峻的问题:

  • 行为只是可能会发生变化的话,每个子类都实现接口,会造成代码无法复用,继而也会带来维护上的噩梦。
  • 如果我想在运行中随时改变飞行的动作呢?继承超类和子类实现单独的接口都无法有效的解决问题。

其实,单独的接口和抽象方法所面临的问题是一致的,即代码复用和维护变更的问题。试想一下,如果有48个Duck子类的飞行行为相同,代码实现就会有48份,而你恰巧在某个时刻需要修改这个行为...

这时,你肯定期待着设计模式能骑着白马来解救你!

分开变化和不变的部分

幸运的是,有一个设计原则,恰好适用于此状况。

找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。

这样的概念很简单,几乎是每个设计模式背后的精神所在。所有的模式都提供了一套方法让“系统中的某部分改变不会影响其它部分”

我们试分析一下,关于变和不变存在以下三种情况:

  1. 一定不变的
  2. 一定会变的
  3. 可能会变的

对于一定会变的,我们很容易用抽象方法或者接口来解决(display)。对于一定不变的(swim),我们就用继承,把实现放在超类里。

棘手的是可能会变化的部分,比如这里的flyquack,这正是让我们左支右绌、进退维谷的根源。根据上面提到的原则,我们应该把这两个部分从Duck类中取出来,建立一组新类来代表每个行为。

separate behavior

我们该如何实现新的行为类呢?我们希望每种Duck在使用行为类的时候具有弹性和灵活性,比如可以动态改变,也就是说我们可以在Duck类中增加设定行为的方法,这样就可以在运行时动态的改变鸭子的行为了。

那么,有什么设计原则可以指导我们么?

针对接口编程,而不是针对实现编程。

我们理应在Duck类中使用接口,而不是具体的实现。也就是说Duck类不负责实现flyquack,具体的实现交给新的类去实现FlyBehaviorQuackBehavior接口。

Duck类应该只针对接口编程,“针对接口编程”真正的意思是“针对超类型编程”。这里所谓的“接口”有多个含义,关键在于使用多态。利用多态,程序可以针对超类型编程,执行时会根据实际状况执行到真正的行为,不会被绑死在超类型的行为上。“针对超类型编程”这句话,可以明确的说成“变量的声明应该是超类型,通常是一个抽象类或者是一个接口。如此,只要是具体实现次超类型的类所产生的对象,都可以指定给这个变量。这也意味着,声明类时不用理会以后执行时的真正对象类型!”

也就是说,高层代码应该针对行为编程!

实现行为

implement duck's behavior

我们设计了两个接口FlyBehaviorQuackBehavior,还有它们对应的类,负责实现具体的行为。这样的设计,可以让飞行和呱呱叫的动作被其它的对象复用,因为这些行为已经与鸭子类无关了。而我们可以新增一些行为,不会影响到既有的行为类,也不会影响到使用飞行行为的鸭子类。这么一来,有了继承的代码复用好处,却没有了继承所带来的包袱。

用代码实现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public interface FlyBehavior {
public void fly();
}

public class FlyWithWings implements FlyBehavior {
public void fly() {
System.out.println("I'm flying!!");
}
}

public class FlyNoWay implements FlyBehavior {
public void fly() {
System.out.println("I can't fly");
}
}

public class FlyRocketPowered implements FlyBehavior {
public void fly() {
System.out.println("I'm flying with a rocket");
}
}

public interface QuackBehavior {
public void quack();
}

public class Quack implements QuackBehavior {
public void quack() {
System.out.println("Quack");
}
}

public class Squeak implements QuackBehavior {
public void quack() {
System.out.println("Squeak");
}
}

public class MuteQuack implements QuackBehavior {
public void quack() {
System.out.println("<< Silence >>");
}
}

把行为整合进抽象类duck

new duck

上图是新的鸭子类,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class Duck {

FlyBehavior flyBehavior;
QuackBehavior quackBehavior;

public abstract void display();

public void performQuack() {
flyBehavior.quack();
}

public void performFly() {
flyBehavior.fly();
}

public void swim() {
System.out.println("All ducks float, even decoys!");
}

}

我们重新实现一下绿头鸭:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MallardDuck extends Duck {

public MallardDuck() {

quackBehavior = new Quack();
flyBehavior = new FlyWithWings();

}

public void display() {
System.out.println("I'm a real Mallard duck");
}
}

我们的绿头鸭代码里有针对实现的代码,就是实例化行为的两行,这貌似违背了了我们之前所述,针对接口编程,而不是针对实现;其实,这里可以改为工厂模式,使得我们的代码彻底的面向接口。但工厂模式不是我们本次的重点,关于这一点我会稍后再略作解释,让我们继续完善我们的代码吧!

之前我们说要把鸭子的行为设计成可以动态改变,现在貌似还差点火候。那么,让我们在Duck类中再加入两个设定行为的方法吧:

1
2
3
4
5
6
7
public void setFlyBehavior(FlyBehavior fb) {
flyBehavior = fb;
}

public void setQuackBehavior(QuackBehavior qb) {
quackBehavior = qb;
}

​ 从此以后,我们可以随时调用这两个方法来改变鸭子的行为。来做个模拟吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MiniDuckSimulator {

public static void main(String[] args) {

Duck mallard = new MallardDuck();
mallard.performQuack();
mallard.performFly();

Duck model = new ModelDuck();
model.performFly();
model.setFlyBehavior(new FlyRocketPowered());
model.performFly();

}
}

output:

1
2
3
4
Quack
I'm flying!!
I can't fly
I'm flying with a rocket!

​ 可见,在运行时想改变鸭子的行为,只需要调用setter方法即可。我们通过把可能变化的行为抽象为接口,使用单独的类去实现它。这样即解决了代码复用的问题,又使得维护变得简单。

是的,这就是策略模式

没错,我们刚刚完成一个策略模式!

是时候了解一下策略模式的具体定义了:

Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

翻译一下:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。

让我们把描述问题的措辞稍作改变,不再把鸭子的行为说成是“一组行为”,我们开始把行为想成“一族算法”,算法代表了鸭子能做的事。如今算法和鸭子类之间不再是IS-A的关系,而是HAS-A的关系。

“有一个”关系相当有趣:每一个鸭子都有一个FlyBehavior和一个QuackBehavior,好将飞行和呱呱叫的行为委托给它们代为处理。当你将两个类结合起来使用,如同本例一般,这就是“组合”。这种做法和“继承”的不同之处在于,鸭子的行为不是继承来的,而是和合适的行为对象“组合”来的。

这也是我们通常所说的另一个设计原则:多用组合,少用继承

策略模式就是简单的多态么

纵观策略模式的定义以及各种围绕策略模式的示例,我们很容易产生一个疑问:策略模式就是简单的多态么?策略模式的定义何其标题看不出任何联系,到底何为策略?

从维基百科上策略模式的定义可以看出,策略模式乃是一个方法论,不拘于多态一种实现方式:

Typically, the strategy pattern stores a reference to some code in a data structure and retrieves it. This can be achieved by mechanisms such as the native function pointer, the first-class function, classes or class instances in object-oriented programming languages, or accessing the language implementation's internal storage of code via reflection.

stackoverflow上亦有相同的发问 Is 'Strategy Design Pattern' no more than the basic use of polymorphism? 得票最高的回答也阐述了相同的意思:策略模式,或者说设计本身,它不是指细节代码,而是一种思维方式。

我们虽然在这里用多态的方式实现了策略模式,但策略模式的实现方式绝非多态一种。

另外我观 设计模式之美 中对策略模式的阐述,其称:不使用工厂模式而直接实例化行为对象的情况为简单的面向接口编程,并非严格意义的策略模式。如此观点虽有启发,但仍未消除心中疑虑,因为“策略”一词在我心中还有另一个概念。

策略模式的概念与定义难以让我释怀。我必须自己寻找一个答案,并说服自己去相信它,即便它可能不那么正确。因为我们每个人之所以要不断的思考,就是要缝合自我认知体系里矛盾的地方。

策略一词,在我的知识体系里,是矛盾的!

从Unix设计哲学中取经

在最初接触策略模式时,我很自然的就联想到Unix设计哲学中的一条原则:分离原则。

Rule of Separation: Separate policy from mechanism; separate interfaces from engines.

分离原则:策略同机制分离,接口同引擎分离

policystrategy 同被翻译为“策略”,我以为思想肯定也很接近。但是Gof中描述的算法族,每个具体的算法就是一个策略,让我一度觉得这两个原则之间可能没有关系。

的确,按照定义,这两个原则对策略的理解不是一个层面的东西,就如同英语中的微妙区别一样,一个是具体的行动计划,一个是指导性原则。

但吊诡的是,这两者居然能品出一丝完全相反的味道来。

先来了解一下分离原则。其实它比策略模式要更加普世,虽然分离原则和策略模式同为软件设计原则,但分离原则要更加抽象,而策略模式更贴近于代码,分离原则更偏向于架构。

分离原则讲究把策略同机制分离,策略是针对使用方,机制则说的是实现方。分离原则认为,策略和机制是按照不同的时间尺度变化的,策略的变化要远远快于机制。所以,把策略和机制揉成一团有两个负面影响:一来会使策略变的死板,难以适应用户需求的改变;二来也意味着任何策略的改变都极有可能动摇机制。

相反,将两者剥离,就有可能在探索新策略的时候不会打破机制。另外也更容易为机制写出较好的测试,因为策略都太过短命,不值得花太多经历在上面。《UNIX编程艺术》一书中,举了x图形引擎的例子。让X成为一个通用的图形引擎,而将用户界面风格留给工具包或者系统其它层来决定。GUI工具包的观感时尚来去匆匆,而光栅操作和组合却是永恒的。

让我们回想一下策略模式中的策略,Gof说每个具体的算法类就是策略。我认为以此起**"策略模式"这个名字多少有些以偏概全,未准确传达此模式使“算法族可以互相替换”的主旨。分离原则和策略模式中的策略是不同场景下对不同内容的描述:分离原则的策略是指使用多种不同机制的方法,让机制与使用分离,本质上还是抽离不变与变化的东西**;而策略模式之策略是指一系列做同类事的算法,因为同类,所以可互换,可多态!

所以,忘掉策略模式概念本身吧,它或许不是一个好名字,但却是一个好的、常用的、易用的代码设计方法。只要心中牢记“面向接口,而不是实现编程”,也许一不小心就会写出策略模式了!

总结学到的思想

  1. 面向接口编程,而不是面向实现编程
  2. “针对接口编程”真正的意思是“针对超类型编程”
  3. “针对超类型编程”这句话,可以明确的说成“变量的声明应该是超类型,通常是一个抽象类或者是一个接口。如此,只要是具体实现次超类型的类所产生的对象,都可以指定给这个变量。这也意味着,声明类时不用理会以后执行时的真正对象类型”
  4. 多用组合,少用继承
  5. 唯一不变的就是变化本身
  6. 所有的模式都提供了一套方法让“系统中的某部分改变不会影响其它部分”

参考文献:

  1. 设计模式:可复用面向对象软件的基础
  2. Head First 设计模式
  3. 设计模式之美

变量的迷惑

如果你有其它类C语言的使用经历(c,java,c++,Go等),那么一提到变量,我们会将变量想象成一个box,它代表了计算机中的一块内存,是一个可以存放值的容器:

box

如上图所示,声明初始化一个a=1,就相当于在内存中开辟了一块空间用于存放值1,使用变量名a就可以改变内存中的值a=2。 当把a赋给一个新的变量b的时候,会在内存中为b重新开辟一块空间,并把a的一个副本存入其中。也就是说变量与变量之间完全独立,抱有这种认识的人大多会对 python 中的变量产生很大的误解,不信我们试看下面的代码片段:

1
2
3
4
5
6
7
8
>>> a = 9527
>>> b = a
>>> a,b
(9527, 9527)
>>> id(a),id(b)
(140232581997552, 140232581997552)
>>> b is a
True

上面的代码段初始化了一个变量a并将其赋值为 9527,之后又把a赋给了变量b,打印他们的值都为 9527,到现在 python 中的变量表现和其它语言没什么不同(表面上看起来)。但我们接着打印了这两个变量的地址,你会惊奇的发现,他们竟然相同。使用is来判断,b就是a,也就是说此时在内存中只有一块空间来存放9527这个值。现在我们试着改变一下a的值:

1
2
3
4
5
>>> a = 1024
>>> a,b
(1024, 9527)
>>> id(a),id(b)
(140232581997456, 140232581997552)

我们赋予a一个新的值1024,然后打印了他们的值,python 的表现仍然跟我们“预期”的一样:a的值改变了,b的值没有。,但是在我们打印了他们的内存地址之后,一切看起来并没那么简单。

ab的地址最初都是140232581997552,当a重新赋值之后,b的地址没有改变,而a的地址却变成了140232581997456。也就是说,python的解释器重新开辟了一块内存给了a,这完全颠覆了我们印象中基于box和store对变量的理解。虽然目前看起来还算工作正常,但是我准备再对示例代码做一些改动:

1
2
3
4
5
6
7
8
9
10
>>> a = [1,2,3,4,5]
>>> b = a
>>> a,b
([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
>>> id(a),id(b)
(140232582690624, 140232582690624)
>>> a == b
True
>>> a is b
True

这一次我们使用了list,一切看起来和刚才一样,ab指向同一块内存,现在我们试着改变list中的元素

1
2
a[1] = 9527
print(a,b)

你认为上段代码的输出会是什么? b中元素的值也会改变么?

1
2
3
>>> a[1] = 9527
>>> print(a,b)
[1, 9527, 3, 4, 5] [1, 9527, 3, 4, 5]

没错,这就是 python 让你惊讶的地方之一,这一次的表现不仅和你的预期不同,甚至和它上一次的表现也不相同。

第一次我们使用的是number,它在 python 中是一个不可变对象,python 中的变量其实是内存对象的一个标签,赋值仅仅是一个绑定的动作,画一个形象的图来表示:

label

当我们使用list时表现又不同,这是因为list在 python 中是一个可变对象。在 python 中一切皆对象,这种特殊的数据模型是造成我们误解的根本原因,接下来我们重点讨论一下 python 中的对象。

一切皆对象

Objects are Python’s abstraction for data. All data in a Python program is represented by objects or by relations between objects. (In a sense, and in conformance to Von Neumann’s model of a “stored program computer”, code is also represented by objects.)
Every object has an identity, a type and a value. An object’s identity never changes once it has been created; you may think of it as the object’s address in memory. The ‘is’ operator compares the identity of two objects; the id() function returns an integer representing its identity.

以上是 python 官网文档中的描述,翻译一下:Python中对象是所有数据的抽象。所有Python程序中的值都由对象或者对象之间的关系表示。Python中每个对象有一个唯一标识identity,一个对象的标识在对象被创建后不再改变。可以认为对象的identity是对象在内存中的地址,其值可以由内置函数id()求得。is操作符可以比较两个对象的identity是否相同,即两个对象是否是同一个。

对于 python 中的变量赋值操作,有两种类比说法。一个是 “boxes vs. label” ,另一个是“names and bindings” 。我们采用“names and bindings” 这种说法,在 python 里一切都是对象,如interger、string、list、dict、set、function等。当我们赋值给一个变量的时候,我们仅仅把变量当成一个名字(name):

<name> = <object>

我们实际上是将一个对象和一个名称绑定,需要注意的是一个对象可以被多个名称绑定,这是最司空见惯的情况,也是最容易引起歧义的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> a = 9527
>>> b = a
>>> a,b
(9527, 9527)
>>> id(a),id(b)
(140232581997552, 140232581997552)
>>> b is a
True
>>> a = "bohu"
>>> b = "bohu"
>>> print(id(a))
140090288720896
>>> print(id(b))
140090288720896
>>> print(a is b)
True

这段代码就展示了一个对象被多个名称绑定的情况,number 9527和字符串bohu是一个数值对象和一个字符串对象,并分别被两个变量绑定。

现在我们使用list来代替numberstring

1
2
3
4
5
6
7
8
>>> a = [1, 2, 3]
>>> b = [1, 2, 3]
>>> print(id(a))
140090289536200
>>> print(id(b))
140090288737736
>>> print(a is b)
False

这个例子中,我们看到ab指向了不同的内存空间,python 的表现之所以有所不同是因为stringnumberimmutable对象,而listmutable的对象,关于python中对象的mutability见下表:

Class immutable
bool Y
int Y
float Y
list N
tuple Y
str Y
set N
frozenset Y
dict N

可见除了listsetdict之外其余都是不可变的,一个immutable的对象被创建之后是不可以改变的。如果你试图通过与之绑定的变量去修改这个对象时,python会创建一个新的实例对象并与原来的变量绑定,之前的对象则伺机被回收。相反,一个mutable的对象是可以被原地改变的,比如之前的例子:

1
2
3
4
5
6
7
8
9
10
11
>>> a = [1,2,3,4,5]
>>> b = a
>>> a,b
([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
>>> id(a),id(b)
(140232582690624, 140232582690624)
>>> a[1] = 9527
>>> print(a,b)
[1, 9527, 3, 4, 5] [1, 9527, 3, 4, 5]
>>> id(a),id(b)
(140232582690624, 140232582690624)

除非重新赋值,否则ab绑定的对象的内存地址是不会改变的。当你使用b = a时,你并没有成功的copy一个list,你只是把两个name绑定到了同一个list对象之上。因此,正确的理解 python 的对象模型会帮助你正确的调试你的程序。

要理解 python 中的变量,我们就不能把变量当成一个盛放的盒子,我们要把 python 中的变量当做贴在盒子上的标签。我们可以在同一个盒子上贴多个标签,例如:

1
2
3
4
>>> a = "super hero powers"
>>> b = "super hero powers"
>>> print(a is b)
True

当我们执行a = "super hero powers"时,我们说:创建了等号右边的对象,并且把名称 a 绑定到这个对象上。当我们执行a = b时,我们说:把 a 绑定到 b 绑定的对象上

由此可见,在python中:

  • 变量的赋值,只是表示让变量指向了某个对象,并不表示拷贝对象给变量;而一个对象,可以被多个变量所指向。
  • 可变对象(列表,字典,集合等等)的改变,会影响所有指向该对象的变量。
  • 对于不可变对象(字符串、整型、元组等等),所有指向该对象的变量的值总是一样的,也不会改变。但是通过某些操作(+= 等等)更新不可变对象的值时,会返回一个新的对象。
  • 变量可以被删除,但是对象无法被删除。

函数调用,传值还是传引用?

先来看官方的一段描述:

Remember that arguments are passed by assignment in Python. Since assignment just creates references to objects, there’s no alias between an argument name in the caller and callee, and so no call-by-reference per se.

参数的传递是通过赋值进行传递(passed by assignment)。也就是说,参数传递时,只是让新变量与原变量指向相同的对象而已,并不存在值传递或是引用传递一说。

1
2
3
4
5
6
7
def my_func1(b):
b = 2

a = 1
my_func1(a)
a
1

这里的参数传递,使变量 a 和 b 同时指向了 1 这个对象。但当我们执行到 b = 2 时,系统会重新创建一个值为 2 的新对象,并让 b 指向它;而 a 仍然指向 1 这个对象。所以,a 的值不变,仍然为 1。

那么对于上述例子的情况,是不是就没有办法改变 a 的值了呢?答案当然是否定的,我们只需稍作改变,让函数返回新变量,赋给 a。这样,a 就指向了一个新的值为 2 的对象,a 的值也因此变为 2。

1
2
3
4
5
6
7
8
def my_func2(b):
b = 2
return b

a = 1
a = my_func2(a)
a
2

当你想获取改变后的值的时候,最好的选择就是返回一个元组来包含多个结果:

1
2
3
4
5
6
7
8
>>> def func1(a, b):
... a = 'new-value' # a and b are local names
... b = b + 1 # assigned to new objects
... return a, b # return new values
...
>>> x, y = 'old-value', 99
>>> func1(x, y)
('new-value', 100)

当传入的参数是一个mutable的对象时,改变对象的值,就会影响所有指向它的变量,因此,我们可以利用这一点达到传引用的效果:

1
2
3
4
5
6
7
8
>>> def func2(a):
... a[0] = 'new-value' # 'a' references a mutable list
... a[1] = a[1] + 1 # changes a shared object
...
>>> args = ['old-value', 99]
>>> func2(args)
>>> args
['new-value', 100]

但我们要注意的是,改变变量和重新赋值的区别

1
2
3
4
5
6
7
8
def my_func(l2):
l2 = l2 + [4]
return l2

l1 = [1, 2, 3]
my_func(l1)
l1
[1, 2, 3]

为什么 l1 仍然是[1, 2, 3],而不是[1, 2, 3, 4]呢?

要注意,这里 l2 = l2 + [4],表示创建了一个“末尾加入元素 4“的新列表,并让 l2 指向这个新的对象。这个过程与 l1 无关,因此 l1 的值不变。当然,同样的,如果要改变 l1 的值,我们就得让上述函数返回一个新列表,再赋予 l1 即可:

1
2
3
4
5
6
7
8
9

def my_func(l2):
l2 = l2 + [4]
return l2

l1 = [1, 2, 3]
l1 = my_func(l1)
l1
[1, 2, 3, 4]

浅拷贝与深拷贝

当我们说深拷贝和浅拷贝时,一般都是针对于集合类型来讲的,如 python 中的listtuplesetdict等。其它语言中的struct类型也会涉及到深浅拷贝之说,通常是指这些集合类型或者结构体中有其它集合的引用。

浅拷贝(shallow copy)

浅拷贝会创建新对象,是指重新分配一块内存,创建一个新的对象,里面的元素是原对象中子对象的引用。注意,其内容非原对象本身的引用,而是原对象内第一层对象的引用。浅拷贝有三种形式:

  • 类型构造器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> l1 = [1,2,3]
>>> l2 = list(l1)
>>> l2
[1, 2, 3]
>>> id(l1),id(l2)
(140232580721664, 140232580722496)
>>> l1 == l2
True
>>> l2 is l1
False
>>> s1 = {1,2,3}
>>> s2 = set(s1)
>>> s2
{1, 2, 3}
>>> id(s1),id(s2)
(140232582029088, 140232580748448)
>>> s1 == s2
True
>>> s1 is s2
False

这里,l2 就是 l1 的浅拷贝,s2 是 s1 的浅拷贝。当然,对于可变的序列,我们还可以通过切片操作符':'完成浅拷贝。

  • 切片操作
1
2
3
4
5
6
7
8
9
>>> l1 = [1,2,3]
>>> l3 = l1[:]
>>> l3
[1, 2, 3]
>>> l1 == l3
True
>>> l3 is l1
False

  • copy 模块中的 copy 函数
1
2
3
4
5
6
7
8
9
10
11
>>> import copy
>>> l1 = [1,2,3,4]
>>> l2 = copy.copy(l1)
>>> l2
[1, 2, 3, 4]
>>> id(l1),id(l2)
(140232580721408, 140232580721664)
>>> l1 == l2
True
>>> l2 is l1
False

因为浅拷贝只是创建一个新对象,集合中的元素内容仍然是原对象中子对象的引用,我们用以上三种方式中的任意一种来观察一下(因为他们都是浅拷贝,结果都是相同的):

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> import copy
>>> l1 = [1,2,3,4]
>>> l2 = copy.copy(l1)
>>> l2
[1, 2, 3, 4]
>>> id(l1),id(l2)
(140232580721408, 140232580721664)
>>> l1 == l2
True
>>> l2 is l1
False
>>> id(l1[2]),id(l2[2])
(140232598059200, 140232598059200)

这里l1l2是两个不同的对象,而l1[2]l2[2]是两个变量名称,通过id()可以看到他们两个绑定到了相同的对象之上:

不带可变对象的拷贝

这是没有可变对象的情况下的拷贝,当有可变对象时,也就是说当对象元素中有listsetdict等集合对象时,浅拷贝只是做一个引用绑定,并不会创建新的可变对象:

1
2
3
4
5
6
7
8
9
>>> s1 = ['a','b','c',['d','e']]
>>> s2 = copy.copy(s1)
>>> s3 = copy.deepcopy(s1)
>>> id(s1[0]),id(s2[0]),id(s3[0])
(140232582406832, 140232582406832, 140232582406832)
>>> id(s1[3]),id(s2[3]),id(s3[3])
(140232580722432, 140232580722432, 140232580722624)
>>> id(s1[3][0]),id(s2[3][0]),id(s3[3][0])
(140232582979824, 140232582979824, 140232582979824)

上面的代码片段增加了深拷贝的例子,关于深拷贝我们一会儿再说,这里只看s1s2,其中s1是包含列表的列表,经过浅拷贝之后我们发现:s1[3]s2[3]指向同样的对象。可以说明浅拷贝只是对子列表做了变量绑定,并没有创建新的对象。那么你在修改s1的同时,必然会影响到s2

1
2
3
4
5
6
7
>>> s1[3][0] = "行藏在我"
>>> s1
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s2
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s3
['a', 'b', 'c', ['d', 'e']]

用一张图来描述一下此时的内存图景:

带可变对象的拷贝

深拷贝(deep copy)

深拷贝只有一种形式,copy 模块中的 deepcopy() 函数。深拷贝和浅拷贝对应,深拷贝拷贝了对象的所有元素,包括多层嵌套的元素。因此,它的时间和空间开销要高。

通过上面的浅拷贝示例可知,浅拷贝不会为可变的子对象构建新的对象,这样就会带来修改了新数据之后旧数据也会被修改的副作用。有时候为了避免这种副作用,我们会使用深拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> s1 = ['a','b','c',['d','e']]
>>> s2 = copy.copy(s1)
>>> s3 = copy.deepcopy(s1)
>>> id(s1[0]),id(s2[0]),id(s3[0])
(140232582406832, 140232582406832, 140232582406832)
>>> id(s1[3]),id(s2[3]),id(s3[3])
(140232580722432, 140232580722432, 140232580722624)
>>> id(s1[3][0]),id(s2[3][0]),id(s3[3][0])
(140232582979824, 140232582979824, 140232582979824)
>>> s1[3][0] = "行藏在我"
>>> s1
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s2
['a', 'b', 'c', ['行藏在我', 'e']]
>>> s3
['a', 'b', 'c', ['d', 'e']]

还是上一节浅拷贝的例子,我们重点来看s3s3是用深拷贝构建出来的,观察可变子对象的id可以发现它是一个新的对象,拥有全新的内存地址,但是其中的不可变对象仍然共享了原来的对象。

我们通过s1[3][0] = "行藏在我"改变了子列表中的内容之后,深拷贝构造出来的s3并未受到影响,因为s1[3][0]改变的是s1[3]指向的对象本身,而s3[3]指向的是另一个不同的对象,此时的内存图景为:

带可变对象的深拷贝

关于元组copy时的注意事项:

  • 元组只包含非容器类型时(如数字、字符串、和其他'原子'类型的对象),无论是浅拷贝还是深拷贝返回的都是原元组对象的引用。
  • 元组包含可变对象时(如listsetdict等),浅拷贝依然返回引用,深拷贝则会创建一个新的对象和子对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
>>> tup1 = (1,2,3)
>>> tup2 = tuple(tup1)
>>> tup2
(1, 2, 3)
>>> id(tup1),id(tup2)
(140232580721216, 140232580721216)
>>> tup3 = copy.copy(tup1)
>>> tup4 = copy.deepcopy(tup1)
>>> id(tup3)
140232580721216
>>> id(tup4)
140232580721216
>>> tup4 is tup1
True
>>> tup3 is tup1
True
>>> tup2 is tup1
True
>>> tupwithlist1 = (1,2,3,[4,5])
>>> tupwithlist2 = copy.copy(tupwithlist1)
>>> tupwithlist3 = copy.deepcopy(tupwithlist1)
>>> id(tupwithlist1),id(tupwithlist2),id(tupwithlist3)
(140232580724112, 140232580724112, 140232580724672)
>>> id(tupwithlist1[3]),id(tupwithlist2[3]),id(tupwithlist3[3])
(140232580722304, 140232580722304, 140232580743680)
>>> tupwithlist1[3][1] = 9527
>>> tupwithlist1
(1, 2, 3, [4, 9527])
>>> tupwithlist2
(1, 2, 3, [4, 9527])
>>> tupwithlist3
(1, 2, 3, [4, 5])

再论元组

元组是immutable的,却有潜在的被更改的可能性

元组本身是不可变的,但是它包含的值却有可能被更改,特别是当元组hold住一个mutable的对象时,例如list

有了之前把变量名称当做一个对象的标签的论述,我们这里举起例子来就容易多了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> dee = ('1861-10-23', ['poetry', 'pretend-fight'])
>>> dum = ('1861-10-23', ['poetry', 'pretend-fight'])
>>> dum == dee
True
>>> dum is dee
False
>>> id(dum), id(dee)
(4313018120, 4312991048)

>>> t_doom = dum
>>> t_doom
('1861-10-23', ['poetry', 'pretend-fight'])
>>> t_doom == dum
True
>>> t_doom is dum
True

我们创建了2个tuple对象,dumt_doom是第一个对象的标签,dee是第二个对象的标签。

dum-t_doom-dee

现在我们为t_doom增加技能:

1
2
3
4
5
6
>>> skills = t_doom[1]
>>> skills.append('rap')
>>> t_doom
('1861-10-23', ['poetry', 'pretend-fight', 'rap'])
>>> dum
('1861-10-23', ['poetry', 'pretend-fight', 'rap'])

dumt_doom都获得了rap技能,原因是他们绑定的是同一个对象, t_doom[1]skills也绑定到了同一个list对象上面:

dum-skills-references

那么我们为什么说此时元组仍是不可变的呢?其实不可变值得是元组的物理内容,元组里包含的是什么?是对于各种对象的引用,dum[1]引用的list对象的改变了,但被引用的对象本身的id并没有变。所以,元组中的可变对象可能会有改动,但是可变对象本身却总保持不变。

参考文章:

  1. Everything Is an Object in Python — Learn to Use Functions as Objects
  2. Python: Everything is an Object, and Some Objects are Mutable
  3. Is Python call-by-value or call-by-reference? Neither.
  4. Python tuples: immutable but potentially changing
  5. Objects, values and types
  6. Programmer's Python - Variables, Objects and Attributes
  7. python变量跟C中变量的区别

平时阅读 C 语言的代码,少不了要在各种形式的 struct 中周旋,特此记录,以备查阅。

声明

struct{ ... } x, y, z;

此种方式指明了类型,并为其声明了变量,分配了存储空间。

但是这种方式没有对结构体类型命名,假如在程序的其它地方再次声明此种类型时会使程序膨胀极难维护。

因此,C 语言提供了两种方式来命名结构体类型:

  1. 结构标记
  2. typedef 定义

结构标记

1
2
3
4
5
struct part {
int number;
char *name;
int on_hand;
};

part 就是创建的标记,之后可以使用它来声明变量了:

1
struct part part1, part2;

part 不是类型名,因此 struct 关键字不能省略!

另外,结构标记的声明可以和结构体变量的声明合并在一起,比如:

1
2
3
4
5
struct part {
int number;
char *name;
int on_hand;
} part1, part2;

typedef 定义

1
2
3
4
5
typedef struct {
int number;
char *name;
int on_hand;
} Part;

类型 Part 的名字必须出现在定义的末尾,此后便可以像内置类型一样使用 Part 了:

1
Part part1, part2;

有两种使用 typedef 定义结构体类型的方法.

第一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>

struct Point{
int x;
int y;
};
typedef struct Point Point;
int main() {
Point p1;
p1.x = 1;
p1.y = 3;
printf("%d \n", p1.x);
printf("%d \n", p1.y);
return 0;
}

第二种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<stdio.h>

typedef struct Point{
int x;
int y;
} Point;
int main() {
Point p1;
p1.x = 1;
p1.y = 3;
printf("%d \n", p1.x);
printf("%d \n", p1.y);
return 0;
}

一些问题

为什么 C 语言会提供两种方式的类型命名呢?其实 C 语言早期并没有 typedef ,所以标记是结构类型命名唯一的方法。当加入 typedef 时已经太晚了,标记已经无法删除了。

虽然我们可以使用任意一种方式来命名结构体类型,甚至可以同时具有标记和typedef:

1
2
3
4
5
typedef struct part{
int number;
char *name;
int on_hand;
} Part;

甚至标记的名字和typedef的名字都可以一样,但通常并不这么做,因为这在早期的编译器中会出现问题。

但是,有时候我们只能使用结构体标记,那就是结构体成员有自引用的时候,一个典型的例子就是链表:

1
2
3
4
struct node {
int value;
struct node *next;
};

这种情况下,如果没有标记 node 就无法声明 next 的类型。

结构体初始化

声明时初始化

1
2
3
4
5
6
7
8
9
// 声明同时进行初始化
struct student_st{
char c;
int score;
const char *name;
} s1 = {'A', 89, "hello"};

// 使用标记声明并初始化
struct student_st s2 = {'A', 91, "Alan"};

指定初始化

C99 中允许指定初始化,将点号和成员名称组合起来称为指示符,指定初始化的优点是初始化值的顺序不需要和声明时一致。如果初始化中有没有指定的成员,那么这些成员将被设为0。

1
2
3
4
5
6
struct student_st s2 =
{
.name = "YunYun",
.c = 'B',
.score = 92,
};

结构体数组

1
2
3
4
5
6
7
8
9
10
11
12
struct student_st stus[2] =
{
{
.c = 'D',
.score = 94,
/*也可以只初始化部分成员*/
},
{
.c = 'D',
.score = 94,
.name = "Xxx"
},

复合字面量

C99 中可以使用复合字面量来创建没有名字的数组,这通常用于函数的参数传递,以避免先创建变量。

1
total = sum_array((int []){1, 2, 3, 4, 5});

复合字面量的格式为:先在一对圆括号内给定类型名,随后在一对花括号内设定所包含的元素的值。

同样,复合字面量也可以用于”实时“创建一个结构,而不需要将其存储在变量中。生成的结构也可以像参数一样传递,可以被函数返回,也可以赋值给变量。

1
2
3
4
5
// 看如下函数调用
print_part((struct part){528, "Disk drive", 100});

// 变量赋值
part1 = (struct part){528, "Disk drive", 100};

细细体会一下变量赋值的例句,它有些类似于含有初始化的声明,但是并不完全一样,初始化只能出现在声明中,不能出现在赋值语句中。

1
2
struct part1 = {528, "Disk drive", 100}; // ok
part1 = {528, "Disk drive", 100}; // error

最近在看一段源码的时候,发现了一个从未见过的 slice 的用法:

1
2
3
4
5
for batchSize < len(readValues) {
rawValues[address] = readValues[0:batchSize:batchSize]
address = address + 1
readValues = readValues[batchSize:]
}

其中readValues[0:batchSize:batchSize]为一个切片操作,但令人困惑的是其拥有3个索引值;这种书写形式在我读过的有限书籍中从未见过,官方的 [Specification](https://golang.org/ref/spec) 也没有找到相应的描述,所以打算将其弄个一清二楚。

经过一番 google 之后,golang slice, slicing a slice with slice[a:b:c]Re-slicing slices in Golang 两篇问答让我顺藤摸瓜找到了源头,它源自 Go1.2 中的新特性——Three-index slices

我们知道,一个 slice 由三个部分构成:指针、长度和容量:

  • 指针, 指向其第一个元素对应的底层数组元素的地址
  • 长度,对应的是slice中的元素个数,也就是通过下标对slice中的元素进行访问时,不得超过长度的大小,否则会panic
  • 容量,一般是从slice的第一个元素位置到底层数据的结尾位置。

slice[i:j:k] 第二个冒号之后的索引便是容量,默认情况下的容量是这个slice能hold住的最大元素个数,也就是上文提到的从slice的第一个元素位置到底层数据的结尾位置,即使这个slice再被截取,容量也是从起始位置到底层数组的结尾位置。

那什么叫hold住呢?长度以外容量以内的元素又不能通过下标访问,这能叫hold么?当然,长度以外容量以内意味着你可以append,我们试举一例:

1
2
3
4
5
6
var a = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
b := a[2:5]
fmt.Println(b[3]) // panic
b = append(b, 100)
fmt.Println(b[3])
fmt.Println(a) // [1 2 3 4 5 100 7 8 9 10]

可以看到切片b的长度为3,直接通过下标访问b[3]会报运行时恐慌,而它的默认容量是8,所以可以append新值进去,并且最终修改了底层数组的值,通过打印切片a就可以看到底层数组也被修改了,a与b共享底层数组。

接下来我们使用第三个索引来指定切片的容量和长度一样:

1
2
3
4
c := a[2:5:5]
c = append(c, 200)
fmt.Println(c) // [3 4 5 200]
fmt.Println(a) // [1 2 3 4 5 100 7 8 9 10]

可见,切片a的内容并没有变,因为c的容量有限,append操作引起了扩容,从而使得切片c的底层数组与切片a的底层数组分道扬镳。码农桃花源深度解密Go语言之Slice中对于数组扩容有更精彩的图文论述,其中也有data[low:high:max]的论述,只是当时阅读数未曾引起重视。

官网用于说明的例子是:

1
2
var array [10]int
slice = array[2:4:7]

一句话来表述容量被限制之后的结果: It is impossible to use this new slice value to access the last three elements of the original array.

这个特性偶尔会很有用,比如在处理底层的[]byte时,如果很清楚调用者不会去修改slice中的值,那么就可以使用这种方式来更好的保护底层的数组,正如我开篇提到的那段代码一样。

三个索引值,第一个可以省略,如果省略就代表 0.

大学时用 Turbo C 2.0 学 C 语言,只记得是蓝底黄字的丑陋界面,不清楚编译完的程序有何用处,也不知道编译和链接这些基本概念。现在想来只学到了 C 的语法,几年下来也忘得精光。本篇以 Linux 下 GCC 为例,简单描述编译链接的过程。

GCC 隐藏的细节

我们首先使用 GCC 来编译并运行一个经典的hello world程序:

1
2
3
4
5
6
7
#include <stdio.h>

int main(int argc, char **argv)
{
printf("hello world!\n");
return 0;
}

使用 GCC 来编译:

1
2
3
4
{14:59}~/Learing/c/src:master ✗ ➭ gcc hello.c 
{14:59}~/Learing/c/src:master ✗ ➭ ls
a.out hello.c
{14:59}~/Learing/c/src:master ✗ ➭

可以看到在不指定 -o选项的时候,默认生成了一个 a.out文件,这就是最后的可执行程序,我们来执行它:

1
2
{14:59}~/Learing/c/src:master ✗ ➭ ./a.out 
hello world!

程序向标准输出打印了 hello world!,我们的程序执行成功。但是 GCC 期间做了哪些工作?a.out 是一步生成的么?

事实上,这个默认过程至少经历了四个阶段,分别是预处理编译汇编链接,如下图所示:

GCC 编译分解过程

预编译

第一阶段的工作就是预处理,由预处理器完成,处理和源代码相关的头文件,生成一个 .i 后缀的中间文件。C 预处理器(C Pre-Processor)也常简写为 CPP,是一个与 C 编译器独立的小程序,预编译器并不理解 C 语言语法,它仅是在程序源文件被编译之前,实现文本替换的功能。可以使用 GCC 的 -E 选项来控制只进行预编译:

1
gcc -E hello.c -o hello.i

预编译阶段主要是处理源文件中以 #开头的预编译指令。比如 #include#define 等,主要的规则如下:

  • 将所有的 #define 删除,并展开所有的宏定义。
  • 处理所有的条件预编译指令,比如 #if#ifdef#elif#else#endif
  • 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其它文件。
  • 删除所有的注释 ///* */

编译

接下来就是整个程序构建最核心的阶段—编译,通过对预处理阶段产生的结果文件进行一些列的词法分析、语法分析、语义分析以及某些编译器优化之后生成汇编代码文件。编译的过程可以使用如下命令单独进行:

1
gcc -S hello.i -o hello.s

其实目前的 GCC 已经将预编译和编译两个阶段合而为一了,使用一个叫 ccl 的程序来完成这两个步骤,我的机器上这个程序在 /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/cc1 ,我们可以直接调用它来完成预编译和编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{16:12}~/Learing/c/src:master ✗ ➭ /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/cc1 hello.c
main
Analyzing compilation unit
Performing interprocedural optimizations
<*free_lang_data> <visibility> <build_ssa_passes> <opt_local_passes> <remove_symbols> <targetclone> <free-fnsummary>Streaming LTO
<whole-program> <fnsummary> <inline> <free-fnsummary> <single-use> <comdats>Assembling functions:
<materialize-all-clones> <simdclone> main
Time variable usr sys wall GGC
phase setup : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 33%) 1220 kB ( 70%)
phase parsing : 0.02 (100%) 0.00 ( 0%) 0.01 ( 33%) 459 kB ( 26%)
phase opt and generate : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 33%) 58 kB ( 3%)
preprocessing : 0.01 ( 50%) 0.00 ( 0%) 0.00 ( 0%) 136 kB ( 8%)
lexical analysis : 0.01 ( 50%) 0.00 ( 0%) 0.01 ( 33%) 0 kB ( 0%)
initialize rtl : 0.00 ( 0%) 0.00 ( 0%) 0.01 ( 33%) 12 kB ( 1%)
TOTAL : 0.02 0.00 0.03 1747 kB

或者,可以直接通过 gcc 和源文件来编译成汇编代码:

1
gcc -S hello.c -o hello.s

通过这些方式都可以得到 hello.s 的汇编文件。事实上 GCC 是个编译工具集,会根据不同的情况调用不同的工具处理每阶段的工作,比如编译时调用 ccl,汇编时调用 as,链接时调用 ld

汇编

正如前文所言,当编译完成得到汇编文件之后,接下来的工作就交给汇编器来执行了,汇编器是将汇编代码转变成机器指令的工具,每一条汇编语句几乎都对应一条机器指令。所以汇编器的活儿相对来说比较简单,只是把汇编指令跟机器指令对照翻译一下,当然翻译完文件就由可读的汇编代码变为只有机器才可以看懂的二进制文件了。对于上面得汇编文件我们可以使用 as 来完成汇编:

1
as hello.s -o hello.o

或者使用 GCC 的 -c 选项,它的意思是编译或者汇编源文件,但不进行链接:

1
gcc -c hello.s -o hello.o

或者直接从 C 文件到目标文件(Object File 的概念非常重要,但此处不展开,留待以后单独讨论):

1
gcc -c hello.c -o hello.o

链接

到这里,我们距离生成最后的可执行文件只有一步之遥,让我们来调用 ld 来生成最后的可执行文件:

1
/usr/bin/ld -static /usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/crtbeginT.o -L /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0 -L /usr/lib -L /lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-pc-linux-gnu/9.3.0/crtend.o /usr/lib/crtn.o -o hello

执行 hello 程序:

1
2
{17:00}~/Learing/c/src:master ✗ ➭ ./hello 
hello world!

程序成功执行并输出了 hello world!,但是我们可以看到上面的 ld 命令链接了一大堆的文件才最后生成 hello 可执行文件。

初学者很容易产生的疑问就是:汇编完成的之后的文件不就是二进制文件么?为什么还要进行链接这个步骤呢?链接到底干了些啥?为什么不直接生成最后的可执行文件呢?要说清楚这些问题并不是一件很容易的事,可以说是一件异常困难的事,这里面涉及到静态链接、动态链接、静态库、动态库、运行时库、标准库、链接器等一系列的问题,以至于《程序员的自我修养》用了整整一部书来讲链接这件事情,所以囫囵吞枣看个大概的想法,也是何其难哉!

但这正是我想写这一系列文章的初衷,我希望我能将这些概念总结出来,略去一些细节,只保留轮廓,便于记忆,同时也给初学者以借鉴。

本篇文章先简单描述编译的过程,下一篇文章我们谈静态链接。

C 语言有很多种运算符(差不多50种),如果一条表达式中有多个运算符,就不可避免的产生二义性.C 语言通过优先级与结合性来解决这个问题,因运算符过多,优先级难以全部准确记忆,故将运算符优先级表记录在此,以备日后有疑问时进行查阅.

C 运算符优先级

运算符的种类

常用的运算符有如下几类:

  1. 算数运算符(+ - * / % ++ --
  2. 关系运算符(== != > < >= <=
  3. 逻辑运算符(&& || !
  4. 位运算符(& | ^
  5. 赋值运算符(= += -+ *= /= %= <<= >>= &= ^= |=
  6. 杂项运算符(sizeof() & * ?:

算数运算符

+-既可以是一元运算符正负号,又可以是二元运算符加减.一元运算符优先级要高于二元运算符.

1
-i + j / k; // 等价于 (-i) + ( j / k)

当表达式中包含两个或者更多个相同优先级的运算符时,就需要根据结合性的规则来运算.

如果运算符是从左向右结合的,就称为左结合,如果是从右像左结合的就称为右结合.二元算数运算符都是左结合的.

1
2
i - j - k; // 等价于 (i - j) - k
i * j / k; // 等价于 (i * j) / k

一元算数运算符都是右结合的.

1
- + i; // 等价于 -(+i)

赋值运算符

在许多编程语言中,赋值是语句.然而,在C语言中,赋值就像 + 一样是运算符.换句话说,赋值操作产生结果,就如同两数相加产生结果一样.赋值表达式 v=e 的值就是赋值运算后 v 的值.

既然赋值是运算符,那么多个赋值就可以串联在一起:

1
i = j = k = 0;

运算符 = 是右结合的,所以上述表达式等价于:

1
i = (j = (k = 0));

有一个例子来说明赋值表达式:

1
2
3
4
5
6
7
8
9
char *strcat (char *s1, const char *s2)
{
char *p = s1;
while (*p)
p++;
while (*p++ = *s2++)
;
return s1;
}

这是个字符串拼接函数,函数中的两个 while 循环的条件表达式都是赋值表达式,因此除了空字符以外所有的字符都为真,当指针到达字符串末尾的 '\0' 时,表达式的值为 0.

自增运算符和自减运算符

自增自减运算符略微有些复杂,那是因为它们既可以作为前缀又可以作为后缀,而且它们还有个副作用就是:会改变操作数的值.

因此,使用自增自减运算符时,我们要谨记两点:

  1. 表达式的值
  2. 操作数的值
1
2
3
4
5
6
7
8
int i = 1;
printf("i is %d\n", i++); // prints "i is 1"
printf("i is %d\n", i); // prints "i is 2"

int i = 1;
printf("i is %d\n", ++i); // prints "i is 2"
printf("i is %d\n", i); // prints "i is 2"

i++ 先赋值再自增,++i 先自增再赋值.

需要注意的是,后缀运算符要比前缀运算符优先级要高,从文章开头的表格中可以看到,后缀自增自减优先级为 1,前缀自增自减优先级为 2.

试想一下,对于 *p++; 语句,自增的是指针还是指针指向的值呢?

很多地方包括 K&R 都是这样解释:尽管 * 和 ++ 的优先级相同,但由于连接的规则是从右往左,所以 p 先和 ++ 进行连接.因此,被自增的不是 *p 而是 p.

对照开头的优先级表,后缀 ++ 优先级要比间接寻址运算符 * 高,因此被进行加法的不是 *p 而是 p.

从语法上看,这种解释才比较合理.

表达式语句

C 语言有一条不同寻常的规则,那就是任何表达式都可以用作语句.换句话说,不论表达式是什么类型,计算什么结果,我们都可以通过在后面添加分号将其转化为语句.例如,可以把表达式 ++i 转换为语句

1
i++;

i 先自增再赋值,但是值会被丢弃,接着执行下一条语句.

一个优先级引起的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void str_cli(FILE *fp, int sockfd)
{
ssize_t n;
char sendline[MAXLINE];
char recieve[MAXLINE];

while (fgets(sendline, MAXLINE, fp) != NULL)
{
// 消除字符串中的换行符
int i = strlen(sendline);
if (sendline[i - 1] == '\n')
{
sendline[i - 1] = 0;
}

Writen(sockfd, sendline, strlen(sendline));

if ((n = read(sockfd, recieve, MAXLINE) == 0))
err_quit("str_cli: server terminated prematurely");

recieve[n] = 0;
Fputs(recieve, stdout);
Fputs("\n", stdout);
}
}

这是我编写的一个 socket 客户端的代码,我在读取 socket 描述符中的内容时犯了错误,在 if 条件中的括号放错了位置,这导致我原本想要进行:

1
if ((n = read(sockfd, recieve, MAXLINE)) == 0)

变成了:

1
if ((n = read(sockfd, recieve, MAXLINE) == 0))

也就是条件变成了 n = read() == 0,对照优先级表,关系运算符 == 要比赋值运算符 = 优先级高,所以这里先进行 read 读取,再与0进行判等,当有正常内容被读出的时候,结果为假,之后赋值给 n,这里 n 会变成 0,进而造成 recieve 被置空.

今天这篇文章我准备说一说 TIME_WAIT ,我相信很多人在工作中都或多或少遇到 TIME_WAIT 的情况,而且在面试中也经常被问到 遇到大量的 TIME_WAIT 时应该怎么办?这样的问题。 因为对 TIME_WAIT 这个状态印象并不是很深,所以被问到类似问题的时候也经常说不出所以然,因此,我花了很长的时间深入研究了一下 TCP 的相关问题,然后在此记录一下。

先放一个 TCP Header 在这里。

TCP 头格式

什么是 TIME_WAIT

TIME_WAIT 是 TCP 状态机中的一个,它出现在连接正常断开的时候。

Figure 1. The TCP state transition diagram

从状态转换图中可以看出,TIME_WAIT 是断开连接时的最后一个状态,其上有个计时器表示连接在 TIME_WAIT 这个状态停留的时长为 2MSL(Maximum Segment Lifetime),意为 2 个最大报文生存时间( RFC793 定义了MSL为2分钟,然而在常见的实现中有 30s,1 分钟,2 分钟的情况;其中 Linux 设置成了 30s,并且是硬编码无法更改,除非重新编译内核;Windows 下默认是 1 分钟,且可以修改)。

大部分人对 TIME_WAIT 的第一印象并不好,但是 Richard Stevens 在他的 《UNIX网络编程》一书中却说道:TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它。

这也正是我所希望的,让我们从 TCP 的四次挥手说起。

Figure 2. TCP states corresponding to normal connection establishment and termination

从上图我们可以看出 TCP 四次挥手的过程:

  1. 客户端调用 close(),协议层发送 FIN 报文表示主动断开连接,而后进入 FIN_WAIT_1 状态。
  2. 服务端收到客户端发送的 FIN ,返回一个 ACK 通知对端:我已知晓,并进入 CLOSE_WAIT 状态。
  3. 客户端收到 ACK 后进入 FIN_WAIT_2 状态,等待服务端应用程序调用 close()操作。
  4. 服务端处理完数据之后调用 close(),协议层发送 FIN 报文给客户端,等待 ACK,然后进入 LAST_ACK 状态。
  5. 客户端收到 FIN 报文之后发送 ACK,并进入 TIME_WAIT 状态。
  6. 服务端收到 ACK 之后进入 CLOSED 状态。
  7. 客户端等待 2*MSL 时间后进入 CLOSED 状态。

通过上面的步骤我们可以得出以下几点:

  1. 只有主动关闭的一方才会进入 TIME_WAIT,这既可以发生在客户端,也可以发生在服务端。
  2. TIME_WAIT 会持续 2*MSL 的时间,之后进入CLOSED 状态。
  3. 在连接没有进入 CLOSED 之前是无法被重用的。

几个概念

在继续深入讨论 TIME_WAIT 之前,我们先来明确几个术语概念:

  • TTL (Time-to-Live),意为生存期,它是 IP 头部的一个字段,用于设置一个数据报可经过的路由器的数量上限。发送方将它初始化为某个值(RFC 建议设为 64,但是 128 或 255 也不少见),每个路由器在转发数据报时将该值减 1。当这个字段达到 0 时,该数据报被丢弃,用于防止路由环路导致数据报在网络中永远循环。有意思的是最初 TTL 这个字段意思是指定 IP 数据报在网络上的最大生存秒数,但路由器总需要将这个值至少减 1。实际上,路由器在正常操作下通常不会持有数据报超过 1 秒钟,因此较早的规则早已被遗忘,这个字段在 IPv6 中根据实际用途已被重命名为跳数限制。
  • MSL(Maximum Segment Lifetime),意为最大报文生存时间,是 TCP 协议中的概念,指的是一个 TCP 的 Segment 在网络上生存的最大时间。很多书籍中都提到 MSL 是由 IP 层的 TTL 来保证的,但 TTL 和 MSL 不绝对相等,一般 MSL 会大于 TTL。我观 RFC793 中使用了 assume 一词(Since we assume that segments will stay in the network no more than the Maximum Segment Lifetime),所以我理解 TCP 并不会保证一个报文的最大生存时间,只是将 MSL 作为前提假设进行协议上的设计。
  • SN(Sequence Number)序列号是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。SN 是一个 32 位的无符号数,因此在到达 $2^{32} -1$ 之后再循环回到 0。
  • ISN(Initial Sequence Number)初始序列号 是一个连接建立之前双方商议的初始的序列号,也就是传输的第一个字节的编号(通常是 SYN 位,因为 SYN 消耗一个序列号,相对的 ACK 不会消耗序列号,毕竟 SYN 和 FIN 是要保证可靠传输的)。很显然这个初始序列号不能是 0 或者 1,它必须是一个随机的数,并会随时间改变,这样每一个连接都拥有不同的初始序列号RFC793 指出初始化序列号可被视为一个32位的计数器,该计数器的数值每 4 微秒加 1,这样循环一次需要 4.55 小时。此举的目的在于为一个连接的报文段安排序列号,以防止出现与其它连接的序列号重叠的情况,尤其是对于同一个连接的两个不同实例(也叫不同的化身)而言,新的序列号也不能出现重叠的情况。

解释一下所谓的同一个连接的不同实例,我们知道标识一个连接需要 2 个 IP 和 2 个端口号,称为四元组。如果再加上协议类型就称为五元组,因为我们只讨论 TCP,所以也可以直接叫四元组。所谓的同一个连接的不同实例是指一个连接关闭后,又以相同的四元组打开了一个新的连接,通常称老的连接为前一个化身。

试想一下,一个连接因为某种原因被关闭,紧接着又以相同的四元组被重新建立。如果这时旧连接的一个延时的报文又到达了,碰巧这个延时的报文段的序列号对新连接又是有效的,那么这个报文就会被视为有效的数据进入新连接的数据流中。TCP 的很多设计目标都是为了避免这种恼人的情况,更为复杂的是随着网路性能的提升,带宽的增加,这种问题甚至出现在同一个连接实例当中,因为 SN 这个 32 位的字段也是要回绕重用的,如果一个回绕的时间太短,小于一个 MSL 的时候,我们也要面对延时报文的问题。

TIME_WAIT 的设计初衷就是为了解决 TCP 新旧连接延迟报文的。

TIME_WAIT 的目的

RFC1185RFC1323RFC7323 中阐明了 TIME_WAIT 存在的两个目的:

  1. 可靠的实现 TCP 全双工连接的终止。
  2. 允许老的重复的报文段在网络中消失。

第一个理由可以通过查看 Figure 2 四次挥手过程,并假设最终的 ACK 丢失来解释。这时服务端将重新发送它的最后的那个 FIN,因此客户端必须维护状态信息,以允许它重新发送最终的 ACK。要是客户端不维护状态信息,其所在的服务器将响应一个 RST,服务端将收到这个 RST 并将其解释为一个错误,这对于一个可靠的协议来说是不完美的终止方式。为了防止这种情况出现,客户端必须等待足够长的时间确保对端收到 ACK,如果对端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。但是你可能会说重新发送的 ACK 还是有可能丢失啊,没错,但 TCP 已经等待了那么长的时间了,已经算仁至义尽了。

为了理解第二个理由必须要很好的理解 Squence NumberISN,也就是序列号初始序列号,我们再次强调:TCP 是面向字节流的协议。 为了解决可靠传输和乱序问题,TCP 为每一个传输的字节都编上了号,TCP 头部的 squence number 表示的是该报文中携带数据的第一个字节的编号,下图可以形象的说明这一点:

Figure 3. 文件数据划分成 TCP 报文段

图中例子假设主机 A 上的一个进程想通过一条 TCP 连接向主机 B 上的一个进程发送一个数据流,这个数据流可能是一个文件。主机 A 中的 TCP 将隐式的对数据流中的每一个字节编号。假定该字节流是一个由 500000 字节的文件组成,其 MSS 为 1000 字节,数据流的首字节编号为 0。如图所示,TCP 将构建 500 个报文段,给第一个报文段分配序号 0,第二个报文段分配序号 1000,第三个报文段分配序号 2000,以此类推。每一个序号被填入到相应的 TCP 报文段头部的序列号字段中。

这个例子中我们假定数据流的首字节编号从 0 开始是不准确的,因为 SYN 消耗一个序列号,所以第一个字节总是 ISN 加上 1。前面讲到 ISN 是一个基于时间的计数器,它是一个随机值,每 4.55 小时回绕一次。相似地,Sequence Number 也是一个 32 位的循环使用字段。也就是说,大约每传送 4GB 的数据之后,序列号就会回到最初的值(也就是 ISN,因为当达到最大值时会再从 0 开始一直加到 ISN),如此循环往复。

有了上面的基本概念,我们现在就可以假设如下情况了。

一个由四元组标识的唯一连接,因某种原因被关闭。但随即以相同的四元组重新打开。那么这是同一个连接的新化身(incarnation),这个时候 TCP 必须要防止前一个连接的老的报文段因为网络延迟而重新到达,以致被误解为新连接的数据。如 Figure 4 所示:

Figure 4. Due to a shortened TIME-WAIT state, a delayed TCP segment has been accepted in an unrelated connection.

图中该连接的前一个化身序列号为 3 的报文段超时重传了,之后连接关闭。假设没有经过 TIME_WAIT,或者 TIME_WAIT 的时间很短,在新的连接化身刚刚发送完序列号为 2 的报文段之后,前一个化身迷途的 seq=3 的报文段又抵达了。TCP 必须防止这种情况。

为了做到这一点,TCP 设计了 TIME_WAIT 。它会锁定这个四元组 2*MSL 的时间 ,这期间不允许以相同的四元组打开新连接,我们知道 MSL 是 TCP 报文段最大的网络生存时间,那么 2*MSL 足以让这个连接上的所有延迟的报文被丢弃,通过 TIME_WAIT 这个规则,我们就能保证每成功建立一个 TCP 连接时,来自该连接先前化身的老的重复的报文都已在网络中消逝了。

前一个连接的老的报文给当前的连接造成错乱的前提是其携带的序列号刚好在当前的连接有效,虽然这不太可能发生,但是因为 ISN 和 SN 两个字段值都是会 wrap around(回绕) 的,所以理论上是会出现这种情况的,RFC1185 中有针对该问题详细的讨论描述,我们借用里面的图来说明这种情况。

为了应对老的重复报文,Tomlinson 提出了基于时间驱动的 ISN 方案,但这个方案只对短连接和传输速率慢的连接有效,如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    |- 2**32       ISN             ISN
| * *
| * *
| * *
| *x *
| o *
^ | * *
| | * x *
| * o *
S | *o *
e | o *
q | * *
| * *
# | * x *
| *o *
|o_______________*____________
^ Time -->
4.55hrs


Figure 5. Clock-Driven ISN avoiding duplication on
short-Lived, slow connections.

横轴为时间,纵轴为序列号或初始序列号(它们本质上是同一个东西,ISN 只是以某种方式选择的序列号初始值),* 代表 ISN,o 代表同一个短时连接的不同化身的轨迹,其序列号在 x 处终止。因为,所以每个化身序列号的增长曲线是低于 ISN 的增长曲线的。也就是说,此时没有 TIME_WAIT 也一样能排除老的重复报文段,因为每个化身的 ISN 均会保证大于前一个化身最后使用的序列号。只要一个报文段的序列号小于当前连接的初始序列号,TCP 自动就可以识别出来从而将其丢弃。但是,对于长连接或者一个快速的网络环境中,仅仅是 ISN 就不能保证这一点了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    |- 2**32       ISN               ISN
| * *
| * *
| * *
| * *
| * *
^ | * *
| | * *
| * *
S | * *
e | * x*
q | * o *
| * o *
# | *o *
| * *
|*_________________*____________
^ Time -->
4.55hrs

Figure 6. Slow, Long-Lived Connection

如图 Figure 6 ,一个长连接且慢速传输的情况,如果 ISN 回绕到与 seq 相同的值时,连接关闭又随即重新打开,那么正在传输中的老的连接的报文段就很可能会进入新连接的窗口中。

在一个高性能的快速的网络环境中,问题更显复杂。因为这时有两种情况使得老的报文段可能造成问题,先看第一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    |- 2**32       ISN               ISN
| * *
| x o * *
| * *
| o-->o* *
| * *
^ | o o *
| | * *
| o * *
S | * *
e | o * *
q | * *
| o* *
# | * *
| o *
|*_________________*____________
^ Time -->
4.55hrs

Figure 7. Duplication on Fast Connection: Nc < 2**32 bytes

如图 Figure 7 所示是连接传输的字节小于 4GB 的情况,并在序列号 x 处连接关闭或者崩溃,紧接着一个新的化身被建立,而此时的 ISN 尚远远小于 x 的值。这样前一个化身的老的重复的报文段很轻易的就侵入到当前的连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    |- 2**32       ISN               ISN
| o * *
| x * *
| * *
| o * *
| o *
^ | * *
| | o * *
| * o *
S | * *
e | o * *
q | * o *
| * *
# | o *
| * o *
|*_________________*____________
^ Time -->
4.55hrs
Figure 8. Duplication on Fast Connection: Nc > 2**32 bytes

Figure 8 所示是连接传输的字节大于 4GB 的情况,且序列号已经回绕(wrap around),原本已经分离的曲线,在序列号回绕到 x 处时又相交了。一旦窗口有重合,老的延迟报文段就会造成问题。

通过上面的图例,我们能得出一个结论:只要新连接的 ISN 大于前一个化身的最终序列号,那么问题便会迎刃而解。果不其然,Garlick, Rom, and Postel 在 RFC1185 中提出了这样的想法:由 TCP 维护每一个连接的最终序列号,当新的连接被打开的时候,保证 ISN 的值永远大于上一个化身的最终使用序列号

但是,貌似这个方案没有被最终采用,TCP 选择了 ISN 以及 TIME_WAIT

TIME_WAIT 造成的影响

TIME_WAIT 主要造成的影响有两个方面:

  1. TIME_WAIT 会长时间占用(2*MSL)一个四元组连接,这可能导致后续相同元组的连接创建失败。
  2. 2*MSL 期间内核需要维持该 SOCKET 的数据结构,因此数量过多的话会消耗内存、并且增加内核遍历有关 hash table 的时间(更消耗CPU),从而导致性能问题。

因为处于 TIME_WAIT 等待状态的连接可能要花费 1 ~ 4 分钟才能进入 CLOSED 的状态并释放相应的四元组,所以在 TIME_WAIT 等待期间具有相同四元组的连接便不能建立。这是很多人对 TIME_WAIT 深恶而痛绝之的根源。

资源不能重用算是一个痛恨的理由,但第二个理由却值得细细推敲一番。多少 TIME_WAIT 算多呢?一千个?一万个?其实这个量级根本不用在乎,TIME_WAIT 所占用的内存很少,主要涉及到两个内核结构:

  1. 内核里有保存所有连接的一个 hash table,这个 hash table 里面既包含 TIME_WAIT 状态的连接,也包含其他状态的连接。主要用于有新的数据到来的时候,从这个 hash table 里快速找到这条连接。不同的内核对这个 hash table 的大小设置不同,你可以通过 dmesg 命令去找到你的内核设置的大小:

    1
    2
    [root@VM_0_6_centos ~]# dmesg |grep "TCP established hash table"
    [ 0.204805] TCP established hash table entries: 16384 (order: 5, 131072 bytes)
  2. 还有一个 hash table 用来保存所有的 bound ports,主要用于可以快速的找到一个可用的端口或者随机端口:

    1
    2
    [root@VM_0_6_centos ~]# dmesg |grep "TCP bind hash table"
    [ 0.207227] TCP bind hash table entries: 16384 (order: 6, 262144 bytes)

由于内核要保存这些数据,所以会占用一部分内存。那么会消耗 CPU 么?

当然!每次找到一个随机端口,还是需要遍历一遍 bound ports 的吧,这必然需要一些 CPU 时间。

TIME_WAIT 很多,既占内存又消耗 CPU,这也是为什么很多人,看到 TIME_WAIT 很多,就蠢蠢欲动的想去干掉他们。其实,如果你再进一步去研究,1 万条 TIME_WAIT 的连接,也就多消耗1M左右的内存,对现代的很多服务器,已经不算什么了。至于 CPU,能减少它当然更好,但是不至于因为 1 万多个 hash item 就担忧。

各种操作系统实现也提供了绕过 TIME_WAIT 的方法,如果你去 google 解决方法,网上十有八九会让你设置 net.ipv4.tcp_tw_reusenet.ipv4.tcp_tw_recycle 这两个参数。如果你不深入了解 TIME_WAIT 的机制以及这两个参数的内在原理而贸然使用,那么结果可能会更加糟糕。

与 TIME_WAIT 有关的几个参数

  • net.ipv4.tcp_timestamps
  • net.ipv4.tcp_tw_reuse
  • net.ipv4.tcp_tw_recycle

需要郑重说明的一点:net.ipv4.tcp_tw_reusenet.ipv4.tcp_tw_recycle 这两个参数全部依赖于 net.ipv4.tcp_timestamps

RFC1323 引入了 timestamp 时间戳选项,主要为了精确计算 RTT(ROUND-TRIP TIME) 。时间戳的另一个功用是为了防止序列号回绕,我们先来介绍这个功能。

PAWS

PAWS(PROTECT AGAINST WRAPPED SEQUENCE NUMBERS)是防序列号回绕的意思。

经过前面的介绍,我们对序列号回绕(wrap around)已经有了清晰的认识。但之前我们都是在讨论新旧连接之间怎么防止延迟的报文段,随着网络性能的提升,同一个连接内也面临着延迟报文的问题。

我们知道序列号是一个 32 位的无符号整型,用它来编码,我们最多编码 4GB 的数据,超过 4GB 后就需要将序列号回绕进行重用。这在以前网速慢的年代不会造成什么问题,但在一个速度足够快的网络中传输大量数据时,序列号的回绕时间就会变短。当 wrap around time 小于 MSL 的时候会发生什么呢?TCP 一切的设计都基于假设报文段最大的生存时间不会超过 MSL 的,如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文段抵达后序列号依然有效的问题。这就让 TCP 协议难以自圆其说,试看下面的示例:

Figure 9. TCP 通过时间戳选项消除相同序列号报文段的二义性

我们假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送者会针对每个发送窗口分配时间戳数值,我们假设每个窗口时间加 1,然后使用这个连接传输一个 6GB 大小的数据流。

32 位的序列号字段在时刻 D 和 E 之间回绕。假设在时刻B有一个报文段丢失并被重传,又假设这个报文段在网络上绕了远路并在时刻 F 重新出现。而且这段时间小于 MSL ,也就是说回绕时间很短,比 MSL 还要小,之前的报文段还没有在网络中消逝,并且还及时绕了回来。

如果 TCP 无法识别这个“乔装”的报文段,那么数据完整性就会遭到破坏。使用时间戳选项能够有效的防止上述问题。接收者可以将时间戳看作一个 32 位的扩展序列号。丢失的报文段会在时刻 F 重新出现,由于它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法会将其丢弃。

需要注意的是,防回绕序列号算法并不要求在发送者与接受者之间有任何形式的时钟同步,接受者所需要的是保证时间戳数值单调增长。

net.ipv4.tcp_tw_reuse

我们先看看这个参数的定义:

1
2
3
4
5
6
7
8
9
tcp_tw_reuse - INTEGER
Enable reuse of TIME-WAIT sockets for new connections when it is
safe from protocol viewpoint.
0 - disable
1 - global enable
2 - enable for loopback traffic only
It should not be changed without advice/request of technical
experts.
Default: 2

其含义是对处于 TIME_WAIT 的 sockets 启动重用功能,使得新建连接时可以重用 TIME_WAIT 状态的四元组。定义中声称这是协议安全的,默认只对回环网卡启用。

这里面有一句很鲜明的话:It should not be changed without advice/request of technical

意思是如果没有专业指导就请不要修改它! 我们回顾一下 TCP 的四次挥手,只有主动断开连接的一方才会进入 TIME_WAIT 状态,并且只有重新发起连接的时候才会重用具有相同四元组的处于 TIME_WAIT 状态的 socket。这也就是说: tcp_tw_reuse 这个参数只对主动发起连接的客户端才奏效,只适用于 outbound 连接!

那么使用 tcp_tw_reuse 跳过了 TIME_WAIT 阶段后,如何防止旧连接的延迟报文段呢?这就要依靠timestamp 时间戳选项提供的功能了。当新连接建立后,时间戳更新为最新的时间,当延迟的报文段到达后,其时间戳是小于当前连接的最近有效时间戳的。这个时候 TCP 就可以将这个报文段直接丢弃了。

再次强调,这个参数只适用于发起连接的一方,只影响出站连接,也就是作为客户端的情况。

net.ipv4.tcp_tw_recycle

这个参数在 Linux 内核 4.12 中已经被废弃,但这里还是值得拿出来说一说

RFC1323 中有下面一段话:

1
2
3
4
5
6
7
8
9
An additional mechanism could be added to the TCP, a per-host
cache of the last timestamp received from any connection.
This value could then be used in the PAWS mechanism to reject
old duplicate segments from earlier incarnations of the
connection, if the timestamp clock can be guaranteed to have
ticked at least once since the old connection was open. This
would require that the TIME-WAIT delay plus the RTT together
must be at least one tick of the sender's timestamp clock.
Such an extension is not part of the proposal of this RFC.

意思是有了时间戳选项,我们可以在 TCP 中用 cache 针对每个 host 记录任何连接最后的接收时间戳。这个值可以像 PAWS 机制那样工作,以防止前一个连接的延迟报文段,也就是用来解决 TIME_WAIT 要解决的问题。我们可以看出来这就是 Garlick, Rom, and PostelRFC1185 中提出记录最终序列号方案的一种变体。Linux 将其实现了,这就是 net.ipv4.tcp_tw_recycle 参数(网上都这么说,但我没有查到官方的说法)。

官方定义是:Enable fast recycling TIME-WAIT sockets. Default value is 0. It should not be changed without advice/request of technical experts.

这个配置同样依赖于双方对 timestamps 的支持,当开启了这个配置后,内核会快速的回收处于 TIME_WAIT 状态的socket 连接。多快?不再是 2*MSL,而是一个RTO(retransmission timeout,数据包重传的timeout时间)的时间,这个时间根据 RTT 动态计算出来,但是远小于2*MSL

它对出站连接和入站连接均有影响,但主要是影响入站连接的重用。所以,之前网上的文章大部分都是使用它来解决服务端 TIME_WAIT 的情况,也就是服务端主动断开了连接。

net.ipv4.tcp_tw_recycle 这个选项特别激进。简单来说就是,Linux 会丢弃所有来自远端的 timestramp 时间戳小于上次记录的时间戳(由同一个远端发送的)的任何数据包。也就是说要使用该选项,则必须保证数据包的时间戳是单调递增的。

但是,如果对端是一个 NAT 网络的话(如:一个公司只用一个 IP 出公网)或是对端的 IP 被另一台重用了,这个事就复杂了。建连接的 SYN 可能就被直接丢掉了(你可能会看到 connection time out 的错误)。所以 NAT 就是 net.ipv4.tcp_tw_recycle 的死穴。

其实我认为理论上在正常的 NAT 网络中也会出现此问题。假设 NAT 网络中一个客户端主动关闭了连接,在收到服务端的 FIN 报文后向对端发送了最后的 ACK,之后进入了 TIME_WAIT 状态。这个报文在 RTT/2 时间到达了服务端,服务端进入了 CLOSED 状态,此时 NAT 网络中的另一个客户机发起了连接,注意原客户机此时仍在 TIME_WAIT 状态,那么服务端的四元组已经可以重用了,但是如果此时发起新连接的客户机携带的时间戳没有保证递增,那么还是会出现 SYN 被丢弃的情况。(之所以有此疑问,是因为我不清楚中间路由是否会记录连接信息,如果中间路由能阻止连接的发起那么就不会出现这种情况,希望懂的朋友给以指正。)

更新:RFC5382 中针对 RSTTIME_WAIT 情况下的 NAT 行为并未给予明确规定,仅仅阐述了一下利弊。所以由实现者自行处理:

1
2
3
4
5
6
7
NAT behavior for handling RST packets, or connections in TIME_WAIT
state is left unspecified. A NAT MAY hold state for a connection in
TIME_WAIT state to accommodate retransmissions of the last ACK.
However, since the TIME_WAIT state is commonly encountered by
internal endpoints properly closing the TCP connection, holding state
for a closed connection may limit the throughput of connections
through a NAT with limited resources.

RFC7857 中又对 RST 的情况做了修正,建议在删除连接信息之前保存 4 分钟的时间:

1
2
3
4
5
Concretely, when the NAT receives a TCP RST matching
an existing mapping, it MUST translate the packet according to the
NAT mapping entry. Moreover, the NAT SHOULD wait for 4 minutes
before deleting the session and removing any state associated with
it if no packets are received during that 4-minute timeout.

我并不清楚主流的路由器是如何处理这两种情况的,而且要研究其究竟超越了我目前的能力范围。因此,鉴于 RFC 中的相关描述,我暂时先从协议角度理解为上述担心的情况不会发生。

但有一点我不是很明白,为什么 Linux 可以实现 tcp_tw_recycle,却不去实现记录前一个化身的最终使用序列号?这样完全可以避免 NAT 网络不同客户端时间戳无法保证单调递增的问题。不管这个连接是从 NAT 网络中哪台客户机发起的,始终为新连接选择一个大于前一个化身最终序列号的 ISN 就不会有任何问题。

其它参数

Linux 有个内核参数 tcp_max_tw_buckets控制并发的 TIME_WAIT 的数量,在我的 Linux 上的默认值是 262144,如果超限,那么,系统会把多的给 destory 掉,然后在日志里打一个警告(如:time wait bucket table overflow),这相当于跳过了 TIME_WAIT 直接将连接销毁。如果对端(被动关闭方)没有收到最后的 ACK,那么会重发 FIN 报文,因为主动端服务器已没有相关的连接信息,服务器收到 FIN 后会回一个 RST 报文将连接重置,到这里还没有什么大问题,只是 TCP 连接没有优雅的关闭而已。

问题在于这个连接跳过了 TIME_WAIT 阶段,如果一个相同四元组的连接随即被创建,而此时恰好前一个连接的迷途的报文又到达了,这会让当前的连接发生数据错乱。

这个值该设置为多少还是有待考量的。

总结

围绕着 TIME_WAIT 的概念已经说的很多了,但其实仍然难以回答大家最关心的问题:如何去避免 TIME_WAIT ?

其实我翻译的 TIME_WAIT及其对协议和可伸缩客户端服务器系统的设计实现 这篇文章中已经给出了完美的答案,我再简单总结一下:

  1. 永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。
  2. 如果你非要在服务端断开连接,且你的服务不会有出站连接,那么服务器除了承受维护 TIME_WAIT 状态所带来的性能影响和资源占用之外,不需要对其有过多的担心。
  3. 如果你的服务端既要建立出站连接又要建立入站连接,黄金法则依然是:如果 TIME_WAIT 注定要发生,那么就让它发生在对端。如果对端超时了,直接使用 RST 终止连接。但你服务端也要避免发起频繁的短时连接,尽量使用长连接或者连接池。
  4. 如果你是客户端,尽量使用长连接和连接池,并发扬风格主动断开连接。
  5. 如果你非要写那种快速打开关闭,分分钟干出一堆 TIME_WAIT 的客户端,或许你可以设计一个应用层的关闭机制,你在客户端发送“我干完了”,服务端接收之后返回一个“再见”,之后客户端便可以发送 RST 终止连接。注意,这虽然解决了 TIME_WAIT 和数据完整性问题,但是延时报文仍然可能成为问题,而 timestamps 选项无法完全解决不同“化身”之间的报文延时,比如 NAT 网络。如果你使用这种方式与服务端断开连接,那么在你的 NAT 网络中恰好另一台机器使用相同的客户端程序连接同一个服务端的时候就有可能遭遇时间戳无法保证单调递增的情况。
  6. 作为客户端请合理的设置 net.ipv4.ip_local_port_range

参考文章:

  1. RFC-0793
  2. RFC-1185
  3. RFC-1323
  4. RFC-7323
  5. TCP/IP详解 卷1:协议
  6. UNIX网络编程
  7. 计算机网络自顶向下方法
  8. Coping with the TCP TIME-WAIT state on busy Linux servers
  9. TIME_WAIT and its design implications for protocols and scalable client server systems
  10. TIME_WAIT and “port reuse”
  11. TCP 的那些事儿(上)
  12. TCP 的那些事儿(下)
  13. 你所不知道的TIME_WAIT和CLOSE_WAIT
  14. 那些与TIME_WAIT有关的参数
  15. 深入理解TCP的TIME-WAIT
  16. TCP timestamp
  17. 一个NAT问题引起的思考
  18. 被抛弃的tcp_recycle

看了左耳朵耗子推荐的这篇 TIME_WAIT and its design implications for protocols and scalable client server systems 讲 TIME_WAIT 的文章,感觉比较清晰,就翻译出来,在这里记录了一下,其实其中内容在《TCP/IP详解》和《UNIX网络编程》中有更详细的讲解。但是文章仍然值得一读,它从程序设计角度描述了应该如何避免 TIME_WAIT 的困扰,但对 TCP 本身着墨不是很多,比如要理解 TIME_WAIT 绕不开 ISN 和 MSL,以及在消除了 TIME_WAIT 之后如何解决延迟报文的问题也未予以详述,我会在我的下一篇文章中就 TIME_WAIT 问题再展开论述,增加自己对 TCP 掌握的熟练度,并结合 ISN、MSL、TIMESTAMP 等概念进行说明。

注:才疏学浅,翻译如有错漏之处,还望斧正。

When building TCP client server systems it's easy to make simple mistakes which can severely limit scalability. One of these mistakes is failing to take into account the TIME_WAIT state. In this blog post I'll explain why TIME_WAIT exists, the problems that it can cause, how you can work around it, and when you shouldn't.

当构建基于 TCP 的 C/S系统的时候,非常容易犯一些简单的错误,这些错误会严重限制系统的可伸缩性。其中之一就是对 TIME_WAIT 状态疏于考虑。在此博客文章中,我将说明 TIME_WAIT 存在的原因、它可能引起的问题、该如何解决它以及何时不应该考虑解决它。

TIME_WAIT is an often misunderstood state in the TCP state transition diagram. It's a state that some sockets can enter and remain in for a relatively long length of time, if you have enough socket's in TIME_WAIT then your ability to create new socket connections may be affected and this can affect the scalability of your client server system. There is often some misunderstanding about how and why a socket ends up in TIME_WAIT in the first place, there shouldn't be, it's not magical. As can be seen from the TCP state transition diagram below, TIME_WAIT is the final state that TCP clients usually end up in.

TIME_WAIT 是 TCP 协议状态转换图中一个经常被误解的状态。一些 socket 会进入这个状态并且保持这个状态相当长的一段时间,如果你有相当多的 socket 处于 TIME_WAIT 状态,那么你创建新连接的能力可能会受到影响,这进而会影响到你 C/S 系统的伸缩性。通常,对于套接字如何以及为什么最终以 TIME_WAIT 结尾的情况经常会产生一些误解,不应该是这样,它不是魔术。如你将在下面的转换图中看到的那样,TIME_WAIT 通常是 TCP 客户端的最终状态(指通常情况下都是客户端主动断开连接)。

TCP StateTransitionDiagram NormalTransitions

Although the state transition diagram shows TIME_WAIT as the final state for clients it doesn't have to be the client that ends up in TIME_WAIT. In fact, it's the final state that the peer that initiates the "active close" ends up in and this can be either the client or the server. So, what does it mean to issue the "active close"?

虽然状态转换图中展示的 TIME_WAIT 是客户端的最终状态,但是以 TIME_WAIT状态结束并不要求一定是客户端。事实上,TIME_WAIT 是发出 “主动关闭” 一端的最终状态,它既可以是客户端,也可以是服务端。那么问题来了,什么是“主动关闭”?

A TCP peer initiates an "active close" if it is the first peer to call Close() on the connection. In many protocols and client/server designs this is the client. In HTTP and FTP servers this is often the server. The actual sequence of events that leads to a peer ending up in TIME_WAIT is as follows.

在 TCP 连接中,率先调用 close() 的一方就是 “主动关闭” 的发起方。在很多协议实现和C/S系统中通常是 client 主动断开连接。HTTP(只在 HTTP 1.0 ) 和 FTP 里是 server 主动关闭。主动端走向 TIME_WAIT 的时序图如下:

TCP StateTransitionDiagram Closure Transitions

Now that we know how a socket ends up in TIME_WAIT it's useful to understand why this state exists and why it can be a potential problem.

现在我们知道了一个socket 是如何以 TIME_WAIT 状态结束的,这对我们理解 TIME_WAIT 状态存在的意义以及为何它可能成为潜在的问题非常有益。

TIME_WAIT is often also known as the 2MSL wait state. This is because the socket that transitions to TIME_WAIT stays there for a period that is 2 x Maximum Segment Lifetime in duration. The MSL is the maximum amount of time that any segment, for all intents and purposes a datagram that forms part of the TCP protocol, can remain valid on the network before being discarded. This time limit is ultimately bounded by the TTL field in the IP datagram that is used to transmit the TCP segment. Different implementations select different values for MSL and common values are 30 seconds, 1 minute or 2 minutes. RFC 793 specifies MSL as 2 minutes and Windows systems default to this value but can be tuned using the TcpTimedWaitDelay registry setting.

TIME_WAIT state 也称 2MSL wait state。这是因为转换为 TIME_WAIT 的套接字在此停留的时间为 2 个最大报文生存时间(MSL)。MSL 是 TCP 协议中任何报文在网络上最大的生存时间,任何超过这个时间的数据都将被丢弃。MSL 是由网络层的 IP 包中的 TTL来保证的(MSL 不与TTL 绝对相等,事实上 MSL 是 TCP 协议的假设基础)。不同的协议实现规定的 MSL 都不相同,通常为 30 seconds, 1 minute or 2 minutes。RFC 793 规定 MSL 为 2 minutes,Linux 默认为 30 seconds,windows 默认为 2 minutes,但是可以通过修改注册表 TcpTimedWaitDelay 的值来自定义。

The reason that TIME_WAIT can affect system scalability is that one socket in a TCP connection that is shutdown cleanly will stay in the TIME_WAIT state for around 4 minutes. If many connections are being opened and closed quickly then socket's in TIME_WAIT may begin to accumulate on a system; you can view sockets in TIME_WAIT using netstat. There are a finite number of socket connections that can be established at one time and one of the things that limits this number is the number of available local ports. If too many sockets are in TIME_WAIT you will find it difficult to establish new outbound connections due to there being a lack of local ports that can be used for the new connections. But why does TIME_WAIT exist at all?

TIME_WAIT 之所以会影响系统的伸缩性是因为连接正常关闭下进入这个状态的 socket 会持续 4 minutes 的时间(根据不同的实现,TIME_WAIT 时间在1 ~ 4 minutes之间)。如果有大量的连接被打开然后随即关闭的话,那么停留在 TIME_WAIT 状态的 socket 就会在操作系统上积累;你可以通过 netstat 来观察 TIME_WAIT 状态的 socket。一次只能建立有限数量的套接字连接,而限制此数量的因素之一是就可用的本地端口数。如果太多的 socket 处于 TIME_WAIT 状态,你会发现很难再建立新的出站连接,原因是缺少可用于新连接的本地端口。但是,为什么 TIME_WAIT 会存在呢?

There are two reasons for the TIME_WAIT state. The first is to prevent delayed segments from one connection being misinterpreted as being part of a subsequent connection. Any segments that arrive whilst a connection is in the 2MSL wait state are discarded.

TIME_WAIT 的存在主要有两个原因,第一个就是阻止前一个连接的延时报文被后续的连接错误接收(相同的五元组,不同的实例称为“化身”,这里指要阻止之前“化身”的报文被当前的连接错误接收)。一个连接处于 2 MSL 等待状态时,任何抵达的报文都将被丢弃(这里应该主要指数据报文,对于 FIN 报文还是要接收的,以便重新发送 ACK ,重新开始 2MSL 计时)。

TIME_WAIT why

In the diagram above we have two connections from end point 1 to end point 2. The address and port of each end point is the same in each connection. The first connection terminates with the active close initiated by end point 2. If end point 2 wasn't kept in TIME_WAIT for long enough to ensure that all segments from the previous connection had been invalidated then a delayed segment (with appropriate sequence numbers) could be mistaken for part of the second connection...

上图有两个从 end point 1 到 end point 2 的连接,这两个连接的五元组相同(双方地址和端口相同)。第一个连接因 end point 2 主动关闭而终止,如果 end point 2 不在 TIME_WAIT 保持足够的时间以确保所有的报文在网络上失效的话,一个延迟的报文(拥有合适的 sequence number,意即对第二个连接来说是有效报文)会被第二个连接错误接收。

Note that it is very unlikely that delayed segments will cause problems like this. Firstly the address and port of each end point needs to be the same; which is normally unlikely as the client's port is usually selected for you by the operating system from the ephemeral port range and thus changes between connections. Secondly, the sequence numbers for the delayed segments need to be valid in the new connection which is also unlikely. However, should both of these things occur then TIME_WAIT will prevent the new connection's data from being corrupted.

需要注意的是,像这样延迟的报文造成问题的情况是非常不太可能的。首先,地址和端口号就不太可能相同,因为客户端的端口号是由操作系统临时分配的。其次,报文的 sequence 号必须是有效的,这个也不太可能发生(涉及到 ISN 的循环 和 sequence number 的 warp around)。然而,如果这两种情况都发生,则 TIME_WAIT 将防止新连接的数据被破坏。

The second reason for the TIME_WAIT state is to implement TCP's full-duplex connection termination reliably. If the final ACK from end point 2 is dropped then the end point 1 will resend the final FIN. If the connection had transitioned to CLOSED on end point 2 then the only response possible would be to send an RST as the retransmitted FIN would be unexpected. This would cause end point 1 to receive an error even though all data was transmitted correctly.

TIME_WAIT 存在的第二个原因就是:实现 TCP 的全双工连接可靠终止。如果 end point 2 最后发出的 ACK 丢失,依照 TCP 协议规则,end point 1 会重发 FIN。如果 end point 2 此时转为 CLOSED 状态,end point 2 的协议栈会响应一个 RST 报文,因为 这个重传的 FIN 并未被认可。 这将导致 end point 1 收获一个 error,即使所有的数据都已正确传输。

Unfortunately the way some operating systems implement TIME_WAIT appears to be slightly naive. Only a connection which exactly matches the socket that's in TIME_WAIT need by blocked to give the protection that TIME_WAIT affords. This means a connection that is identified by client address, client port, server address and server port. However, some operating systems impose a more stringent restriction and prevent the local port number being reused whilst that port number is included in a connection that is in TIME_WAIT. If enough sockets end up in TIME_WAIT then new outbound connections cannot be established as there are no local ports left to allocate to the new connection.

不幸的是,某些操作系统实现 TIME_WAIT 的方式似乎有些幼稚。只有阻塞在 TIME_WAIT 中的套接字连接,才需要给予 TIME_WAIT 提供的保护。这意味着由客户端地址,客户端端口,服务器地址和服务器端口标识的连接。但是,某些操作系统施加了更严格的限制,并阻止了本地端口号被重用,而该端口号包含在 TIME_WAIT 的连接中(这句不是很明白,感觉意思是处于 TIME_WAIT 的 socket 涉及到的所有端口号都不能重用)。如果以 TIME_WAIT 结束的套接字足够多,那么将无法建立新的出站连接,因为没有剩余的本地端口可分配给新连接。

Windows does not do this and only prevents outbound connections from being established which exactly match the connections in TIME_WAIT.

Windows 不会这样做,它仅阻止建立与 TIME_WAIT 中的连接完全匹配的出站连接。

Inbound connections are less affected by TIME_WAIT. Whilst the a connection that is actively closed by a server goes into TIME_WAIT exactly as a client connection does the local port that the server is listening on is not prevented from being part of a new inbound connection. On Windows the well known port that the server is listening on can form part of subsequently accepted connections and if a new connection is established from a remote address and port that currently form part of a connection that is in TIME_WAIT for this local address and port then the connection is allowed as long as the new sequence number is larger than the final sequence number from the connection that is currently in TIME_WAIT. However, TIME_WAIT accumulation on a server may affect performance and resource usage as the connections that are in TIME_WAIT need to be timed out eventually, doing so requires some work and until the TIME_WAIT state ends the connection is still taking up (a small amount) of resources on the server.

入站的连接很少会受到 TIME_WAIT 的影响。服务端主动关闭连接进入 TIME_WAIT 的过程与客户端类似,但是不会阻止服务器正在侦听的本地端口成为新的入站连接的一部分。在 Windows 操作系统上,那些服务端侦听的知名端口可以用于创建新的入站连接,只要新的连接的 ISN (初始序列号)大于处于 TIME_WAIT 的那个连接的最终序列号,那么即使远端地址和远端端口也是当前 TIME_WAIT 连接的一部分, 这个连接也会创建成功(RFC 1122中有相关描述,但是新“化身”的 ISN 号不敢保证一定会比老“化身”最后的序列号大,特别是中间在 NAT 网络的情况下)。但是,服务器上 TIME_WAIT 的累积可能会影响性能和资源使用,因为 TIME_WAIT 中的连接最终需要超时,这需要做一些工作,直到TIME_WAIT状态结束,连接仍会占用(少量)服务器上的资源。

Given that TIME_WAIT affects outbound connection establishment due to the depletion of local port numbers and that these connections usually use local ports that are assigned automatically by the operating system from the ephemeral port range the first thing that you can do to improve the situation is make sure that you're using a decent sized ephemeral port range. On Windows you do this by adjusting the MaxUserPort registry setting; see here for details. Note that by default many Windows systems have an ephemeral port range of around 4000 which is likely too low for many client server systems.

鉴于 TIME_WAIT 会因本地端口号的耗尽而影响到出站连接的建立,并且这些连接通常使用由操作系统从临时端口范围自动分配的本地端口,因此,你可以做的第一件事就是确保正在使用适当大小的临时端口范围。在 Windows 上你可以通过修改注册表选项 MaxUserPort 来自定义。请注意,默认情况下,许多 Windows 系统的临时端口范围约为 4000,这对于许多 C/S 系统而言可能太低了。

Whilst it's possible to reduce the length of time that socket's spend in TIME_WAIT this often doesn't actually help. Given that TIME_WAIT is only a problem when many connections are being established and actively closed, adjusting the 2MSL wait period often simply leads to a situation where more connections can be established and closed in a given time and so you have to continually adjust the 2MSL down until it's so low that you could begin to get problems due to delayed segments appearing to be part of later connections; this would only become likely if you were connecting to the same remote address and port and were using all of the local port range very quickly or if you connecting to the same remote address and port and were binding your local port to a fixed value.

尽管可以减少 socket 在 TIME_WAIT 中花费的时间,但这实际上并没有帮助。鉴于 TIME_WAIT 成为问题仅仅出现在有大量连接主动关闭的情况下,因此调整 2MSL 时间值只会让更多的连接不断的被建立和关闭,从而你会陷入不断的调小 2MSL 的恶性循环中,直到这个值小到让你开始遭遇延迟报文干扰后续连接的情况。这经常出现在你作为客户端对同一个服务端发起大量短连接的情况下,本地端口被快速申请,却慢速的释放,或者是作为客户端你使用了本地固定端口。

Changing the 2MSL delay is usually a machine wide configuration change. You can instead attempt to work around TIME_WAIT at the socket level with the SO_REUSEADDR socket option. This allows a socket to be created whilst an existing socket with the same address and port already exists. The new socket essentially hijacks the old socket. You can use SO_REUSEADDR to allow sockets to be created whilst a socket with the same port is already in TIME_WAIT but this can also cause problems such as denial of service attacks or data theft. On Windows platforms another socket option, SO_EXCLUSIVEADDRUSE can help prevent some of the downsides of SO_REUSEADDR, see here, but in my opinion it's better to avoid these attempts at working around TIME_WAIT and instead design your system so that TIME_WAIT isn't a problem.

更改 2MSL 延时通常是操作系统级别的配置改动。你可以改为在 socket 层面使用 SO_REUSEADDR 选项针对 TIME_WAIT 问题做一些工作。这个选项允许你在相同的正在使用的地址和端口上创建 socket,新的 socket 本质上是劫持了老的socket。你可以使用此选项来创建新的 socket,即便它正处在 TIME_WAIT 状态,但是这样做有可能造成问题,例如拒绝服务攻击或者数据丢失。在 Windows 平台上,另一个 socket 选项 SO_EXCLUSIVEADDRUSE 可以帮助弥补 SO_REUSEADDR 的某些缺点。但我认为最好避免这些尝试来解决 TIME_WAIT 的问题,而应精心设计系统从而使 TIME_WAIT 不会成为问题。

The TCP state transition diagrams above both show orderly connection termination. There's another way to terminate a TCP connection and that's by aborting the connection and sending an RST rather than a FIN. This is usually achieved by setting the SO_LINGER socket option to 0. This causes pending data to be discarded and the connection to be aborted with an RST rather than for the pending data to be transmitted and the connection closed cleanly with a FIN. It's important to realise that when a connection is aborted any data that might be in flow between the peers is discarded and the RST is delivered straight away; usually as an error which represents the fact that the "connection has been reset by the peer". The remote peer knows that the connection was aborted and neither peer enters TIME_WAIT.

上面的 TCP 转换图均展示的是有序连接终止。然而还有一种终止连接的方式,就是发送一个 RST 报文而不是 FIN 报文。一般是通过将 SO_LINGER 套接字选项设置为0来实现的。发送 RST 将导致待传输的数据(尚在发送缓冲区中)被丢弃,它不像发送 FIN 进行有序终止那样会等待数据被传送完成(一个 RST 报文会立即被送出,而 FIN 需要在缓冲区排队)。连接被终止时连接两端任何尚在流中的数据都将被丢弃,认识到这一点非常重要,因为 RST 报文会立即被传输;应用通常会收到“connection has been reset by the peer”的错误。这样远端就会意识到连接已经终止了,而且不会有任何一方进入 TIME_WAIT 状态。

Of course a new incarnation of a connection that has been aborted using RST could become a victim of the delayed segment problem that TIME_WAIT prevents, but the conditions required for this to become a problem are highly unlikely anyway, see above for more details. To prevent a connection that has been aborted from causing the delayed segment problem both peers would have to transition to TIME_WAIT as the connection closure could potentially be caused by an intermediary, such as a router. However, this doesn't happen and both ends of the connection are simply closed.

如此一来,没有了 TIME_WAIT 的保护,一个被 RST 终止的连接的新“化身”可能会成为延迟报文的受害者,但是无论如何,出现这种情况的可能性微乎其微,请参阅上文以了解更多详细信息。

There are several things that you can do to avoid TIME_WAIT being a problem for you. Some of these assume that you have the ability to change the protocol that is spoken between your client and server but often, for custom server designs, you do.

当然,要避免 TIME_WAIT 造成的困扰,我们尚有可为。前提是你可以控制客户端和服务端之间的设计实现,对于自定义的服务端来说,通常都需要这样做。

For a server that never establishes outbound connections of its own, apart from the resources and performance implication of maintaining connections in TIME_WAIT, you need not worry unduly.

对于一个不会建立出站连接的服务端来说,除了承受维护 TIME_WAIT 状态所带来的性能影响和资源占用之外,不需要对其有过多的担心。

For a server that does establish outbound connections as well as accepting inbound connections then the golden rule is to always ensure that if a TIME_WAIT needs to occur that it ends up on the other peer and not the server. The best way to do this is to never initiate an active close from the server, no matter what the reason. If your peer times out, abort the connection with an RST rather than closing it. If your peer sends invalid data, abort the connection, etc. The idea being that if your server never initiates an active close it can never accumulate TIME_WAIT sockets and therefore will never suffer from the scalability problems that they cause. Although it's easy to see how you can abort connections when error situations occur what about normal connection termination? Ideally you should design into your protocol a way for the server to tell the client that it should disconnect, rather than simply having the server instigate an active close. So if the server needs to terminate a connection the server sends an application level "we're done" message which the client takes as a reason to close the connection. If the client fails to close the connection in a reasonable time then the server aborts the connection.

如果你的服务端既要建立出站连接又要建立入站连接,那么设计的黄金法则就是:如果 TIME_WAIT 注定要发生,那么就让它发生在对端。简单来说就是不管什么原因,永远不要在服务端主动关闭连接。如果对端超时,请使用 RST 中止连接,而不要关闭它。如果对端发送了无效的数据,使用 RST 终止连接等等。其中诀窍就是如果服务端永远不主动关闭连接,那么服务器上就不会积累 TIME_WAIT 的 socket,因此便不会遭受可伸缩性问题。我们看到在发生异常的情况下如果发送 RST 终止连接,那么正常情况下的终止又该如何避免呢?理想情况下,你应该在应用层设计一种协议方式,让服务端告知客户端可以断开连接,而不是由服务端主动发起关闭。所以,服务端如果想断开连接了,那么发送一个应用层的消息“我们分手吧”,客户端收到后再主动发起连接关闭,如果客户端在预定时间内没有成功关闭,那么服务端可以主动终止连接。

On the client things are slightly more complicated, after all, someone has to initiate an active close to terminate a TCP connection cleanly, and if it's the client then that's where the TIME_WAIT will end up. However, having the TIME_WAIT end up on the client has several advantages. Firstly if, for some reason, the client ends up with connectivity issues due to the accumulation of sockets in TIME_WAIT it's just one client. Other clients will not be affected. Secondly, it's inefficient to rapidly open and close TCP connections to the same server so it makes sense beyond the issue of TIME_WAIT to try and maintain connections for longer periods of time rather than shorter periods of time. Don't design a protocol whereby a client connects to the server every minute and does so by opening a new connection. Instead use a persistent connection design and only reconnect when the connection fails, if intermediary routers refuse to keep the connection open without data flow then you could either implement an application level ping, use TCP keep alive or just accept that the router is resetting your connection; the good thing being that you're not accumulating TIME_WAIT sockets. If the work that you do on a connection is naturally short lived then consider some form of "connection pooling" design whereby the connection is kept open and reused. Finally, if you absolutely must open and close connections rapidly from a client to the same server then perhaps you could design an application level shutdown sequence that you can use and then follow this with an abortive close. Your client could send an "I'm done" message, your server could then send a "goodbye" message and the client could then abort the connection.

客户端这头就略有复杂,正所谓“我不入地狱,谁入地狱”,毕竟总要有人主动发起关闭使 TCP 正常结束,也总要有人去承受 TIME_WAIT。然而,将 TIME_WAIT 终止在客户端还具有几个好处呢!首先,如果出于某种原因,客户端由于 TIME_WAIT 中 socket 的累积而导致连接问题话,只是它一个客户端的问题,其它的客户端不会受其影响。其次,快速打开和关闭与同一服务器的 TCP 连接的效率很低,因此 TIME_WAIT 的维持维持一个更长的等待时间反而有比较有意义。不要设计那种每分钟都要通过新建连接来连接到服务端的客户端。相反,使用长连接来替代,只有在连接失败的时候才去重连,如果中间路由不愿意维护长时间的空闲连接,可以在应用层添加心跳机制,或者打开 TCP 的 keep alive 保活机制,再或者就主动接受连接被重置。这样做的好处就是你不会积累 TIME_WAIT 了,如果你做的工作就是短期任务,那么考虑使用连接池吧。如果你非要写那种快速打开关闭,分分钟干出一堆 TIME_WAIT 的客户端,或许你可以设计一个应用层的关闭机制,你在客户端发送“我干完了”,服务端接收之后返回一个“再见”,之后客户端便可以发送 RST 终止连接了(注意,虽然解决了 TIME_WAIT 和数据完整性问题,但是延时报文仍然可能成为问题,而 per-host PAWS 无法完全解决不同“化身”之间的报文延时)。

TIME_WAIT exists for a reason and working around it by shortening the 2MSL period or allowing address reuse using SO_REUSEADDR are not always a good idea. If you're able to design your protocol with TIME_WAIT avoidance in mind then you can often avoid the problem entirely.

存在即合理,一味的通过 TCP 选项来重用地址或是盲目的减少 MSL 的值的做法都是不可取的。

念念不忘,必有回响,若你在设计协议时一直考虑 TIME_WAIT 的问题,那么你终将会完全避开。

Have you ever thought ?

有一次,我在编写一个 Go 程序,这个程序要做的一件事是在操作系统上执行一个命令(可执行文件或者可执行脚本),程序大概像下面这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cmdSlice := strings.Fields(strings.TrimSpace(cmdString))
if len(cmdSlice) == 0 {
return errors.New("index out of range [0] with length 0")
}
// search for an executable named file in the
// directories named by the PATH environment variable.
// If file contains a slash, it is tried directly and the PATH is not consulted.
// The result may be an absolute path or a path relative to the current directory.
executableFile, err := exec.LookPath(cmdSlice[0])
if err != nil {
return errors.WithStack(NewPathError(cmdSlice[0], err.Error()))
}

cmd := exec.Command(executableFile, cmdSlice[1:]...)

当我让程序去执行一个 shell 脚本的时候,收到了 fork/exec: exec format error 的错误,然而我在 shell 下执行这个脚本却是正常的,这让我很迷惑。

当我弄清楚原因是我没有在脚本里加 Shebang#!) 的时候,疑惑愈深:为什么操作系统会容忍我的过错呢?对此,我会在稍后的章节中进行解释。当搞清楚问题的始末的之后,我突然对操作系统执行程序的方式产生了极大的兴趣,在好奇心的驱使下,我试图去搞清楚它,这也是我写这篇文章的初衷。

你是否想过,除了在 shell 下启动一个程序,是否还有其它的方式? 我们是不是永远无法摆脱 shell? 你是否曾经对 shell 下各种的执行方式感到困惑?比如,“source xxx.sh”“. xxx.sh”“./xxx.sh”“. ./xxx.sh”“sh ./xxx.sh”。 没关系,这篇文章会带你走出迷雾。

本文会涉及到一点 SysvinitSystemd 的内容, 但不会过多的去介绍他们,只是简单说明,这是一种能让你的程序运行起来的方式,我最后的重点会放在 shell 上面。

让程序跑起来有多少种可能的方法?

当我产生这个疑问之后,我努力的思考,并去寻找答案,最后总结了如下几种:

  1. 传统的 Sysvinit 方式
  2. Systemd
  3. crontab 或者 Systemd Timer
  4. shell(无论是终端还是 sshd )
  5. GUI

其中 1, 2 两种方式可以算作同一类,虽然他们的工作方式有所不同,但都属于系统管理层面。如果你的程序是一个随系统启动,并托付给系统管理的 Daemon ,那么最好的方式就是通过 Sysvinit 或者 Systemd 来管理,他们都是 Linux 的 init 系统。相似的 init 系统还有 Upstart ,但我并不熟悉它,所以不准备做介绍,当然这并不影响,因为他们属于一类系统。

定时任务是另一个可能会拉起一个程序的方式,相信很多朋友都有在 Linux 上使用 crontab 的经历,而它的继任者 Systemd Timer 可能就没那么多人熟悉了。

shell 是最常见的启动程序的方式。事实上 shell 的主要作用就是去运行其它的程序,即便是前面 3 种方式,很多时候也是使用 shell 来启动程序的,只不过不是我们手动在 shell 里执行而已。

还有一种方式就是在桌面环境下,使用 GUI 来启动一个前台程序,你可能通过点击一个 .desktop 的快捷方式来启动一个桌面应用,在我的 Manjaro 下桌面应用全部是由 plasmashell 这个进程 fork 出来的子进程。

承前启后的 SysV init

Linux 操作系统的启动首先从 BIOS 开始,然后由 Boot Loader 载入内核,并初始化内核。内核初始化的最后一步就是启动 init 进程。这个进程是系统的第一个进程,PID 为 1,又叫超级进程,也叫根进程。它负责产生其他所有用户进程。所有的进程都会被挂在这个进程下,如果这个进程退出了,那么所有的进程都被 kill 。如果一个子进程的父进程退了,那么这个子进程会被挂到 PID 1 下面。

因为大多数 Linux 发行版的 init 系统是和 Unix System V 是相互兼容的,因此 Linux 上的 init 系统也被成为 Sysvinit 。在 Systemd 出现之前,大多数常见的 Linux 发行版都使用Sysvinit

在 sysvinit 下有几个 runlevel ,并且有 0~6 七个运行级别,比如:常见的 3 级别指定启动到多用户的字符命令行界面,5 级别指定启动到图形界面,0 表示关机,6 表示重启。其配置在 /etc/inittab 文件中。

与此配套的还有 /etc/init.d/ 和 /etc/rc[X].d,前者存放各种进程的启停脚本(需要按照规范支持 start,stop子命令),后者的 X 表示不同的 runlevel 下相应的后台进程服务,如:/etc/rc3.d 是 runlevel=3 的。 里面的文件主要是 link 到 /etc/init.d/ 里的启停脚本。其中也有一定的命名规范:S 或 K 打头的,后面跟一个数字,然后再跟一个自定义的名字,如:S01rsyslog,S02ssh。S 表示启动,K表示停止,数字表示执行的顺序。

为了将操作系统带入可操作状态,init 系统通过读取 /etc/inittab 获得 runlevel,然后依次顺序执行对应 level 下的脚本。rc[X].d 下都是些 link, 链接到 rc.d 中的 shell 脚本, 可见系统初始化过程中依然是使用的 shell 来启动相应程序的。

然而这些脚本中需要使用 awk, sed, grep, find, xargs 等等这些操作系统的命令,这些命令需要生成进程(这涉及到 shell 的工作方式,我稍后在 shell 小节详细介绍),生成进程的开销很大,关键是生成完这些进程后,这个进程就干了点屁大的事就退了。这完全是大材小用,操作系统废了九牛二虎之力拉起来一个进程,结果这个进程就干了个把字符串转为小写的活儿,然后丢下一脸懵逼的操作系统就潇洒的退出了。

可以想见,当 rc.d 中有大量的脚本,且脚本中又有成百上千个类似于 awk、sed、grep 这样的命令时,系统的启动过程就会变得漫长。当然对于启停不那么频繁的服务器来说,这依然可以接受,而且这样的系统设计也很符合 Unix 设计哲学:Do one thing and Do it well,所以 sysvinit 可以一统江湖几十年。直到 2006年 Linux 内核进入 2.6 时代,Linux 开始进入桌面系统,而桌面系统和服务器系统不一样的是,桌面系统面临频繁重启,而且,用户会非常频繁的使用硬件的热插拔技术。于是,在这些新的使用场景下,sysvint 开始变得不合时宜了。

更详细的 sysvint 介绍可以参考 浅析 Linux 初始化 init 系统-sysvinit

步行夺猛马的 Systemd

历史上总是会有人站出来对现状说不,2010 年 Lennart Poettering 和他的小伙伴们开源并发布了一个新的 init 系统——Systemd

Systemd 是 Linux 系统中最新的初始化系统(init),它主要的设计目标是克服 sysvinit 固有的缺点,提高系统的启动速度。systemd 和 ubuntu 的 upstart 是竞争对手,而 ubuntu 在 15.04 及后续版本中已将 systemd 设置为默认 init 程序,redhat 和 centos 也从 7.0 之后开始使用 systemd,截止目前 systemd 已经运行在大部分的 Linux发行版中。

在系统启动上 systemd 拥有绝对的优势,有张三方对比图可见分晓:

如今 systemd 成为 1 号进程,后续所有的进程都是由它 fork 出来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
systemd(1)─┬─ModemManager(494)─┬─{ModemManager}(495)
│ └─{ModemManager}(498)
├─NetworkManager(463)─┬─{NetworkManager}(467)
│ └─{NetworkManager}(470)
├─agent(1229)─┬─{agent}(1232)
│ └─{agent}(1236)
├─avahi-daemon(465)───avahi-daemon(468)
├─baloo_file(655)───{baloo_file}(671)
├─bluetoothd(464)
├─crond(460)
├─cupsd(476)
├─dbus-daemon(462)
├─dbus-daemon(1458)
├─dockerd(557)─┬─containerd(583)─┬─{containerd}(589)
│ │ ├─{containerd}(590)
│ │ └─{containerd}(13694)
│ ├─{dockerd}(561)
│ └─{dockerd}(861)
└──fcitx(1431)─┬─sh(1576)
└─{fcitx}(1478)

深入了解请参考:LINUX PID 1 和 SYSTEMD

定时任务

简单说一下定时任务,当我们使用 crontab 配置了一个定时执行任务之后,Cron 每分钟做一次检查,看看哪个命令可执行,当 Cron 检查到有命令需要执行时则 fork 子进程,再由此子进程 fork-execve 执行真正的命令:

1
2
3
4
5
init(1)-+-NetworkManager(1747)
└-crond(2107)-+-crond(355)---sh(356)---sleep(14329)
|-crond(10967)---sh(10968)---sleep(14326)
|-crond(12098)---sh(12099)---sleep(14327)
`-crond(15114)---sh(15115)---sleep(14328)

这篇 Linux cron运行原理 有更详细的介绍。

对于 systemd 我测试一个 timer,其进程是挂在 systemd 进程之下的,我猜测也是 systemd 进程去 fork 执行 timer 中的任务。

1
2
{17:22}~/PycharmProjects ➭ ps -ef|grep udp
root 17002 1 0 17:19 ? 00:00:00 /usr/bin/python3 /home/liupeng/udpClient.py

伟大的造物主——shell

通过前面的分析,会发现 Linux 上的程序绝大多数情况下是通过 shell 来执行的,所以我们接下来将重点放在 shell 上。

你可能会问: shell 有什么好讲的,它不就是个与内核交互的外壳程序么?

没错,它的功能就是如此纯粹——a shell is a user interface for access to an operating system's services,但它却无处不在。

传统的 Sysvinit 系统下绝大部分的系统服务都是通过 shell 拉起来的,虽然到了 Systemd 时代,很多工作由 C 语言重新实现了(具体见LINUX PID 1 和 SYSTEMD),但是你依然可以使用 systemd 来管理你的启停脚本,这些脚本用来启停你的程序。而对于非 Daemon 方式的程序。你仍然需要用 shell 来启动它们到前台,或者使用 nohup、setsid 等方式启动到后台。

可见,我们无法逃离 shell,它就像是一个造物主,系统中几乎所有的进程都是或曾是它的子民。

要讲清楚shell是一个十分艰巨的任务,对于只查过几天资料的我来说自然无法胜任,但是择其一两点来讲,以多少理清一些 Linux 下程序启动与运行的原理为目的,或可一试。

文中涉及到关于 shell 的实验或者结论皆以 Bash 作为参考依据。

What is a shell?

Bash 主页上有关于 shell 的定义:

At its base, a shell is simply a macro processor that executes commands. The term macro processor means functionality where text and symbols are expanded to create larger expressions.

这段话真不太好翻译,勉强翻译一下为:从根本上说,shell 只是执行命令的宏处理器。术语宏处理器是指将文本和符号扩展以创建更大的表达式的功能。

对于 Unix shell 来说,它既是一个命令行解释器也是一个编程语言。shell 作为命令行解释器为丰富的 GNU 工具集提供了用户接口,而作为编程语言它成功的将这些工具集结合在一起,之后就可以将命令编写进文件,去完成各种各样的任务。

很多人可能傻傻分不清 terminalttyconsoleshell,这里第一个高票回答对这些概念做了详细的解释:What is the exact difference between a 'terminal', a 'shell', a 'tty' and a 'console'?。如果英文阅读不畅,知乎上有人将其翻译了一下:终端、Shell、tty 和控制台(console)有什么区别?,我不再做额外的阐述了,接下来只需要记住 shell 是一个命令行解释器就好,它可以运行在交互模式和非交互模式。

shell 是如何查找命令的

当我们在交互式 shell 下敲下一个命令时,shell 查找命令文件的规则大概如下:

  1. 执行命令前 shell 会先检查是否有 alias,如果有就会使用 alias 中的内容。

  2. 如果 command 名字不包含 "/" ,shell 将尝试寻找它。如果存在同名的函数,则会调用函数。

  3. 如果没有匹配到函数,则从 shell 内置命令(builtins)中寻找,如果找到则调用该命令。

  4. 如果都没有找到则从 $PATH 中寻找,为了避免每次遍历 $PATH ,shell 维护了一张 HASH 表,记录了每个命令对应的绝对路径(我的 manjaro 在每次安装新软件之后,需要执行一下rehash命令。如此,可执行文件才能在当前 shell 下被找到),如果 HASH 表中没有再去 $PATH 中的目录遍历,如果 PATH 中未找到就执行一个预定义的函数 command_not_found_handle 。如果函数存在,则在子 shell 中调用,如果不存在则打印错误信息并返回 127 状态码。

  5. 如果寻找成功或者 command 中含有 “/”, shell 将在新环境中执行它( fork 一个新进程 )。

  6. 如果 command 不是异步启动的,shell 将等待其完成并收集退出状态码。

如上所述就是 shell 在执行命令式的查找规则。也是时候破解一下我们在文章开头留下的谜题了,先从 ./ 开始吧。

./ 在类 Unix 系统中表示相对路径指向某个文件或者目录,因为在 Unix 系统中 PATH 不包含当前路径,也无法包含当前路径。如 ./testtouch ./a

. 是 BASH 的一个内建命令,它继承自 Bourne shell (sh),并且是 source 同义词,跟 source 功能相同。

. filename [arguments] 的功能是在当前 shell 上下文中( 不会 fork )读取并执行 filename 中的命令,如果 filename 不包含 “/” , shell 将从 $PATH 中寻找该文件,如果当前 shell 不是 POSIX 模式,则在 PATH 中寻找失败后,继续从当前目录中寻找。

对于 sh ./test.sh 这种模式,在 bash 的文档中可以找到对应的描述 Invoked with name sh(大多数 Linux 发行版会把 sh 设置成 bash 的软连接,所以这里只针对此种情况):

When invoked as sh, Bash enters POSIX mode after the startup files are read.

POSIX mode 我会在后面展开介绍,这里暂且略去,开始进入 shell 如何执行一个 command 吧。

shell 是如何执行命令的

我在介绍 Systemd 和 cron 的时候用了 fork 这个词,而在描述 shell 的时候仅仅说“shell 启动相应程序”。其实,shell 执行一个程序的方式也一样使用了 fork,我只是为了能在本章节重点作介绍才故意没有使用 fork 这个词。

我们知道, Linux 下的可执行文件可以分为 ELF 文件脚本文件,当我们在 bash 下输入一个命令执行某个 ELF 程序时,Linux 系统是如何装载并执行它的呢?

首先,在用户层面,bash 进程会调用 fork() 系统调用创建一个新的进程。新进程通过调用 execve() 系统调用来执行指定的 ELF 文件。原先的 bash 进程继续返回并等待刚才启动的新进程结束,之后继续等待用户输入命令。

当进入 execve() 系统调用之后,Linux 内核就开始进行真正的装载工作。在内核中,execve() 系统调用相应的入口是 sys_execve()sys_execve() 进行一些参数的检查复制之后,调用 do_execve()do_execve() 会首先查找被执行的文件,如果找到文件,则读取文件的前 128 个字节。

为什么要先读取文件的前 128 个字节?这是因为 Linux 支持的可执行文件不止 ELF 一种,还包括 a.outJava 程序、以 #! 开头的脚本程序。do_execve() 通过读取前 128 个字节来判断文件的格式。每种可执行文件格式的开头几个字节都是很特殊的,尤其是前4个字节,被称为 魔数(Magic Number)。

我们用一段 C 程序来读取一下各种文件的前 4 个字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
int main (int argc, const char * argv[]) {

FILE *fp;
int r;
int i;
fp = fopen(argv[1], "rb");
fread(&r, 4, 1, fp);

printf("%X \n", r);

fclose(fp);
return 0;
}
  1. ELF

    我们编译这段程序,并读取程序自身

    1
    2
    3
    {15:32}~/Learing/c/src:master ✗ ➭ gcc -o read4bytes read4bytes.c 
    {15:33}~/Learing/c/src:master ✗ ➭ ./read4bytes read4bytes
    464C457F

    可以看到输出为 464C457F,我们查看ASCII 表,得出如下的对应关系:
    ELF Header

    我的操作系统字节序是小端法排序,因此,ELF的可执行文件格式的头 4 个字节为 0x7F、E、L、F

  2. shell 脚本

    1
    2
    {15:33}~/Learing/c/src:master ✗ ➭ ./read4bytes ~/meta    
    622F2123

    前 4 个字节为 622F2123,我们再查一下 ASCII 表的对应关系:
    shell script header

    翻转一下就是 #!/b,可以猜测如果我们多读 7 个字节,结果肯定是#!/bin/bash.

    对于 pythonperlphp脚本处理方式相同。

  3. java class

    1
    2
    {15:51}~/Learing/c/src:master ✗ ➭ ./read4bytes ~/java/HelloWorld.class 
    BEBAFECA

    《程序员的自我修养》一书 6.5 章节介绍 Linux 装载可执行文件时,依次介绍了 ELFjava 可执行文件#!三种情况。ELF 的前 4 个字节将 16 进制转换为 ASCII 字符是 ELF;但是 java 的 class 文件则不同,由上可知读出的前4个字节的 16 进制表示为 BEBAFECA。因为是小端,所以 16 进制的表示法刚好是 CAFEBABE,并不需要转化成具体的字符,而书中介绍说 “Java的可执行文件格式的头 4 个字节为 c、a、f、e”,我猜可能书中存在前后逻辑不一致的问题,除非真的存在所谓的 java 可执行文件,如果有朋友了解,欢迎联系我,给我批评指正。

    关于 CAFEBABE的来源可以参见 wiki 上 James Gosling 的自白。

do_execve() 读取了 128 个字节的文件头部之后,调用 search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程。Linux 中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handler() 会通过判断头部的魔数确定文件的格式,并且调用相应的装载处理过程。常见的可执行程序及其装载处理过程的对应关系如下所示.

  • ELF 可执行文件:load_elf_binary()
  • a.out 可执行文件:load_aout_binary()
  • 可执行脚本程序:load_script()

有必要提一下 a.out, a.out 本身要追溯到更早的 Unix 时代,并且伴随 Linux 的诞生至今在 Linux 中有将近 ~28 年的历史。从 内核 5.1 开始, Linux 移除 a.out 格式的消息,因为 ELF 自 1994 年进入 Linux 1.0 以来,已经 ~25 年了,a.out 早已年久失修,而且现在基本上找不到能产生 a.out 格式的编译器了。

你可能会说,gcc 默认编译生成的不就是 a.out 么?非也,此 a.out 非彼 a.out。gcc 默认生成的 a.out 的实际格式也是 ELF,如果你按照刚才的方式读取 a.out 的前四个字节,你会发现同样是 464C457F,a.out 这个名字很大意义上属于计算机历史文化的沿袭,想了解更多可以参考为 a.out 举行一个特殊的告别仪式

我在这里省去 load_elf_binary() 的过程,只需提一下其中一步会修改系统调用的返回地址为 ELF 文件的入口地址,细节可以去参考《程序员的自我修养》 6.5 节。当 load_elf_binary() 执行完毕之后,返回至 do_execve() 再返回至 sys_execve() 时,因为 load_elf_binary() 已经修改了返回地址,所以当 sys_execve() 系统调用从内核态返回到用户态时,EIP 寄存器直接跳转到了 ELF 程序的入口地址,于是开始执行新程序的代码指令, ELF 可执行文件装载完成。

是时候去破解我在文章开头留下的问题了,我用 Go 程序通过 forkexec 去执行脚本的时候收获到 fork/exec: exec format error 的错误。现在来看是 search_binary_handle() 的过程出了问题,内核并没有识别到脚本文件格式,经查确认是我脚本中没有加入 Shebang,当我在首行增加了 #!/bin/bash 之后,程序便可以正确运行了。

我还没有解释为什么在交互式的 shell 下执行不带 Shebang 的脚本不会触发错误,因为说起我寻找答案的过程总让我喟叹不已,我是从一篇几近 30 年前的文章中找到答案的。这让我想到了多年前我学习 Oracle 调优时从一本 10 多年前出版的书中获益的经历。我难以想象,我今天写就的一篇博文,有可能会在 30 年后帮助到另一个人,这会让我永葆写作的热情......

就是这篇 (Why do some scripts start with #! ... ?)写于 1992 年的文章帮助我找到事实的真相。

简单概括一下就是早在 Unix 时代,为了不让内核什么东西都拿来执行,程序员们发明了 “magic number”,通过 magic number 内核可以辨别出哪些是可执行程序,在文件不可执行时抛出 ENOEXEC 错误,但是 shell 代码扩充了这项功能,在收到 ENOEXEC 失败后会去使用 “/bin/sh” 尝试将其作为 shell 脚本去执行,所以脚本执行是由 shell 来完成的,而不是内核,代码逻辑大概像这样:

1
2
3
4
5
6
7
8
9
10
/* try to run the program */
execl(program, basename(program), (char *)0);

/* the exec failed -- maybe it is a shell script? */
if (errno == ENOEXEC)
execl ("/bin/sh", "sh", "-c", program, (char *)0);

/* oh no mr bill!! */
perror(program);
return -1;

后来,伯克利的一些 guys 扩充了内核的功能,使其可以识别魔数 “#!”,如果内核读到 #! 则将继续读取该行的剩余部分,并将其作为命令去解释运行文件中的内容。

试想,当你执行一个没有正确填写 Shebang 的脚本文件的时候,shell 很可能会给你报一个没有执行权限的错误,当你依照错误提示给予 +x 权限的时候,你很可能收到更多的错误,原因很可能是你正在编写一个 python 脚本。

乖乖的写 Shebang 吧 !

事实上,后来我在 Bash 的文档中也找到了相关描述:

this execution fails because the file is not in executable format, and the file is not a directory, it is assumed to be a shell script and the shell executes it as described in Shell Scripts.

也许这一节描述没有燃起你的兴奋点,因为我假设你对 forkexec 函数族以及虚拟内存有所了解,如果你不了解的话可以参考下面我给出的链接:

  • Unix/Linux fork前传
  • Linux fork那些隐藏的开销
  • Fork三部曲之clone的诞生
  • 深入理解计算机系统 第9章 Virtual Memory

login、non-login、interactive 、non-interactive 与 Startup Files

因为 shell 可以运行在交互模式和非交互模式下,并且有 login 和 non-login 的情况,所以每一种组合他们读取并执行的 Startup Files 都有所不同,下面我给出一幅图来展示各种不同的情况:

bash and startup files

所谓的 login & interactive 模式我举两个例子,一个是我们登录 Linux 字符界面的时候,输入用户名密码进入的那个 shell 就是登录交互式的,另一个就是我们使用 sshd 服务远程登录,在输入用户名密码后获得的 shell 也是登录交互式的。

对于非交互式的 shell 典型的情况就是执行脚本啦,而在执行脚本的时候可以通过添加 --login 或者 -l 的选项来使这个 shell 去读取 Startup Files,因为它没有输入口令的登录动作,只有读取和执行 Startup Files 。

另外,你在 X Windows 下运行 terminal 软件打开的 shell 是 non-login & interactive 模式的。如果你曾有在视窗下打开 shell 却无法获取 ~/bash_profile 中定义的变量的疑惑的话,现在你可以释然了(尤其是使用图形界面安装过 Oracle 的 DBA 们,你们是否也好奇:明明设置好了环境变量,为何每次在图形界面下执行dbca都会找不到命令,还必须手动执行以下source ~/.bash_profile)。

来做个实验吧,我事先在 /etc/profile/etc/bashrc~/.bash_profile~/.bashrc 中增加了 echo “Hello from xxxx” 的语句,让我们来看看各种情况下我们得到的 shell 到底执行了哪些文件:

  1. sshd

    1
    2
    3
    4
    5
    6
    {11:07}~ ➭ ssh root@192.168.1.41
    Last login: Wed Dec 4 10:44:06 2019 from 192.168.1.183
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile

    ~/.bash_profile 调用了 ~/.bashrc, ~/.bashrc 调用了 /etc/bashrc,所以 shell 调用的是/etc/profile 和 ~/.bash_profile

  2. GUI Terminal

    terminal bash

    GUI 下打开 shell 只运行了 ~/.bashrc

  3. 运行 bash

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [root@afis-db ~]# bash
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    [root@afis-db ~]# exit
    exit
    [root@afis-db ~]# bash -l
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile

    默认情况下,bash 命令进入的是一个非登录的交互式子 shell,当使用 -l--login 选项后进入的是登录的交互式子 shell 。

  4. su

    su 的功能是切换用户,其中 - 选项表示登录,一个登录的 shell 在 ps 中显示为-bash,非登录的显示为 bash

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    [root@afis-db ~]# su - oracle
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile
    [oracle@afis-db ~]$ echo $$
    11935
    [oracle@afis-db ~]$ ps -ef|grep 11935
    oracle 11935 11934 0 13:22 pts/1 00:00:00 -bash
    oracle 11960 11935 0 13:22 pts/1 00:00:00 ps -ef
    oracle 11961 11935 0 13:22 pts/1 00:00:00 grep 11935
    [oracle@afis-db ~]$ exit
    logout
    [root@afis-db ~]# su oracle
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    [oracle@afis-db root]$ echo $$
    11965
    [oracle@afis-db root]$ ps -ef|grep 11965
    oracle 11965 11964 0 13:22 pts/1 00:00:00 bash
    oracle 11982 11965 4 13:22 pts/1 00:00:00 ps -ef
    oracle 11983 11965 0 13:22 pts/1 00:00:00 grep 11965
  5. 运行脚本—非交互模式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [root@afis-db ~]# ./script.sh 
    I am script!
    [root@afis-db ~]# bash script.sh
    I am script!
    [root@afis-db ~]# bash -l script.sh
    Hello from /etc/profile
    Hello from /etc/bashrc
    Hello from ~/.bashrc
    Hello from ~/.bash_profile
    I am script!

    可见在非交互模式下不会读取任何文件,增加了登录选项则会依次读取 Startup Files。

我们很少以 bash script.sh 这种方式执行脚本,更多的是以 ./script.sh运行,当以后一种方式执行时,真正执行脚本的解释器依赖于具体的 Shebang。而我们经常看到使用 sh script.sh 这样的方式执行,那 sh 究竟是什么呢?

在大多数 Linux 发行版中,sh 通常是 bash 的软连接,但是 bash 文章中有如下描述:

If Bash is invoked with the name sh, it tries to mimic the startup behavior of historical versions of sh as closely as possible, while conforming to the POSIX standard as well.

When invoked as sh, Bash enters POSIX mode after the startup files are read.

意思是当你用 sh 来启动 shell 的时候,bash 会以 posix 标准模式运行,就如同调用了 bash --posix。需要注意的是:**sh 并不是一个具体的 shell 实现,而是一种规格标准,bash 在这种模式下运行的时候,将遵循 posix 的标准去读取执行文件**。如下图所示:

sh and startup files

来实地验证一下:

  1. 运行 sh

    1
    2
    3
    4
    5
    6
    [root@afis-db ~]# sh
    sh-4.1# exit
    exit
    [root@afis-db ~]# sh -l
    Hello from /etc/profile
    Hello from ~/.profile.(this file is touched by me)
  2. 执行脚本

    1
    2
    3
    4
    5
    6
    [root@afis-db ~]# sh script.sh 
    I am script!
    [root@afis-db ~]# sh -l script.sh
    Hello from /etc/profile
    Hello from ~/.profile.(this file is touched by me)
    I am script!

因为生产服务器上使用 Bash 居多,而线上服务多少都依托于 shell 去调用,因为不同的调用方式下 shell 读取执行文件的规则不同,这样就可能对应用造成一定程度的困扰。我曾经维护一个线上的 java 项目,这个项目有上百个 java 服务需要每天定时重启,项目上线时反复检查验证了 cron 服务的配置以确保万无一失,而没有想到启停脚本对环境变量 JAVA_HOME 的依赖会在 cron 调用的时候失效。如今看来,只要使用登录非交互模式即可。

总结

本片文章细节太过零散,去验证以及查阅资料花了不少时间,其实写作的最初兴奋点是想从 fork & exec 的角度去理解 Linux 上各种执行程序的方式,但是回头一看,关于 fork 和 exec 的介绍只有寥寥几笔,剩下的都是关于细节的追求与验证,但是 Done is better than perfect

因能力有限,行文或有疏漏与错误之处,望阅读本文的朋友给予斧正,也希望了解其它启动方式的朋友不吝赐教。

参考文章:

  1. Why do some scripts start with #! ... ?
  2. Bash Reference Manual
  3. 浅析 Linux 初始化 init 系统——sysvinit
  4. 浅析 Linux 初始化 init 系统——Systemd
  5. Linux 的启动流程
  6. LINUX PID 1 和 SYSTEMD
  7. Difference between a 'terminal', a 'shell', a 'tty' and a 'console'?
  8. 为什么执行自己的程序时需要加上点斜杠
  9. Shebang
  10. Java class file
  11. 为 a.out 举行一个特殊的告别仪式
  12. Linux cron运行原理
  13. 《程序员的自我修养》

左耳朵耗子说过一段话,我深以为然。

技术的发展过程非常重要,要尽可能早的进入时下的新技术,而不应等待这些技术成熟之后再进入。我进入 Go 和 Docker 的技术不能算早,但也不算晚,从 2012 年学习 Go,到 2013 年学习 Docker 到今天,我清楚地看到了这两种技术的生态圈发展过程。让我收获最大的并不是这些技术本身,而是一个技术的变迁和行业的发展。从中,我看到了非常具体的各种思潮和思路,这些东西比起 Go 和 Docker 来说更有价值。因为,这不但让我重新思考我已掌握的技术以及如何更好地解决已有的问题,而且还让我看到了未来。

改变世界的“箱子”

1956 年 4 月 26 号,麦克莱恩的第一艘集装箱货船理性X号从纽约港起航,他无从预想,这个简陋的发明,在未来几十年内会彻底改变人类的命运,将人类的航运业带入了一个巨大的新时代。

集装箱的发明,极大地推动了全球化的协作浪潮,使得制造业的产业链条发生了翻天覆地的变化。从集装箱规模化应用的那一刻,地球变成了一座巨大的工厂,开始轰鸣了起来。

因为集装箱,商品以及原材料可以以极低的运输成本送往世界各地,从而使得全球的资源都卷入到了分工链条当中,经济学家讲:分工产生效能,因为生产效能的极大提升,人类创造了不可胜数的财富。而集装箱的影响依然在持续,依然在不断的改变着我们每一个人的生活。

到了 80 年代,突然大门一开,进来一个胖子,就是我们中国人。我们啥都没有,我们没有资金没有技术,但是我们有勤劳的双手,我们有庞大的人口存量,所以我们用这个优势进入到了全球化的分工当中。

结果呢,中国用 35 年的时间完成了西方 300 多年的工业革命进程,而这已经是中国第四次进行工业化革命的尝试了,你能说集装箱没有功劳么。

可是,像这样具有伟大意义的发明,在其问世的前十年可谓历尽坎坷,并没有让麦克莱恩占据领航者的地位。甚至最早的集装箱都不是麦克莱恩发明的,但他却是集装箱真正意义上的践行者。因此,人类最终将集装箱之父的荣誉授予他。

《经济学家》这样评价:如果没有集装箱,就不会有全球化!

如果你打开 Google 翻译翻译一下“集装箱”这个词,你会得到如下的答案:

Container

在英文中一词多义可能表示多义之间具有意义的延伸

容器 不正是如此么?

第一道灵光

让我们将历史拨回到 1979 年,贝尔实验室的一群大男孩亦或是老男孩正在开发 unix v7(Version 7 Unix) 操作系统。项目进行到了最后的开发和测试阶段,即便是对于这些天才般的程序员来说,系统级别的软件构建和测试仍然是一个繁复且无比棘手的难题。你可以想象:当一次测试开始,源代码编译和安装完成之后,整个测试环境就被“污染”了。要想进行下一次测试,就需要重新搭建,并且再编译安装测试。那有没有办法来避免重复的环境搭建,快速的销毁和重建所需的基础设施环境呢?

我们今天有虚拟化和云计算,这个问题听起来好像不难解决。但是,在那个计算机软件刚刚萌芽的蛮荒时代,这样的想法近乎是异想天开。一块 64K 的内存条要卖 419 美元的年代,“快速销毁和重建基础设施”的想法还是有点“科幻”了。

但天才毕竟是天才,这些黑客程序员们就想,能不能在现有的操作系统环境下“隔离”出一个新环境用于软件的构建和测试呢?

于是,一个叫做 chroot(Change Root) 的系统调用就此诞生了!

chroot 会重定向进程及其子进程的根目录到操作系统上的一个新位置,使得该进程的文件“视图”与外面的“世界”完全隔离,它不能够对这个指定根目录之外的文件进行访问动作,不能读取,也不能更改它的内容。

这是容器史上第一道乍现的灵光,在 unix v7 上面被孕育出来。

但令人叹息的是,unix v7 是贝尔实验室发布的最后一个可自由分发的版本,之后 AT&T 开始收回 Unix 的版权,倾情弹奏 Unix 商业化的序曲。也正因为此, Richard Matthew Stallman 在 1983 年发起 GUN(GNU's Not UNIX) 计划和自由软件运动,几十年后垂垂老矣的 Unix 最终被商业的 Linux 击溃。

然而,chroot 毕竟打开了进程隔离的大门,虽然孕育它的 Unix 在后续的发展中逐渐式微,但容器化的思想却如同奔流不息的河流一般,跨越了重重艰难险阻,不断地在历史的精彩处漫延。

百家争鸣

2000 年,Unix伯克利大学分发版 FreeBSD 操作系统发布了 jail 命令。jail 是从 chroot 得到的启发,并从中进一步发展而来,它将隔离扩展到了整个用户环境,使得进程在一个沙盒内运行。在进程看来,跟实际的操作系统几乎是一样的,对于进程来说就像被关进了监狱,这也是 jail 名称的由来,jail 中的进程甚至可以拥有自己的 IP 地址,可以对环境进行各种定制。我们可以说, chroot 开创了进程隔离的思想,但 FreeBSD Jails 才真正实现了进程的沙箱化

2004年,Solaris Containers 发布,它也是秉承了 jail 的思想,为进程提供一个隔离的沙盒,进程在其中独立运行。它也被称为“吃了类固醇的 chroot ”(chroot on steroids)

不过,无论是 FreeBSD Jails ,还是紧接着出现的 Solaris Containers ,都没有能在更广泛的软件开发和交付场景中扮演到更重要的角色。在这段属于 Jails 们的时代,进程沙箱技术因为“云”的概念尚未普及,始终被局限在了小众而有限的世界里。就如同集装箱刚刚出现的那十年,所有和集装箱配套的设施都还没有,整个社会都还没有为集装箱做好准备。你看,他们两者不仅意义上相近,就连命运也如出一辙。

可见,任何一种新技术,即便问世很早,可要整个社会系统去适应它,却需要一个无比漫长的过程。

让我们回到容器技术上来,事实上,在 Jails 大行其道的这几年间,同样在迅速发展的 Linux 阵营上也陆续出现多个类似的沙箱技术比如 Linux VServerOpen VZ (未进入内核主干)。但如同那些 jail 的前辈一样,受当时计算机环境的制约,被局限在一个小众的圈子里。

早在 2002 年,Plan 9 from Bell Labs(Go语言的运行时就是使用的该操作系统的汇编器语法)操作系统对 Namespace 的广泛运用为 Linux 带来了灵感, Linux 在其内核 2.4.19 版本上加入了 Namesapce 功能(可以实现对应资源的隔离)。最初只有mount namespace,后续的pidnetipcUTSuser等一直到内核3.8版本才实现完成。内核4.6中又添加了Cgroup namespace

要知道 Namespace 是现代容器技术最底层的技术支撑。它虽然解决了虚拟化和资源环境隔离的问题,但是我们还希望对隔离的进程在资源使用上加以限制,namespace并没有提供解决方案。

2007年,一种名叫 Process Container 技术的发布。它是由 Google 的工程师 Paul MenageRohit Seth 发起并实现的,旨在对一组进程进行资源上的隔离、限制。在合并入 Linux 内核的时候,由于 Linux 中存在 Container 的概念,故被重命名为 Cgroups

Cgroups 有两个版本,v1 版本由 Paul Menage Rohit Seth 维护。v2 版本首次出现在 2016 年 3 月发布的内核 4.5 中,Tejun Heo 重新设计并重写了Cgroups,主要解决 v1 在用户体验上的问题。

2008 年,通过将 Cgroups 的资源管理能力和 Linux Namespace 的视图隔离能力组合在一起, LXC(Linux Container) 这样的完整的容器技术出现在了 Linux 内核当中。它是第一个完善的容器管理方案,你可以通过 LXC 来创建和启动容器了。 LXC 跟之前出现的沙盒技术非常类似,但其赶上了 Linux 大规模商用的浪潮,境遇要比那些前辈们要好一些。伴随着公有云市场的崛起,很快催生了一个全新的、名为 PaaS 的产业。

2011 年,由 Vmware 主导的 Cloud Foundry 开发了一个新项目:Warden,它最开始是一个 LXC 的封装,后来重构成了直接对 Cgroups 以及 Linux Namespace 操作的架构。

Cloud Foundry 项目的诞生,第一次对 PaaS 的概念完成了清晰而完整的定义。这其中,“PaaS 项目通过对应用的直接管理、编排和调度让开发者专注于业务逻辑而非基础设施”,以及“PaaS 项目通过容器技术来封装和启动应用”等理念,也第一次出现在云计算产业当中并得到认可。

按照这个剧本,容器技术以及云计算的发展,理应向着 PaaS 的和以应用为中心的方向继续演进下去。

如果不是有一家叫做 Docker 的公司出现的话。

另一只”箱子“

时间来到了 2013 年,Docker 的第一个版本发布,它是基于 LXC 的,但它创建和使用应用容器的逻辑跟 Warden 等没有本质不同。只不过是把 LXC 复杂的创建和使用方式简化成了自己的一套命令体系。但是 Docker 作为 PaaS 行业的搅局者,其真正的杀手锏是容器镜像。Docker 通过镜像技术,提出了buildshiprun的概念,创造了一次构建、处处运行的新思想,将容器技术向IT产业链条的上游和下游进行了延伸。

关于如何封装应用,这本身不是开发者所关心的事情,所以 PaaS 项目有着无数的发挥空间。但到这如何定义应用这个问题,就是跟每一位技术人员息息相关了。在那个时候,Cloud Foundry 给出的方法是 Buildpack ,它是一个应用可运行文件(比如 WAR 包)的封装,然后在里面内置了 Cloud Foundry 可以识别的启动和停止脚本,以及配置信息。

然而,Docker 项目通过容器镜像,直接将一个应用运行所需的完整环境,即:整个操作系统的文件系统也打包了进去。这种思路,可算是解决了困扰 PaaS 用户已久的一致性问题,制作一个“一次构建、处处运行”的 Docker 镜像的意义,一下子就比制作一个连开发和测试环境都无法统一的 Buildpack 高明了太多。

更为重要的是,Docker 项目还在容器镜像的制作上引入了“层”的概念,这种基于“层”(也就是“commit” ) 进行 buildpushupdate 的思路,显然是借鉴了 Git 的思想。所以这个做法的好处也跟 Github 如出一辙:制作 Docker 镜像不再是一个枯燥而乏味的事情,因为通过 DockerHub 这样的镜像托管仓库,你和你的软件立刻就可以参与到全世界软件分发的流程当中了。

至此,你就应该明白,Docker 项目实际上解决的确实是一个更高维度的问题:软件究竟应该通过什么样的方式进行交付?

更重要的是,一旦当软件的交付方式定义的如此清晰并且完备的时候,利用这个定义在去做一个托管软件的平台比如 PaaS,就变得非常简单而明了了。这也是为什么 Docker 项目会多次表示自己只是“站在巨人肩膀上”的根本原因:没有最近十年 Linux 容器等技术的提出与完善,要通过一个开源项目来定义并且统一软件的交付历程,恐怕如痴人说梦。

然而,这并没有使 Docker 站在领导者的位置上。CoreOS 是一个专注于容器的操作系统,一度与 Docker 打的很火热,但是 CoreOS 渐渐的发现 Docker 野心很大,甚至动了 CoreOS 的市场。至于 Docker 有什么野心,我后面会讲。 CoreOS 不肯坐以待毙,于是在 2014 年推出了新的容器引擎 rocket 。后来谷歌开始支持 CoreOS ,容器就此分化成了 Docker 阵营和 Google 阵营。也就在同一年,Docker 0.9 发布,用 libcontainer 库代替了原来的 LXC 。

到了 2015 年,容器圈太乱了,大家觉得长此以往不利于发展,于是就组织起来,在 Linux 基金会的支持下成立了 OCI(Open Constitution Initiative)OCI 致力于围绕容器镜像格式容器运行时建立开发的行业标准,让容器可以在各种兼容性的操作系统和平台上移植,没有人为的技术屏障。大家并不希望这个工业标准由 Docker 一家说了算。

2016 年 Docker 发布了 1.11 版本,做了一些架构调整,里面新出现了符合 OCI 标准的 runC 。runC 其实就是对 libcontainer 的调用,是一种符合开放式容器格式标准的一种实现,后来 Docker 就把 runC 贡献出来了。

下图是 2018 年容器市场份额统计:

Container Market Shares

可见 2018 年的容器市场 Docker 占了 83% ,这个数据乍看之下相当可观,可是你要知道在 2017 年这个份额还是99%。位居第二的就是占比12%的 rocket ,第三是 mesos ,占比4%,第四是我们介绍过的 LXC 占比仅有1%。

现在,我们可以得出一个结论,**Docker 并不等于容器**。如果你想用容器,其实你有很多选择,Docker 仅仅是你的首选而已。如今下一代容器架构的呼声言犹在耳( Podman + Skopeo + Buildah ),我们不禁要问了:既然 Docker 这么优秀,为什么会落到如此众叛亲离的地步呢?

编排大战

如今我们知道,现代容器的底层技术为 NamespaceCgroups ,Docker 因为带来了革命性的容器镜像思想而异军突起,在容器领域大张挞伐,并在短时间内挤占市场。虽然表面上风光无限,却终究难掩容器技术门槛偏低的事实,也就是说任何公司和组织都可以利用 NamespaceCgroups 进入容器市场(比如 CoreOS 的 rocket ), Docker 更是深谙此道,因此它必须寻求突破。

试想一下, Docker 容器发端之后,感觉一下子拥有了可以施展黑科技的魔法棒,但为什么只能搞搞开发、测试这种小打小闹的活儿呢?根本没有生产力大爆发啊?好像也没有改变整个行业的协作方式啊?你看,这是不是和集装箱刚刚问世的前十年非常像呢?那个时候整个社会协作体系还没有为集装箱做好准备,配套的道路、桥梁、码头、吊车等设备和基础设施全都没有就绪。

回到 Docker 上,我们不禁要问:我们的码头和吊车,乃至相应的货轮、桥梁准备好了么?全产业界已经接受了以容器镜像为主要形态的软件发布模式了吗?应用的执行都基于容器了吗?分布式以及微服务架构已经非常普及了吗?

显然还没有。

这里我们不得不提一个观点:容器本身并没有价值。就像集装箱一样,本身就是个大铁盒子,它没有太多价值,单单靠它提高不了社会协作的效能。它只有流动起来,才会产生价值,把货物从一个车间运到另一个车间,这种连接才是其价值所在。容器也一样,本身没有太多商业价值,你弄得再完美,你也只能在一个服务器上折腾,翻不出多大的浪花,那怎么样才能产生价值呢?

那就是真正价值所在——容器编排

在既有硬件资源的基础上,启动容器不需要关注具体运行的节点,各个容器之间仍能保持通信,信息在容器之间依然可以流动。这样就拥有了商业价值,容器技术便可以付诸商用,整个软件的开发交付流程就会变得高效和颠覆。所以我们需要的是一个分布式的调度器,其主要功能就是容器编排。

Docker 也深知这一点,当它不顾一切于 2015 年带着 Swarm 挤进编排领域的时候,发现面前立着一座大山: Kubernetes

Kubernetes 源于 Google 内部的 Borg 项目,经 Google 使用 Go 语言重写后,被命名为 Kubernetes,并于 2014 年 6 月开源。Kubernetes 希腊语意思是“舵手”,致力于管理数以万计的容器集群。你看舵手不正是隐喻了方向流动么。因其开头字母和结尾字母之间共有 8 个字,所以简短的称其为k8s

2014 年 k8s 开源之后,同年底 Docker 就设计了 machine+swarm+compose 的组合方案。2015 年 7 月 k8s 发布了第一个商用版本 1.0 ,同一年 Docker 的 Swarm 发布,编排大战已经开始上演了。

2016 年 2 月 Docker 发布了 1.12 版本,它不顾众怒,将 Swarm 强行内置到 Docker 的容器引擎里面,企图利用 Docker 项目在容器领域的领导地位推动 Swarm 的发展。这有点像什么?举个不恰当的例子,我订购了一个集装箱,你却附赠了一个不太好用的货轮。这一下一石惊起千层浪,业界都纷纷谴责 Docker 。同年的 7 月 Apache mesos 发布了 1.0 ,也是一个容器编排框架,至此,KubernetesDocker SwarmApache Mesos 已成三足鼎立之势。

然而,2017 年 9 月,Mesosphere 宣布支持 Kubernetes,这也是迫于用户压力,在对抗和妥协面前,不得不选择后者。

2017 年 10 月,在欧洲的 DockerCon 大会上,Docker 公司 CTO Solomon Hykes 宣布,Docker 的下个版本将支持 Kubernetes,台下观众响起热烈掌声,因为这是容器圈等待已久的消息。

Kubernetes 在众多厂商和开源爱好者的共同努力下迅速崛起,时至今日已成长为容器管理领域的事实标准。Kubernetes 极大推动了云原生领域的发展,被称为影响云计算未来 10 年的技术。

毫无疑问,Kubernetes 已经赢得了容器编排的大战。

Docker 会是改变世界的那只“箱子”吗?

2001 年 5 月 30 号,集装箱之父麦克莱恩去世。全世界所有的集装箱船,不管在哪个港口,不管在全球的哪个角落,都拉响了汽笛,向这位老人致敬,我想这是一个创新者得到的最高荣誉。

那么, Docker 会是改变世界的那只“箱子”吗?

这要看你怎么理解 Docker ,如果我们把它理解为容器技术buildshiprun 这样一次构建,处处运行的理念,那么我相信它是改变世界的箱子。当我们软件发布模式已经切换到容器镜像形态的时候,当我们应用的运行都是基于容器的时候,当分布式的操作系统或者平台已经整装待发的时候,它有什么理由不改变世界呢?我们没法想象那时 IT 世界会是什么样子的,但有一点可以肯定,IT 世界的分工协作方式以及产业链条肯定是一个全新的面貌。

反观 Docker 作为一家公司,它会是改变世界的箱子么?我觉得很大可能不会。

上个世纪 80 年代,在集装箱刚刚发力的时候,麦克莱恩破产了。那他犯了什么错误吗?没有,他就是跑慢了,没有什么实质性的错误。Docker 很可能步他的后尘,但是那又怎么样呢? Docker 已经完成了它的历史使命,在让 IT 的世界工厂运转起来的路上,它已经推了一把,这本身就已经足够了。

总结

早在 2014 年,RedHat 就与 Kubernetes 达成了战略合作关系,宣布全面投入 Kubernetes。在当时的一份官宣中, RedHat 以非常自信的姿态表达了对容器的“颠覆性”创新的认可,并大胆预言 Kubernetes 会在 2015 年后取得应用编排与管理领域的统治地位。

当时业界对这个论断大多不以为然,甚至嗤之以鼻,但今天回过头来再看,预言已经成为事实。

Rancher 的创始人梁胜博士有过一句评论,非常在理:时至今日,在容器技术领域依然有许多创新,只不过这些创新大多发生在 Kubernetes 以及 CNCF 生态系统中了。

是的,比如最近很火的 Service Mesh 的实现 istio , 它就是基于 kubernetes 进行的创新啊。

这有点像以前在单片机上玩汇编,汇编说白了就是单片机上运行的应用程序,但是后来有人写了个超级大的应用程序,那就是操作系统,然后大家就都在操作系统上玩了。而 Kubernetes 不正是云时代分布式的操作系统么?

回到文章开头的主题上,我们应该及早的进入到一个新兴的技术领域,即使 Docker 会在不久的将来被下一代容器取代,我们也仍然有必要去学习它。因为你最终得到的并不是这项技术本身,而是整个技术领域的发展脉络与思潮演变,这才是无比珍贵的东西。因为一旦你对技术的发展有所感知,它就可能会影响到你未来的人生选择。

我们仍然身处在容器化变革的浪潮当中,你无法想象它将来会对你的命运产生什么样的影响。我们每一个人都像是这个湍流中行进的小船,方向决定了你驶向远方还是抵触暗礁。我们唯一能做的,就是拿一根竹篙,根据水流的情况和环境的变化随时的轻轻点那么一下,微微的改变一下我们的航迹。

注:本文很多段落,大段引用了张磊老师的文章,我读过他的《Docker 容器与容器云》,订阅过他的极客时间专栏《深入剖析Kubernetes》,深深佩服其学识之渊博。在有些关于容器发展史的描述中,我基本上引用了原话,因为自知不能写的更好。

参考文章:

  1. Linux namespaces
  2. cgroups
  3. DOCKER基础技术:LINUX CGROUP
  4. Docker 会是改变世界的那只“箱子”吗?
  5. 为什么说 2019,是属于容器技术的时代?
  6. Linux 容器技术史话:从 chroot 到未来
  7. DOCKER基础技术:LINUX NAMESPACE(上)
  8. Linux Namespace : 简介
  9. 下一代容器架构已出,Docker何去何处?
  10. Docker 背后的标准化容器执行引擎——runC
0%