Fork me on GitHub

Python列表

序列

序列指的是一块可存放多个值的连续内存空间,这些值按一定顺序排列,可通过每个值所在位置的编号(称为索引)访问它们。

在 Python 中,序列类型包括字符串、列表、元组、集合和字典,这些序列支持以下几种通用的操作,但比较特殊的是,集合和字典不支持索引、切片、相加和相乘操作。

字符串也是一种常见的序列,它也可以直接通过索引访问字符串内的字符。

序列索引

序列中,每个元素都有属于自己的编号(索引)。从起始元素开始,索引值从 0 开始递增。

除此之外,Python 还支持索引值是负数,此类索引是从右向左计数,换句话说,从最后一个元素开始计数,从索引值 -1 开始。

注意,在使用负值作为列序中各元素的索引值时,是从 -1 开始,而不是从 0 开始。

无论是采用正索引值,还是负索引值,都可以访问序列中的任何元素。

1
2
3
str="hello!"
print(str[0],"==",str[-6])
print(str[5],"==",str[-1])

序列切片

切片操作是访问序列中元素的另一种方法,它可以访问一定范围内的元素,通过切片操作,可以生成一个新的序列。

1
sname[start : end : step]

各个参数的含义:

  • sname:表示序列的名称;
  • start:表示切片的开始索引位置(包括该位置),此参数也可以不指定,会默认为 0,也就是从序列的开头进行切片;
  • end:表示切片的结束索引位置(不包括该位置),如果不指定,则默认为序列的长度;
  • step:表示在切片过程中,隔几个存储位置(包含当前位置)取一次元素,也就是说,如果step的值大于 1,则在进行切片去序列元素时,会“跳跃式”的取元素。如果省略设置step的值,则最后一个冒号就可以省略。
1
2
3
4
5
6
7
str="今天天气真好"
#取索引区间为[0,2]之间(不包括索引2处的字符)的字符串
print(str[:2]) # 今天
#隔 1 个字符取一个字符,区间是整个字符串
print(str[::2]) # 今天真
#取整个字符串,此时 [] 中只需一个冒号即可
print(str[:]) # 今天天气真好

序列相加

Python 中,支持两种类型相同的序列使用+运算符做相加操作,它会将两个序列进行连接,但不会去除重复的元素。

这里所说的“类型相同”,指的是+运算符的两侧序列要么都是列表类型,要么都是元组类型,要么都是字符串。

1
2
str="!"
print("hello " + "world" + str) # hello world!

序列相乘

使用数字n乘以一个序列会生成新的序列,其内容为原来序列被重复n次的结果。

1
2
str="hello"
print(str * 3) # hellohellohello

比较特殊的是,列表类型在进行乘法运算时,还可以实现初始化指定长度列表的功能。

1
2
list = [None] * 5
print(list) # [None, None, None, None, None]

检查元素是否包含在序列中

可以使用in关键字检查某元素是否为序列的成员:

1
value in sequence

其中,value表示要检查的元素,sequence表示指定的序列。

1
2
str="www.baidu.com"
print('c' in str) # True

in关键字用法相同,但功能恰好相反的,还有not in关键字,它用来检查某个元素是否不包含在指定的序列中:

1
2
str="www.baidu.com"
print('c' not in str) # False

和序列相关的内置函数

Python 提供了几个内置函数,可用于实现与序列相关的一些常用操作。

函数 功能
len() 计算序列的长度,即返回序列中包含多少个元素。
max() 找出序列中的最大元素。注意,对序列使用 sum() 函数时,做加和操作的必须都是数字,不能是字符或字符串,否则该函数将抛出异常,因为解释器无法判定是要做连接操作(+ 运算符可以连接两个序列),还是做加和操作。
min() 找出序列中的最小元素。
list() 将序列转换为列表。
str() 将序列转换为字符串。
sum() 计算元素和。
sorted() 对元素进行排序。
reversed() 反向序列中的元素。
enumerate() 将序列组合为一个索引序列,多用在 for 循环中。

list列表

Python 中没有数组,但是加入了列表。列表会将所有元素都放在一对中括号[ ]里面,相邻元素之间用逗号,分隔:

1
[element1, element2, element3, ..., elementn]

格式中,element1 ~ elementn表示列表中的元素,个数没有限制,只要是 Python 支持的数据类型就可以。

列表可以存储整数、小数、字符串、列表、元组等任何类型的数据,并且同一个列表中元素的类型也可以不同。

注意,在使用列表时,虽然可以将不同类型的数据放入到同一个列表中,但通常情况下不这么做,同一列表中只放入同一类型的数据,这样可以提高程序的可读性。

另外,经常用list代指列表,这是因为列表的数据类型就是list,通过type()函数就可以知道:

1
2
type( ["python", 1, [2,3,4] , 3.0] )
<class 'list'>

创建列表

创建列表的方法可分为两种。

1.使用 [] 直接创建列表

使用[]创建列表后,一般使用=将它赋值给某个变量:

1
listname = [element1 , element2 , element3 , ... , elementn]

其中,listname表示变量名,element1 ~ elementn表示列表元素。

1
2
num = [1, 2, 3, 4, 5, 6, 7]
program = ["JS", "Python", "Java"]

另外,使用此方式创建列表时,列表中元素可以有多个,也可以一个都没有:

1
emptylist = []

这表明,emptylist是一个空列表。

2.使用 list() 函数创建列表

Python 还提供了一个内置的函数list(),使用它可以将其它数据类型转换为列表类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#将字符串转换成列表
list1 = list("hello")
print(list1) # ['h', 'e', 'l', 'l', 'o']
#将元组转换成列表
tuple1 = ('Python', 'Java', 'C++', 'JavaScript')
list2 = list(tuple1)
print(list2) # ['Python', 'Java', 'C++', 'JavaScript']
#将字典转换成列表
dict1 = {'a':100, 'b':42, 'c':9}
list3 = list(dict1)
print(list3) # ['a', 'b', 'c']
#将区间转换成列表
range1 = range(1, 6)
list4 = list(range1)
print(list4) # [1, 2, 3, 4, 5]
#创建空列表
print(list()) # []

访问列表元素

列表是 Python 序列的一种,我们可以使用索引访问列表中的某个元素(得到的是一个元素的值),也可以使用切片访问列表中的一组元素(得到的是一个新的子列表)。

1
listname[i]

其中,listname表示列表名字,i表示索引值。列表的索引可以是正数,也可以是负数。

1
listname[start : end : step]

其中,listname表示列表名字,start表示起始索引,end表示结束索引,step表示步长。

1
2
3
4
5
6
7
8
url = list("http://www.baidu.com/python")
#使用索引访问列表中的某个元素
print(url[3]) #使用正数索引
print(url[-4]) #使用负数索引
#使用切片访问列表中的一组元素
print(url[9: 18]) #使用正数切片
print(url[9: 18: 3]) #指定步长
print(url[-6: -1]) #使用负数切片

运行结果:

1
2
3
4
5
p
t
['w', '.', 'b', 'a', 'i', 'd', 'u', '.', 'c']
['w', 'a', 'u']
['p', 'y', 't', 'h', 'o']

删除列表

可以使用del关键字将其删除。

实际开发中并不经常使用del来删除列表,因为 Python 自带的垃圾回收机制会自动销毁无用的列表,即使开发者不手动删除,Python 也会自动将其回收。

1
del listname

其中,listname表示要删除列表的名称。

1
2
3
intlist = [1, 45, 8, 34]
del intlist
print(intlist)

运行结果:

1
2
3
4
Traceback (most recent call last):
File "C:\Users\mozhiyan\Desktop\demo.py", line 4, in <module>
print(intlist)
NameError: name 'intlist' is not defined

list列表添加元素

使用+运算符可以将多个序列连接起来;列表是序列的一种,所以也可以使用+进行连接,这样就相当于在第一个列表的末尾添加了另一个列表。

1
2
3
4
5
6
language = ["Python", "C++", "Java"]
birthday = [1991, 1998, 1995]
info = language + birthday
print(language) # ['Python', 'C++', 'Java']
print(birthday) # [1991, 1998, 1995]
print(info) # ['Python', 'C++', 'Java', 1991, 1998, 1995]

从运行结果可以发现,使用+会生成一个新的列表,原有的列表不会被改变。

+更多的是用来拼接列表,而且执行效率并不高,如果想在列表中插入元素,应该使用下面几个专门的方法。

append()方法添加元素

append()方法用于在列表的末尾追加元素:

1
listname.append(obj)

其中,listname表示要添加元素的列表;obj表示到添加到列表末尾的数据,它可以是单个元素,也可以是列表、元组等。

1
2
3
4
5
6
7
8
9
10
11
l = ['Python', 'C++', 'Java']
#追加元素
l.append('PHP')
print(l)
#追加元组,整个元组被当成一个元素
t = ('JavaScript', 'C#', 'Go')
l.append(t)
print(l)
#追加列表,整个列表也被当成一个元素
l.append(['Ruby', 'SQL'])
print(l)

运行结果为:

1
2
3
['Python', 'C++', 'Java', 'PHP']
['Python', 'C++', 'Java', 'PHP', ('JavaScript', 'C#', 'Go')]
['Python', 'C++', 'Java', 'PHP', ('JavaScript', 'C#', 'Go'), ['Ruby', 'SQL']]

可以看到,当给append()方法传递列表或者元组时,此方法会将它们视为一个整体,作为一个元素添加到列表中,从而形成包含列表和元组的新列表。

extend()方法添加元素

extend()append()的不同之处在于:extend()不会把列表或者元祖视为一个整体,而是把它们包含的元素逐个添加到列表中。

1
listname.extend(obj)

其中,listname指的是要添加元素的列表;obj表示到添加到列表末尾的数据,它可以是单个元素,也可以是列表、元组等,但不能是单个的数字。

1
2
3
4
5
6
7
8
9
10
11
l = ['Python', 'C++', 'Java']
#追加元素
l.extend('C')
print(l)
#追加元组,元祖被拆分成多个元素
t = ('JavaScript', 'C#', 'Go')
l.extend(t)
print(l)
#追加列表,列表也被拆分成多个元素
l.extend(['Ruby', 'SQL'])
print(l)

运行结果:

1
2
3
['Python', 'C++', 'Java', 'C']
['Python', 'C++', 'Java', 'C', 'JavaScript', 'C#', 'Go']
['Python', 'C++', 'Java', 'C', 'JavaScript', 'C#', 'Go', 'Ruby', 'SQL']

insert()方法插入元素

如果希望在列表中间某个位置插入元素,可以使用insert()方法。

1
listname.insert(index , obj)

其中,index表示指定位置的索引值。insert()会将obj插入到listname列表第index个元素的位置。如果index位置在列表中不存在,则将新元素添加至列表结尾。

当插入列表或者元组时,insert()也会将它们视为一个整体,作为一个元素插入到列表中,这一点和append()是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
l = ['Python', 'C++', 'Java']
#插入元素
l.insert(1, 'C')
print(l)
#插入元组,整个元祖被当成一个元素
t = ('C#', 'Go')
l.insert(2, t)
print(l)
#插入列表,整个列表被当成一个元素
l.insert(3, ['Ruby', 'SQL'])
print(l)
#插入字符串,整个字符串被当成一个元素
l.insert(0, "JS")
print(l)

输出结果为:

1
2
3
4
['Python', 'C', 'C++', 'Java']
['Python', 'C', ('C#', 'Go'), 'C++', 'Java']
['Python', 'C', ('C#', 'Go'), ['Ruby', 'SQL'], 'C++', 'Java']
['JS', 'Python', 'C', ('C#', 'Go'), ['Ruby', 'SQL'], 'C++', 'Java']

提示,insert()主要用来在列表的中间位置插入元素,如果你仅仅希望在列表的末尾追加元素,那更建议使用append()extend()

list列表删除元素

列表中删除元素主要分为以下 3 种场景:

  • 根据目标元素所在位置的索引进行删除,可以使用del关键字或者pop()方法;
  • 根据元素本身的值进行删除,可使用列表(list类型)提供的remove()方法;
  • 将列表中所有元素全部删除,可使用列表(list类型)提供的clear()方法。

del:根据索引值删除元素

del不仅可以删除整个列表,还可以删除列表中的某些元素。

del可以删除列表中的单个元素:

1
del listname[index]

其中,listname表示列表名称,index表示元素的索引值。

del也可以删除中间一段连续的元素:

1
del listname[start : end]

其中,start表示起始索引,end表示结束索引。del会删除从索引startend之间的元素,不包括end位置的元素。

1
2
3
4
5
6
7
lang = ["Python", "C++", "Java", "PHP", "Ruby", "MATLAB"]
#使用正数索引
del lang[2]
print(lang) # ['Python', 'C++', 'PHP', 'Ruby', 'MATLAB']
#使用负数索引
del lang[-2]
print(lang) # ['Python', 'C++', 'PHP', 'MATLAB']
1
2
3
4
5
6
lang = ["Python", "C++", "Java", "PHP", "Ruby", "MATLAB"]
del lang[1: 4]
print(lang) # ['Python', 'Ruby', 'MATLAB']
lang.extend(["SQL", "C#", "Go"])
del lang[-5: -2]
print(lang) # ['Python', 'C#', 'Go']

pop():根据索引值删除元素

pop()方法用来删除列表中指定索引处的元素:

1
listname.pop(index)

其中,listname表示列表名称,index表示索引值。如果不写index参数,默认会删除列表中的最后一个元素,类似于数据结构中的“出栈”操作。

1
2
3
4
5
nums = [40, 36, 89, 2, 36, 100, 7]
nums.pop(3)
print(nums) # [40, 36, 89, 36, 100, 7]
nums.pop()
print(nums) # [40, 36, 89, 36, 100]

大部分编程语言都会提供和pop()相对应的方法,就是push(),该方法用来将元素添加到列表的尾部,类似于数据结构中的“入栈”操作。但是 Python 是个例外,Python 并没有提供push()方法,因为完全可以使用append()来代替push()的功能。

remove():根据元素值进行删除

除了del关键字,Python 还提供了remove()方法,该方法会根据元素本身的值来进行删除操作。

需要注意的是,remove()方法只会删除第一个和指定值相同的元素,而且必须保证该元素是存在的,否则会引发ValueError错误。

1
2
3
4
5
6
7
8
9
10
nums = [40, 36, 89, 2, 36, 100, 7]
#第一次删除36
nums.remove(36)
print(nums)
#第二次删除36
nums.remove(36)
print(nums)
#删除78
nums.remove(78)
print(nums)

运行结果:

1
2
3
4
5
6
[40, 89, 2, 36, 100, 7]
[40, 89, 2, 100, 7]
Traceback (most recent call last):
File "C:\Users\mozhiyan\Desktop\demo.py", line 9, in <module>
nums.remove(78)
ValueError: list.remove(x): x not in list

clear():删除列表所有元素

clear()用来删除列表的所有元素,也即清空列表:

1
2
3
url = list("test")
url.clear()
print(url) # []

list列表修改元素

Python 提供了两种修改列表元素的方法,你可以每次修改单个元素,也可以每次修改一组元素(多个)。

修改单个元素

修改单个元素非常简单,直接对元素赋值即可。

1
2
3
4
nums = [40, 36, 89, 2, 36, 100, 7]
nums[2] = -26 #使用正数索引
nums[-3] = -66.2 #使用负数索引
print(nums) # [40, 36, -26, 2, -66.2, 100, 7]

使用索引得到列表元素后,通过=赋值就改变了元素的值。

修改一组元素

Python 支持通过切片语法给一组元素赋值。在进行这种操作时,如果不指定步长(step参数),Python 就不要求新赋值的元素个数与原来的元素个数相同;这意味,该操作既可以为列表添加元素,也可以为列表删除元素。

1
2
3
4
nums = [40, 36, 89, 2, 36, 100, 7]
#修改第 1~4 个元素的值(不包括第4个元素)
nums[1: 4] = [45.25, -77, -52.5]
print(nums) # [40, 45.25, -77, -52.5, 36, 100, 7]

如果对空切片(slice)赋值,就相当于插入一组新的元素:

1
2
3
4
nums = [40, 36, 89, 2, 36, 100, 7]
#在4个位置插入元素
nums[4: 4] = [-77, -52.5, 999]
print(nums) # [40, 36, 89, 2, -77, -52.5, 999, 36, 100, 7]

使用切片语法赋值时,Python 不支持单个值,下面的写法就是错误的:

1
nums[4: 4] = -77

但是如果使用字符串赋值,Python 会自动把字符串转换成序列,其中的每个字符都是一个元素:

1
2
3
s = list("Hello")
s[2:4] = "XYZ"
print(s) # ['H', 'e', 'X', 'Y', 'Z', 'o']

使用切片语法时也可以指定步长(step参数),但这个时候就要求所赋值的新元素的个数与原有元素的个数相同:

1
2
3
4
nums = [40, 36, 89, 2, 36, 100, 7]
#步长为2,为第1、3、5个元素赋值
nums[1: 6: 2] = [0.025, -99, 20.5]
print(nums) # [40, 0.025, 89, -99, 36, 20.5, 7]

list列表查找元素

列表提供了index()count()方法,它们都可以用来查找元素。

index() 方法

index()方法用来查找某个元素在列表中出现的位置(也就是索引),如果该元素不存在,则会导致ValueError错误,所以在查找之前最好使用count()方法判断一下。

1
listname.index(obj, start, end)

其中,listname表示列表名称,obj表示要查找的元素,start表示起始位置,end表示结束位置。

startend参数用来指定检索范围:

  • startend可以都不写,此时会检索整个列表;
  • 如果只写start不写end,那么表示检索从start到末尾的元素;
  • 如果startend都写,那么表示检索startend之间的元素。

index()方法会返回元素所在列表中的索引值。

1
2
3
4
5
6
7
8
9
nums = [40, 36, 89, 2, 36, 100, 7, -20.5, -999]
#检索列表中的所有元素
print( nums.index(2) )
#检索3~7之间的元素
print( nums.index(100, 3, 7) )
#检索4之后的元素
print( nums.index(7, 4) )
#检索一个不存在的元素
print( nums.index(55) )

运行结果:

1
2
3
4
5
6
7
3
5
6
Traceback (most recent call last):
File "C:\Users\mozhiyan\Desktop\demo.py", line 9, in <module>
print( nums.index(55) )
ValueError: 55 is not in list

count()方法

count()方法用来统计某个元素在列表中出现的次数:

1
listname.count(obj)

其中,listname代表列表名,obj表示要统计的元素。

如果count()返回 0,就表示列表中不存在该元素,所以count()也可以用来判断列表中的某个元素是否存在。

1
2
3
4
5
6
7
8
nums = [40, 36, 89, 2, 36, 100, 7, -20.5, 36]
#统计元素出现的次数
print("36出现了%d次" % nums.count(36))
#判断一个元素是否存在
if nums.count(100):
print("列表中存在100这个元素")
else:
print("列表中不存在100这个元素")

运行结果:

1
2
36出现了3次
列表中存在100这个元素

Python变量类型

变量的赋值

变量的值不是一成不变的,它可以随时被修改,只要重新赋值即可;另外你也不用关心数据的类型,可以将不同类型的数据赋值给同一个变量。

1
2
3
4
5
n = 10  #将10赋值给变量n
n = 95 #将95赋值给变量n
abc = 12.5 #将小数赋值给变量abc
abc = 85 #将整数赋值给变量abc
abc = "hello" #将字符串赋值给变量abc

注意,变量的值一旦被修改,之前的值就被覆盖了,不复存在了,再也找不回了。换句话说,变量只能容纳一个值。

除了赋值单个数据,你也可以将表达式的运行结果赋值给变量:

1
2
sum = 100 + 20  #将加法的结果赋值给变量
rem = 25 * 30 % 7 #将余数赋值给变量

变量的使用

使用 Python 变量时,只要知道变量的名字即可。

1
2
3
4
5
6
7
8
9
n = 10
print(n) #将变量传递给函数
# 10
m = n * 10 + 5 #将变量作为四则运算的一部分
print(m) # 105
print(m - 30) #将由变量构成的表达式作为参数传递给函数
# 75
m = m * 2 #将变量本身的值翻倍
print(m) # 210

Python 是弱类型的语言

在强类型的编程语言中,定义变量时要指明变量的类型,而且赋值的数据也必须是相同类型的。和强类型语言相对应的是弱类型语言,Python、JavaScript、PHP 等脚本语言一般都是弱类型的。

弱类型语言有两个特点:

  • 变量无须声明就可以直接赋值,对一个不存在的变量赋值就相当于定义了一个新变量。
  • 变量的数据类型可以随时改变,比如,同一个变量可以一会儿被赋值为整数,一会儿被赋值为字符串。

注意,弱类型并不等于没有类型!弱类型是说在书写代码时不用刻意关注类型,但是在编程语言的内部仍然是有类型的。我们可以使用type()内置函数类检测某个变量或者表达式的类型:

1
2
3
4
5
6
7
num = 10
type(num) # <class 'int'>
num = 15.8
type(num) # <class 'float'>
num = 20 + 15j
type(num) # <class 'complex'>
type(3*15.6) # <class 'float'>

整数类型(int)

整数就是没有小数部分的数字,Python 中的整数包括正整数、0 和负整数。

Python 的整数不分类型,或者说它只有一种类型的整数。Python 整数的取值范围是无限的。

当所用数值超过计算机自身的计算能力时,Python 会自动转用高精度计算(大数计算)。

1
2
3
4
5
6
7
8
9
10
11
12
#将 78 赋值给变量 n
n = 78
print(n) # 78
print(type(n)) # <class 'int'>
#给x赋值一个很大的整数
x = 8888888888888888888888
print(x) # 8888888888888888888888
print(type(x)) # <class 'int'>
#给y赋值一个很小的整数
y = -7777777777777777777777
print(y) # -7777777777777777777777
print(type(y)) # <class 'int'>

不管对于多大或者多小的整数,Python 只用一种类型存储,就是int

整数的不同进制

可以使用多种进制来表示整数:

  1. 十进制形式:由 0~9 共十个数字排列组合而成。
  2. 二进制形式:由 0 和 1 两个数字组成,书写时以0b0B开头。例如,101 对应十进制数是 5。
  3. 八进制形式:八进制整数由 0~7 共八个数字组成,以0o0O开头。注意,第一个符号是数字 0,第二个符号是大写或小写的字母O
  4. 十六进制形式:由 09 十个数字以及 AF(或 a~f)六个字母组成,书写时以0x0X开头,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#十六进制
hex1 = 0x45
hex2 = 0x4Af
print("hex1Value: ", hex1) # hex1Value: 69
print("hex2Value: ", hex2) # hex2Value: 1199
#二进制
bin1 = 0b101
print('bin1Value: ', bin1) # bin1Value: 5
bin2 = 0B110
print('bin2Value: ', bin2) # bin2Value: 6
#八进制
oct1 = 0o26
print('oct1Value: ', oct1) # oct1Value: 22
oct2 = 0O41
print('oct2Value: ', oct2) # oct2Value: 33

数字分隔符

为了提高数字的的可读性,Python 3.x 允许使用下划线_作为数字(包括整数和小数)的分隔符。通常每隔三个数字添加一个下划线,类似于英文数字中的逗号。下划线不会影响数字本身的值。

1
2
distance = 384_000_000
print("地球和月球的距离:", distance) # 地球和月球的距离:384000000

小数/浮点数(float)

小数通常以浮点数的形式存储。浮点数和定点数是相对的:小数在存储过程中如果小数点发生移动,就称为浮点数;如果小数点不动,就称为定点数。

Python 中的小数有两种书写形式:

  1. 十进制形式:例如 34.6、346.0、0.346。书写小数时必须包含一个小数点,否则会被当作整数处理。
  2. 指数形式:小数的指数形式的写法为:aEnaena为尾数部分,是一个十进制数;n为指数部分,是一个十进制整数;Ee是固定的字符,用于分割尾数部分和指数部分。整个表达式等价于 a×10n

指数形式的小数举例:

  • 2.1E5 = 2.1×105,其中 2.1 是尾数,5 是指数。
  • 3.7E-2 = 3.7×10-2,其中 3.7 是尾数,-2 是指数。
  • 0.5E7 = 0.5×107,其中 0.5 是尾数,7 是指数。

注意,只要写成指数形式就是小数,即使它的最终值看起来像一个整数。例如14E3等价于 14000,但 14E3 是一个小数。

Python 只有一种小数类型,就是float

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
f1 = 12.5
print("f1Value: ", f1) # f1Value: 12.5
print("f1Type: ", type(f1)) # f1Type: <class 'float'>
f2 = 0.34557808421257003
print("f2Value: ", f2) # f2Value: 0.34557808421257
print("f2Type: ", type(f2)) # f2Type: <class 'float'>
f3 = 0.0000000000000000000000000847
print("f3Value: ", f3) # f3Value: 8.47e-26
print("f3Type: ", type(f3)) # f3Type: <class 'float'>
f4 = 345679745132456787324523453.45006
print("f4Value: ", f4) # f4Value: 3.456797451324568e+26
print("f4Type: ", type(f4)) # f4Type: <class 'float'>
f5 = 12e4
print("f5Value: ", f5) # f5Value: 120000.0
print("f5Type: ", type(f5)) # f5Type: <class 'float'>
f6 = 12.3 * 0.1
print("f6Value: ", f6) # f6Value: 1.2300000000000002
print("f6Type: ", type(f6)) # f6Type: <class 'float'>

从运行结果可以看出,Python 能容纳极小和极大的浮点数。print在输出浮点数时,会根据浮点数的长度和大小适当的舍去一部分数字,或者采用科学计数法。

f5的值是 120000,但是它依然是小数类型,而不是整数类型。

f612.3*0.1的计算结果很明显是 1.23,但是print的输出却不精确。这是因为小数在内存中是以二进制形式存储的,小数点后面的部分在转换成二进制时很有可能是一串无限循环的数字,无论如何都不能精确表示,所以小数的计算结果一般都是不精确的。

字符串

若干个字符的集合就是一个字符串(String)。字符串必须由双引号" "或者单引号' '包围:

1
2
"字符串内容"
'字符串内容'

字符串中的双引号和单引号没有任何区别。

处理字符串中的引号的

当字符串内容中出现引号时,我们需要进行特殊处理,否则 Python 会解析出错:

1
'I'm a great coder!'

由于上面字符串中包含了单引号,此时 Python 会将字符串中的单引号与第一个单引号配对,这样就会把'I'当成字符串,而后面的m a great coder!'就变成了多余的内容,从而导致语法错误。

对于这种情况,我们有两种处理方案:

1.对引号进行转义

在引号前面添加反斜杠\就可以对引号进行转义,让 Python 把它作为普通文本对待:

1
2
3
4
str1 = 'I\'m a great coder!'
str2 = "引文双引号是\",中文双引号是“"
print(str1) # I'm a great coder!
print(str2) # 引文双引号是",中文双引号是“

2.使用不同的引号包围字符串

如果字符串内容中出现了单引号,那么我们可以使用双引号包围字符串,反之亦然。

1
2
str1 = "I'm a great coder!"  #使用双引号包围含有单引号的字符串
str2 = '引文双引号是",中文双引号是“' #使用单引号包围含有双引号的字符串

字符串的换行

Python 不是格式自由的语言,它对程序的换行、缩进都有严格的语法要求。要想换行书写一个比较长的字符串,必须在行尾添加反斜杠\

1
2
3
s2 = 'It took me six months to write this Python tutorial. \
Please give me more support. \
I will keep it updated.'

Python 也支持表达式的换行:

1
2
3
num = 20 + 3 / 4 + \
2 * 3
print(num)

长字符串

使用三个单引号或者双引号可以对多行内容进行注释,这其实是 Python 长字符串的写法。所谓长字符串,就是可以直接换行(不用加反斜杠\)书写的字符串。

长字符串由三个双引号"""或者三个单引号'''包围:

1
2
"""长字符串内容"""
'''长字符串内容'''

在长字符串中放置单引号或者双引号不会导致解析错误。

如果长字符串没有赋值给任何变量,那么这个长字符串就不会起到任何作用,和一段普通的文本无异,相当于被注释掉了。

注意,此时 Python 解释器并不会忽略长字符串,也会按照语法解析,只是长字符串起不到实际作用而已。

当程序中有大段文本内容需要定义成字符串时,优先推荐使用长字符串形式,因为这种形式非常强大,可以在字符串中放置任何内容,包括单引号和双引号。

1
2
3
longstr = '''It took me 6 months to write this Python tutorial.
Please give me a to 'thumb' to keep it updated.'''
print(longstr)

长字符串中的换行、空格、缩进等空白符都会原样输出。

Python原始字符串

Python 字符串中的反斜杠\有着特殊的作用,就是转义字符。

转义字符有时候会带来一些麻烦,例如我要表示一个包含 Windows 路径D:\Program Files\Python 3.8\python.exe这样的字符串,在 Python 程序中直接这样写肯定是不行的,不管是普通字符串还是长字符串。因为\的特殊性,我们需要对字符串中的每个\都进行转义,也就是写成D:\\Program Files\\Python 3.8\\python.exe这种形式才行。

为了解决转义字符的问题,Python 支持原始字符串。在原始字符串中,\不会被当作转义字符,所有的内容都保持“原汁原味”的样子。

在普通字符串或者长字符串的开头加上r前缀,就变成了原始字符串:

1
2
str1 = r'原始字符串内容'
str2 = r"""原始字符串内容"""

将上面的 Windows 路径改写成原始字符串的形式:

1
2
rstr = r'D:\Program Files\Python 3.8\python.exe'
print(rstr)

原始字符串中的引号

如果普通格式的原始字符串中出现引号,程序同样需要对引号进行转义,否则 Python 照样无法对字符串的引号精确配对;但是和普通字符串不同的是,此时用于转义的反斜杠会变成字符串内容的一部分。

1
2
str1 = r'I\'m a great coder!'
print(str1) # I\'m a great coder!

需要注意的是,Python 原始字符串中的反斜杠仍然会对引号进行转义,因此原始字符串的结尾处不能是反斜杠,否则字符串结尾处的引号会被转义,导致字符串不能正确结束。

在 Python 中有两种方式解决这个问题:一种方式是改用长字符串的写法,不要使用原始字符串;另一种方式是单独书写反斜杠。

例如想表示D:\Program Files\Python 3.8\,可以这样写:

1
2
str1 = r'D:\Program Files\Python 3.8' '\\'
print(str1) # D:\Program Files\Python 3.8\

我们先写了一个原始字符串r'D:\Program Files\Python 3.8',紧接着又使用'\\'写了一个包含转义字符的普通字符串,Python 会自动将这两个字符串拼接在一起。

bytes类型

bytes类型用来表示一个字节串。

字节串(bytes)和字符串(string)的对比:

  • 字符串由若干个字符组成,以字符为单位进行操作;字节串由若干个字节组成,以字节为单位进行操作。
  • 字节串和字符串除了操作的数据单元不同之外,它们支持的所有方法都基本相同。
  • 字节串和字符串都是不可变序列,不能随意增加和删除数据。

bytes只负责以字节序列的形式(二进制形式)来存储数据,至于这些数据到底表示什么内容(字符串、数字、图片、音频等),完全由程序的解析方式决定。如果采用合适的字符编码方式(字符集),字节串可以恢复成字符串;反之亦然,字符串也可以转换成字节串。

我们可以通过字符串来创建bytes对象,或者说将字符串转换成bytes对象。有以下三种方法可以达到这个目的:

  • 如果字符串的内容都是 ASCII 字符,那么直接在字符串前面添加b前缀就可以转换成bytes
  • bytes是一个类,调用它的构造方法,也就是bytes(),可以将字符串按照指定的字符集转换成bytes;如果不指定字符集,那么默认采用 UTF-8。
  • 字符串本身有一个encode()方法,该方法专门用来将字符串按照指定的字符集转换成对应的字节串;如果不指定字符集,那么默认采用 UTF-8。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#通过构造函数创建空 bytes
b1 = bytes()
#通过空字符串创建空 bytes
b2 = b''
#通过b前缀将字符串转换成 bytes
b3 = b'python'
print("b3: ", b3) # b3: b'python'
print(b3[3]) # 112
print(b3[7:22])
#为 bytes() 方法指定字符集
b4 = bytes('小明8岁了', encoding='UTF-8')
print("b4: ", b4)
#通过 encode() 方法将字符串转换成 bytes
b5 = "小明8岁了".encode('UTF-8')
print("b5: ", b5)

从运行结果可以发现,对于非 ASCII 字符,print输出的是它的字符编码值(十六进制形式),而不是字符本身。非 ASCII 字符一般占用两个字节以上的内存,而bytes是按照单个字节来处理数据的,所以不能一次处理多个字节。

bytes类也有一个decode()方法,通过该方法可以将bytes对象转换为字符串。

1
2
3
#通过 decode() 方法将 bytes 转换成字符串
str1 = b5.decode('UTF-8')
print("str1: ", str1)

bool布尔类型

bool类型表示真(对)或假(错)。TrueFalse是 Python 中的关键字,当作为 Python 代码输入时,一定要注意字母的大小写,否则解释器会报错。

布尔类型可以当做整数来对待,即True相当于整数值 1,False相当于整数值 0。因此,下边这些运算都是可以的:

1
2
3
4
False+1
1
True+1
2
1
2
3
4
5>3
True
4>20
False

在 Python 中,所有的对象都可以进行真假值的测试,包括字符串、元组、列表、字典、对象等。

input()函数

input()是 Python 的内置函数,用于从控制台读取用户输入的内容。input()函数总是以字符串的形式来处理用户输入的内容,所以用户输入的内容可以包含任何字符。

1
str = input(tipmsg)

说明:

  • str表示一个字符串类型的变量,input会将读取到的字符串放入str中。
  • tipmsg表示提示信息,它会显示在控制台上,告诉用户应该输入什么样的内容;如果不写tipmsg,就不会有任何提示信息。
1
2
3
4
5
6
7
a = input("Enter a number: ")
b = input("Enter another number: ")
print("aType: ", type(a))
print("bType: ", type(b))
result = a + b
print("resultValue: ", result)
print("resultType: ", type(result))

运行结果:

1
2
3
4
5
6
Enter a number: 100↙
Enter another number: 45↙
aType: <class 'str'>
bType: <class 'str'>
resultValue: 10045
resultType: <class 'str'>

↙表示按下回车键,按下回车键后input()读取就结束了。

本例中我们输入了两个整数,希望计算出它们的和,但是事与愿违,Python 只是它们当成了字符串,+起到了拼接字符串的作用,而不是求和的作用。

我们可以使用 Python 内置函数将字符串转换成想要的类型:

  • int(string)将字符串转换成int类型;
  • float(string)将字符串转换成float类型;
  • bool(string)将字符串转换成bool类型。

修改上面的代码,将用户输入的内容转换成数字:

1
2
3
4
5
6
7
8
9
a = input("Enter a number: ")
b = input("Enter another number: ")
a = float(a)
b = int(b)
print("aType: ", type(a))
print("bType: ", type(b))
result = a + b
print("resultValue: ", result)
print("resultType: ", type(result))

运行结果:

1
2
3
4
5
6
Enter a number: 12.5↙
Enter another number: 64↙
aType: <class 'float'>
bType: <class 'int'>
resultValue: 76.5
resultType: <class 'float'>

print()函数

1
print(value,...,sep='',end='\n',file=sys.stdout,flush=False)

value参数可以接受任意多个变量或值,因此print()函数完全可以输出多个值。

1
2
3
4
user_name = 'Charlie'
user_age = 8
#同时输出多个变量和字符串
print("读者名:",user_name,"年龄:",user_age) # 读者名: Charlie 年龄: 8

从输出结果来看,使用print()函数输出多个变量时,print()函数默认以空格隔开多个变量,如果希望改变默认的分隔符,可通过sep参数进行设置。

1
2
3
#同时输出多个变量和字符串,指定分隔符
print("读者名:" ,user_name,"年龄:",user_age,sep='|')
# 读者名:|Charlie|年龄:|8

在默认情况下,print()函数输出之后总会换行,这是因为print()函数的end参数的默认值是\n,这个\n就代表了换行。如果希望print()函数输出之后不会换行,则重设end参数即可:

1
2
3
4
#设置end 参数,指定输出之后不再换行
print(40,'\t',end="")
print(50,'\t',end="")
print(60,'\t',end="")

输出结果:

1
40    50    60

file参数指定print()函数的输出目标,file参数的默认值为sys.stdout,该默认值代表了系统标准输出,也就是屏幕,因此print()函数默认输出到屏幕。实际上,完全可以通过改变该参数让print()函数输出到特定文件中:

1
2
3
4
f = open("demo.txt","w")#打开文件以便写入
print('沧海月明珠有泪',file=f)
print('蓝田日暖玉生烟',file=f)
f.close()

上面程序中,open()函数用于打开demo.txt文件,接连 2 个print函数会将这 2 段字符串依次写入此文件,最后调用close()函数关闭文件。

print()函数的flush参数用于控制输出缓存,该参数一般保持为False即可,这样可以获得较好的性能。

格式化字符串

print()函数使用以%开头的转换说明符对各种类型的数据进行格式化输出。

转换说明符 解释
%d、%i 转换为带符号的十进制整数
%o 转换为带符号的八进制整数
%x、%X 转换为带符号的十六进制整数
%e 转化为科学计数法表示的浮点数(e 小写)
%E 转化为科学计数法表示的浮点数(E 大写)
%f、%F 转化为十进制浮点数
%g 智能选择使用 %f 或 %e 格式
%G 智能选择使用 %F 或 %E 格式
%c 格式化字符及其 ASCII 码
%r 使用 repr() 函数将表达式转换为字符串
%s 使用 str() 函数将表达式转换为字符串

转换说明符只是一个占位符,它会被后面表达式(变量、常量、数字、字符串、加减乘除等各种形式)的值代替。

1
2
age = 8
print("小明已经%d岁了!" % age) # 小明已经8岁了!

print()函数中,由引号包围的是格式化字符串,它相当于一个字符串模板,可以放置一些转换说明符(占位符)。本例的格式化字符串中包含一个%d说明符,它最终会被后面的age变量的值所替代。

中间的%是一个分隔符,它前面是格式化字符串,后面是要输出的表达式。

当然,格式化字符串中也可以包含多个转换说明符,这个时候也得提供多个表达式,用以替换对应的转换说明符;多个表达式必须使用小括号( )包围起来。

1
2
3
name = "小明"
age = 8
print("%s已经%d岁了。" % (name, age)) # 小明已经8岁了。

总之,有几个占位符,后面就得跟着几个表达式。

指定最小输出宽度

当使用转换说明符时,可以使用下面的格式指定最小输出宽度(至少占用多少个字符的位置):

  • %10d表示输出的整数宽度至少为 10;
  • %20s表示输出的字符串宽度至少为 20。
1
2
3
4
5
6
7
8
9
10
11
n = 1234567
print("n(10):%10d." % n)
print("n(5):%5d." % n)
url = "python"
print("url(35):%35s." % url)
print("url(20):%20s." % url)
运行结果:
n(10): 1234567.
n(5):1234567.
url(35): python.
url(20): python.

从运行结果可以发现,对于整数和字符串,当数据的实际宽度小于指定宽度时,会在左侧以空格补齐;当数据的实际宽度大于指定宽度时,会按照数据的实际宽度输出。

这里指定的只是最小宽度,当数据的实际宽度足够时,指定的宽度就没有实际意义了。

指定对齐方式

默认情况下,print()输出的数据总是右对齐的。也就是说,当数据不够宽时,数据总是靠右边输出,而在左边补充空格以达到指定的宽度。Python 允许在最小宽度之前增加一个标志来改变对齐方式:

标志 说明
- 指定左对齐
+ 表示输出的数字总要带着符号;正数带 +,负数带 -
0 表示宽度不足时补充 0,而不是补充空格

几点说明:

  • 对于整数,指定左对齐时,在右边补 0 是没有效果的,因为这样会改变整数的值。
  • 对于小数,以上三个标志可以同时存在。
  • 对于字符串,只能使用-标志,因为符号对于字符串没有意义,而补 0 会改变字符串的值。
1
2
3
4
5
6
7
8
9
10
11
n = 123456
# %09d 表示最小宽度为9,左边补0
print("n(09):%09d" % n) # n(09):000123456
# %+9d 表示最小宽度为9,带上符号
print("n(+9):%+9d" % n) # n(+9): +123456
f = 140.5
# %-+010f 表示最小宽度为10,左对齐,带上符号
print("f(-+0):%-+010f" % f) # f(-+0):+140.500000
s = "Hello"
# %-10s 表示最小宽度为10,左对齐
print("s(-10):%-10s." % s) # s(-10):Hello .

指定小数精度

对于小数(浮点数),print()还允许指定小数点后的数字位数,也即指定小数的输出精度。

精度值需要放在最小宽度之后,中间用点号.隔开;也可以不写最小宽度,只写精度。

1
2
%m.nf
%.nf

m表示最小宽度,n表示输出精度,.是必须存在的。

1
2
3
4
5
6
7
f = 3.141592653
# 最小宽度为8,小数点后保留3位
print("%8.3f" % f) # 3.142
# 最小宽度为8,小数点后保留3位,左边补0
print("%08.3f" % f) # 0003.142
# 最小宽度为8,小数点后保留3位,左边补0,带符号
print("%+08.3f" % f) # +003.142

类型转换

常用数据类型转换函数:

函 数 作 用
int(x) 将 x 转换成整数类型
float(x) 将 x 转换成浮点数类型
complex(real,[,imag]) 创建一个复数
str(x) 将 x 转换为字符串
repr(x) 将 x 转换为表达式字符串
eval(str) 计算在字符串中的有效 Python 表达式,并返回一个对象
chr(x) 将整数 x 转换为一个字符
ord(x) 将一个字符 x 转换为它对应的整数值
hex(x) 将一个整数 x 转换为一个十六进制字符串
oct(x) 将一个整数 x 转换为一个八进制的字符串

拥塞控制的一般原理

在计算机网络中的链路容量(即带宽)、交换结点中的缓存和处理机等,都是网络的资源。

在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫做拥塞(congestion)。

拥塞产生的原因

网络拥塞往往是由许多因素引起的。例如:

  • 点缓存的容量太小;
  • 链路的容量不足;
  • 处理机处理的速率太慢;
  • 拥塞本身会进一步加剧拥塞;

出现拥塞的原因:∑ 对资源需求 > 可用资源

拥塞控制与流量控制的区别

所谓拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。拥塞控制所要做 的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机、所有的路由器,以及与降低网络传输性能有关的所有因素。

但TCP连接的端点只要迟迟不能收到对方的确认信息,就猜想在当前网络中的某处很可能发生了拥塞,但 这时却无法知道拥塞到底发生在网络的何处,也无法知道发生拥塞的具体原因。

流量控制往往是指点对点通信量的控制,是个端到端的问题(接收端控制发送端)。流量控制所要做的就是抑制发送端发送数据的速率,以便使接收端来得及接收。

拥塞控制所起的作用

拥塞控制的前提:网络能够承受现有的网络负荷。

实践证明,拥塞控制是很难设计的,因为它是一个动态问题。

分组的丢失是网络发生拥塞的征兆而不是原因。

在许多情况下,甚至正是拥塞控制本身成为引起网络性能恶化、甚至发生死锁的原因。

开环控制和闭环控制

开环控制就是在设计网络时事先将有关发生拥塞的因素考虑周到,力求网络在工作时不产生拥塞。但一旦整个系统运行起来,就不再中途进行改正了。

闭环控制是基于反馈环路的概念,根据网络当前的运行状态采取相应控制措施。

属于闭环控制的有以下几种措施:

  • 监测网络系统,以便检测到拥塞在何时、何处发生。
  • 将拥塞发生的信息传送到可采取行动的地方。
  • 调整网络系统的运行以解决出现的问题。

监测网络的拥塞主要指标有:

  • 由于缺少缓存空间而被丢弃的分组的百分数;
  • 平均队列长度;
  • 超时重传的分组数;
  • 平均分组时延;
  • 分组时延的标准差,等等。

上述这些指标的上升都标志着拥塞的增长。

传递拥塞通知:发送通知拥塞发生的分组;在分组中保留表示拥塞状态的字段;周期性地发出探测分组等。

采取行动的时机过于频繁,会使系统产生不稳定的振荡;过于迟缓地采取行动又不具有任何实用价值。

#解决拥塞的两条思路:增加网络可用资源;减少用户对资源的需求。

TCP 的拥塞控制方法

TCP 采用基于窗口的方法进行拥塞控制。该方法属于闭环控制方法。
TCP发送方维持一个拥塞窗口 cwnd (Congestion Window)
发送端利用拥塞窗口根据网络的拥塞情况调整发送的数据量。
发送窗口大小不仅取决于接收方窗口,还取决于网络的拥塞状况,所以真正的发送窗口值为:

真正的发送窗口值 = Min (接收方窗口值,拥塞窗口值)

控制拥塞窗口的原则:

  • 只要网络没有出现拥塞,拥塞窗口就可以再增大一些,以便把更多的分组发送出去,这样就可以提高网络的利用率。
  • 但只要网络出现拥塞或有可能出现拥塞,就必须把拥塞窗口减小一些,以减少注入到网络中的分组数,以便缓解网络出现的拥塞。

拥塞的判断

重传定时器超时:网络已经发生了拥塞。

收到三个重复的 ACK:预示网络可能会出现拥塞(实际可能还未发生拥塞)。

TCP拥塞控制算法

四种拥塞控制算法:

  • 慢开始 (slow-start)
  • 拥塞避免 (congestion avoidance)
  • 快重传 (fast retransmit)
  • 快恢复 (fast recovery)

慢开始

目的:用来确定网络的负载能力或拥塞程度。
算法的思路:由小到大逐渐增大拥塞窗口数值。
两个变量:
拥塞窗口
初始拥塞窗口值:2 种设置方法。
1 至 2 个最大报文段 (旧标准)
2 至 4 个最大报文段 (RFC 5681)
窗口值逐渐增大。
慢开始门限:防止拥塞窗口增长过大引起网络拥塞。

拥塞窗口 cwnd 控制方法:在每收到一个对新的报文段的确认后,可以把拥塞窗口增加最多一个 SMSS 的数值。
拥塞窗口 cwnd 每次的增加量 = min (N, SMSS)

其中 N 是原先未被确认的、但现在被刚收到的确认报文段所确认的字节数。
不难看出,当 N < SMSS 时,拥塞窗口每次的增加量要小于 SMSS。
用这样的方法逐步增大发送方的拥塞窗口 cwnd,可以使分组注入到网络的速率更加合理。

发送方每收到一个对新报文段的确认
(重传的不算在内)就使 cwnd 加 1。

传输轮次

使用慢开始算法后,每经过一个传输轮次 (transmission round),拥塞窗口 cwnd 就加倍。
一个传输轮次所经历的时间其实就是往返时间 RTT。
“传输轮次”更加强调:把拥塞窗口 cwnd 所允许发送的报文段都连续发送出去,并收到了对已发送的最后一个字节的确认。
例如,拥塞窗口 cwnd = 4,这时的往返时间 RTT 就是发送方连续发送 4 个报文段,并收到这 4 个报文段的确认,总共经历的时间。

设置慢开始门限状态变量 ssthresh

慢开始门限 ssthresh 的用法如下:
当 cwnd < ssthresh 时,使用慢开始算法。
当 cwnd > ssthresh 时,停止使用慢开始算法而改用拥塞避免算法。
当 cwnd = ssthresh 时,既可使用慢开始算法,也可使用拥塞避免算法。

拥塞避免算法

思路:让拥塞窗口 cwnd 缓慢地增大,避免出现拥塞。
每经过一个传输轮次,拥塞窗口 cwnd = cwnd + 1。
使拥塞窗口 cwnd 按线性规律缓慢增长。
在拥塞避免阶段,具有 “加法增大” (Additive Increase) 的特点。

在超时之前,每经过一个传输轮次就使 cwnd 加 1。

无论在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(重传定时器超时):
ssthresh = max (cwnd/2,2)
cwnd = 1
执行慢开始算法
目的:迅速减少主机发送到网络中的分组数,使得发生拥塞的路由器有足够时间把队列中积压的分组处理完毕。

慢开始和拥塞避免算法的实现举例

当 TCP 连接进行初始化时,将拥塞窗口置为 1。图中的窗口单位不使用字节而使用报文段。

慢开始门限的初始值设置为 16 个报文段,即 ssthresh = 16。

在执行慢开始算法时,拥塞窗口 cwnd=1,发送第一个报文段。

发送方每收到一个对新报文段的确认 ACK,就把拥塞窗口值加 1,然后开始下一轮的传输(请注意,横坐标是传输轮次,不是时间)。因此拥塞窗口 cwnd 随着传输轮次按指数规律增长。

当拥塞窗口 cwnd 增长到慢开始门限值 ssthresh 时(图中的点 ,此时拥塞窗口 cwnd = 16),就改为执行拥塞避免算法,拥塞窗口按线性规律增长。

“拥塞避免”并非指完全能够避免了拥塞。利用以上的措施要完全避免网络拥塞还是不可能的。
“拥塞避免”是说在拥塞避免阶段把拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。

当拥塞窗口 cwnd = 24 时,网络出现了超时(图中的点 ),发送方判断为网络拥塞。于是调整门限值 ssthresh = cwnd / 2 = 12,同时设置拥塞窗口 cwnd = 1,进入慢开始阶段。

按照慢开始算法,发送方每收到一个对新报文段的确认 ACK,就把拥塞窗口值加 1。
当拥塞窗口 cwnd = ssthresh = 12 时(图中的点,这是新的 ssthresh 值),改为执行拥塞避免算法,拥塞窗口按线性规律增大。

当拥塞窗口 cwnd = 16 时(图中的点),出现了一个新的情况,就是发送方一连收到 3 个对同一个报文段的重复确认(图中记为 3-ACK)。发送方改为执行快重传和快恢复算法。

快重传算法

发送方只要一连收到三个重复确认,就知道接收方确实没有收到报文段,因而应当立即进行重传(即“快重传”),这样就不会出现超时,发送方也不就会误认为出现了网络拥塞。
使用快重传可以使整个网络的吞吐量提高约20%。

不难看出,快重传并非取消重传计时器,而是在某些情况下可以更早地(更快地)重传丢失的报文段。

采用快重传 FR (Fast Retransmission) 算法可以让发送方尽早知道发生了个别报文段的丢失。
快重传 算法首先要求接收方不要等待自己发送数据时才进行捎带确认,而是要立即发送确认,即使收到了失序的报文段也要立即发出对已收到的报文段的重复确认。

快重传举例

快恢复算法

当发送端收到连续三个重复的确认时,由于发送方现在认为网络很可能没有发生拥塞,因此现在不执行慢开始算法,而是执行快恢复算法 FR (Fast Recovery) 算法:
慢开始门限 ssthresh = 当前拥塞窗口 cwnd / 2 ;
新拥塞窗口 cwnd = 慢开始门限 ssthresh ;
开始执行拥塞避免算法,使拥塞窗口缓慢地线性增大。

加法增大,乘法减小 (AIMD)

可以看出,在拥塞避免阶段,拥塞窗口是按照线性规律增大的。这常称为“加法增大” AI (Additive Increase)。
当出现超时或3个重复的确认时,就要把门限值设置为当前拥塞窗口值的一半,并大大减小拥塞窗口的数值。这常称为“乘法减小”MD (Multiplicative Decrease)。
二者合在一起就是所谓的 AIMD 算法。

无线局域网 WLAN

无线局域网的组成

无线局域网 WLAN(Wireless Local Area Network) 指采用无线通信技术的局域网。

特点:

  • 提供了移动接入的功能
  • 节省投资,建网速度较快
  • 支持便携设备联网

由于手机普及率日益增高,通过无线局域网接入到互联网已成为当今上网的最常用的方式。

无线局域网 WLAN 可分为两大类:有固定基础设施的 WLAN、无固定基础设施的 WLAN。

所谓“固定基础设施”是指预先建立起来的、能够覆盖一定地理范围的一批固定基站。

IEEE 802.11

IEEE 802.11 是一个有固定基础设施的无线局域网的国际标准。

IEEE 802.11 是个相当复杂的标准。但简单地说,802.11 就是无线以太网的标准:

  • 它使用星形拓扑,其中心叫做接入点 AP (Access Point)
  • 在MAC层使用 CSMA/CA 协议

凡使用 802.11 系列协议的局域网又称为 Wi-Fi(Wireless-Fidelity),意思是“无线保真度”。

IEEE 802.11 规定无线局域网的最小构件是基本服务集 BSS。一个基本服务集 BSS 包括一个基站和若干个移动站,一个站无论要和本 BSS 的站进行通信,还是要和其他 BSS 的站进行通信,都必须通过本 BSS 的基站。

基本服务集内的基站叫做接入点 AP(Access Point) 其作用和网桥相似。当网络管理员安装 AP 时,必须为该 AP 分配一个不超过 32 字节的服务集标识符 SSID 和一个通信信道。SSID 其实就是指使用该 AP 的无线局域网的名字。

一个基本服务集 BSS 所覆盖的地理范围叫做一个基本服务区 BSA(Basic Service Area)。BSA 的范围直径一般不超过100米。

ESS 还可通过门户为无线用户提供到非 802.11 无线局域网(例如,到有线连接的互联网)的接入。门户的作用就相当于一个网桥。

一个基本服务集可以是孤立的,也可通过接入点 AP 连接到一个主干分配系统 DS(Distribution System),然后再接入到另一个基本服务集,构成扩展的服务集 ESS(Extended Service Set)。

移动站 A 从某一个基本服务集漫游到另一个基本服务集(到A'的位置),仍可保持与另一个移动站 B 进行通信。

建立关联 (association)

一个移动站若要加入到一个基本服务集 BSS,就必须先选择一个接入点 AP,并与此接入点建立关联。建立关联就表示这个移动站加入了选定的 AP 所属的子网,并和这个 AP 之间创建了一个虚拟线路。只有关联的 AP 才能向这个移动站发送数据帧,而这个移动站也只有通过关联的 AP 才能向其他站点发送数据帧。

为了使一个基本服务集 BSS 能够为更多的移动站提供服务,往往在一个 BSS 内安装有多个接入点 AP。有时一个移动站也可以收到本基本服务集以外的 AP 信号。移动站只能在多个 AP 中选择一个建立关联。通常可以选择信号最强的一个 AP 。但有时也可能该 AP 提供的信道都已被其它移动站使用了。在这种情况下,也只能与信号强度稍差些的 AP 建立关联。

此后,这个移动站点就和选定的 AP 互相使用 802.11 关联协议进行对话。移动站点还要向该 AP 鉴别自身。在关联阶段过后,移动站点要通过关联的 AP 向该子网发送 DHCP 发现报文以获取 IP 地址。这时,互联网中的其它部分就把这个移动站点当做该 AP 子网中的一台主机。

重建关联 (reassociation) 和分离 (dissociation)

若移动站使用重建关联服务,就可把这种关联转移到另一个接入点。当使用分离服务时,就可终止这种关联。

移动站与 AP 建立关联的方法

  • 被动扫描:移动站等待接收接入站周期性发出的信标帧。信标帧中包含有若干系统参数(如服务集标识符 SSID 以及支持的速率等)。
  • 主动扫描:移动站主动发出探测请求帧,然后等待从 AP 发回的探测响应帧。

热点 (hot spot)

热点就是公众无线入网点。由许多热点和 AP 连接起来的区域叫做热区。

用户可以通过无线信道接入到无线互联网服务提供者 WISP(Wireless Internet Service Provider),然后再经过无线信道接入到互联网。

接入安全

无线局域网用户在和附近的接入点 AP 建立关联时,一般还要键入用户密码。

初期的接入加密方案称为 WEP(Wired Equivalent Privacy,意思是有线等效的保密)。

现在的接入加密方案为 WPA(WiFi Protected Access,意思是“无线局域网受保护的接入”) 或 WPA2。

移动自组网络

移动自组网络又称为自组网络。自组网络是没有固定基础设施(即没有 AP)的无线局域网。这种网络是由一些处于平等状态的移动站之间相互通信组成的临时网络。

三个主要问题:路由选择协议,多播,安全。

自组网络的服务范围通常是受限的,而且一般也不和外界的其他网络相连接。移动自组网络也就是移动分组无线网络。

移动自组网络的应用前景

  • 携带了移动站的作战人员可利用临时建立的移动自组网络进行通信。
  • 作战的地面车辆群和坦克群,以及海上的舰艇群、空中的机群组网。
  • 在抢险救灾时,迅速组建移动自组网络实现通信。

无线传感器网络 WSN

无线传感器网络 WSN(Wireless Sensor Network) 是由大量传感器结点通过无线通信技术构成的自组网络。

无线传感器网络的应用是进行各种数据的采集、处理和传输。特点:

  • 不需要很高的带宽,必须保持低功耗。
  • 对协议栈的大小有严格的限制。
  • 对网络安全性、结点自动配置、网络动态重组等方面有一定的要求。

传感器结点的组成

无线传感器网络主要的应用领域

无线传感器网络主要的应用领域就是组成各种物联网 IoT (Internet of Things) ,例如:

  • 环境监测与保护;
  • 战争中对敌情的侦查和对兵力、装备、物资等的监控;
  • 医疗中对病房的监测和对患者的护理;
  • 在危险的工业环境中的安全监测;
  • 城市交通管理、建筑内的温度/照明/安全控制等。

移动自组网络不同于移动 IP

移动 IP 技术使漫游的主机可以用多种方式连接到互联网。

移动 IP 的核心网络功能仍然是基于在固定互联网中一直在使用的各种路由选择协议。

移动自组网络是将移动性扩展到无线领域中的自治系统,它具有自己特定的路由选择协议,并且可以不和互联网相连。

几种不同的接入

固定接入 (fixed access) —— 在作为网络用户期间,用户设置的地理位置保持不变。
移动接入 (mobility access) —— 用户设置能够以车辆速度移动时进行网络通信。当发生切换时,通信仍然是连续的。
便携接入 (portable access) —— 在受限的网络覆盖面积中,用户设备能够在以步行速度移动时进行网络通信,提供有限的切换能力。
游牧接入 (nomadic access) —— 用户设备的地理位置至少在进行网络通信时保持不变。如用户设备移动了位置,则再次进行通信时可能还要寻找最佳的基站。

802.11 局域网的物理层

802.11 标准中物理层相当复杂。根据物理层的不同(如工作频段、数据率、调制方法等),对应的标准也不同。

802.11 的物理层有以下几种实现方法:

  • 直接序列扩频 DSSS
  • 正交频分复用 OFDM
  • 跳频扩频 FHSS (已很少用)
  • 红外线 IR (已很少用)

802.11 局域网的 MAC 层协议

CSMA/CA 协议

无线局域网不能简单地搬用 CSMA/CD 协议。因为:

  • “碰撞检测”要求一个站点在发送本站数据的同时,还必须不间断地检测信道,但接收到的信号强度往往会远远小于发送信号的强度,在无线局域网的设备中要实现这种功能就花费过大。
  • 即使能够实现碰撞检测的功能,并且在发送数据时检测到信道是空闲的时候,在接收端仍然有可能发生碰撞。

无线局域网的特殊问题

A 和 C 检测不到彼此的无线信号,都以为 B 是空闲的,因而都向 B 发送数据,结果发生碰撞。

这种未能检测出媒体上已存在的信号的问题叫做隐蔽站问题。

B 向 A 发送数据,而 C 又想和 D 通信。C 检测到媒体上有信号,于是就不敢向 D 发送数据。

其实 B 向 A 发送数据并不影响 C 向 D 发送数据这就是暴露站问题(exposed station problem) 。

无线局域网不能使用 CSMA/CD,而只能使用改进的 CSMA 协议。改进的办法是把 CSMA 增加一个碰撞避免 CA(Collision Avoidance) 功能。802.11 就使用 CSMA/CA 协议。在使用 CSMA/CA 的同时,还增加使用停止等待协议。

802.11 的 MAC 层

MAC 层通过协调功能来确定在基本服务集 BSS 中的移动站在什么时间能发送数据或接收数据。

DCF 子层在每一个结点使用 CSMA 机制的分布式接入算法,让各个站通过争用信道来获取发送权。因此 DCF 向上提供争用服务。

PCF 子层使用集中控制的接入算法把发送数据权轮流交给各个站从而避免了碰撞的产生。自组网络就没有 PCF 子层。

帧间间隔 IFS

所有的站在完成发送后,必须再等待一段很短的时间(继续监听)才能发送下一帧。这段时间的通称是帧间间隔 IFS(InterFrame Space)。

帧间间隔长度取决于该站欲发送的帧的类型。高优先级帧需要等待的时间较短,因此可优先获得发送权。

若低优先级帧还没来得及发送而其他站的高优先级帧已发送到媒体,则媒体变为忙态,因而低优先级帧就只能再推迟发送了。这样就减少了发生碰撞的机会。

两种常用的帧间间隔

SIFS,即短帧间间隔,长度为28 μs,是最短的帧间间隔,用来分隔开属于一次对话的各帧。一个站应当能够在这段时间内从发送方式切换到接收方式。

使用 SIFS 的帧类型有:ACK 帧、CTS 帧、由过长的 MAC 帧分片后的数据帧,以及所有回答 AP 探询的帧和在 PCF 方式中接入点 AP 发送出的任何帧。

DIFS,即分布协调功能帧间间隔,它比 SIFS 的帧间间隔要长得多,长度为 128 μs 。在 DCF 方式中,DIFS 用来发送数据帧和管理帧。

CSMA/CA 协议的原理

欲发送数据的站先检测信道。在 802.11 标准中规定了在物理层的空中接口进行物理层的载波监听。

通过收到的相对信号强度是否超过一定的门限数值就可判定是否有其他的移动站在信道上发送数据。

当源站发送它的第一个 MAC 帧时,若检测到信道空闲,则在等待一段时间 DIFS 后就可发送。

当源站发送它的第一个 MAC 帧时,若检测到信道空闲,则在等待一段时间 DIFS 后,信道若仍然空闲,就开始发送。

目的站若正确收到此帧,则经过时间间隔 SIFS 后,向源站发送确认帧 ACK。

为什么信道空闲还要再等待
这是考虑到可能有其他的站有高优先级的帧要发送。
如有,就要让高优先级帧先发送。

假定没有高优先级帧要发送
源站发送了自己的数据帧。
目的站若正确收到此帧,则经过时间间隔 SIFS 后,向源站发送确认帧 ACK。
若源站在规定时间内没有收到确认帧 ACK(由重传计时器控制这段时间),就必须重传此帧,直到收到确认为止,或者经过若干次的重传失败后放弃发送。

虚拟载波监听
虚拟载波监听 (Virtual Carrier Sense) 的机制是让源站将它要占用信道的时间(包括目的站发回确认帧所需的时间)通知给所有其他站,以便使其他所有站在这一段时间都停止发送数据。这样就大大减少了碰撞的机会。
“虚拟载波监听”是指:其他站实际上并没有监听信道,而是由于其他站收到了“源站的通知”才不发送数据。
所谓“源站的通知”就是源站在其 MAC 帧首部中的第二个字段“持续时间”中填入了在本帧结束后还要占用信道多少时间(以微秒为单位),包括目的站发送确认帧所需的时间。

网络分配向量
当一个站检测到正在信道中传送的 MAC 帧首部的“持续时间”字段时,就调整自己的网络分配向量 NAV (Network Allocation Vector)。
NAV 指出:必须经过多少时间才能完成数据帧的这次传输,才能使信道转入到空闲状态。

争用窗口
信道从忙态变为空闲时,任何一个站要发送数据帧时,不仅都必须等待一个 DIFS 的间隔,而且还要进入争用窗口,并计算随机退避时间以便再次重新试图接入到信道。
在信道从忙态转为空闲时,为了避免几个站同时发送数据(一旦发送就要把一帧发送完,不能中途停止),各站就要执行退避算法,以减少发生碰撞的概率。
802.11 使用二进制指数退避算法。

二进制指数退避算法
第 i 次退避就在 22+i 个时隙中随机地选择一个,即:第 i 次退避是在时隙 {0, 1, …, 22+i – 1} 中随机地选择一个。
第 1 次退避是在 8 个时隙中随机选择一个。
第 2 次退避是在 16 个时隙中随机选择一个。
当时隙编号达到 255 时(这对应于第 6 次退避)就不再增加了。
这里决定退避时间的变量 i 称为退避变量。

退避计时器 (backoff timer)
站点每经历一个时隙的时间就检测一次信道。
这可能发生两种情况:
若检测到信道空闲,退避计时器就继续倒计时。
若检测到信道忙,就冻结退避计时器的剩余时间,重新等待信道变为空闲,并再经过时间 DIFS 后,从剩余时间开始继续倒计时。当退避计时器的时间减小到零时,就开始发送整个数据帧。

冻结退避计时器剩余时间的做法是为了使协议对所有站点更加公平。

802.11 的退避机制

退避算法的使用情况

仅在下面的情况下才不使用退避算法:
检测到信道是空闲的,并且这个数据帧是要发送的第一个数据帧。
除此以外的所有情况,都必须使用退避算法:
在发送第一个帧之前检测到信道处于忙态。
在每一次的重传后。
在每一次的成功发送后。

CSMA/CA算法归纳

若站点最初有数据要发送(而不是发送不成功再进行重传),且检测到信道空闲,在等待时间 DIFS 后,就发送整个数据帧。
否则,站点就要等检测到信道空闲并经过时间 DIFS 后,执行 CSMA/CA 协议的退避算法,启动退避计数器。在退避计数器减少到零之前,一旦检测到信道忙,就冻结退避计时器。一旦信道空闲,退避计时器就进行倒计时。
当退避计时器时间减少到零时(这时信道只可能是空闲的),站点就发送整个的帧并等待确认。
发送站若收到确认,就知道已发送的帧被目的站正确收到了。这时如果要发送第二帧,就要从上面的步骤 (2) 开始,执行 CSMA/CA 协议的退避算法,随机选定一段退避时间。若源站在规定时间内没有收到确认帧 ACK(由重传计时器控制这段时间),就必须重传此帧 (再次使用 CSMA/CA 协议争用接入信道),直到收到确认为止,或者经过若干次的重传失败后放弃发送。

对信道进行预约

为了更好地解决隐蔽站带来的碰撞问题,802.11 允许要发送数据的站对信道进行预约。

使用 RTS 帧和 CTS 帧会使整个网络的通信效率有所下降。但与数据帧相比,开销不算大。
相反,若不使用这种控制帧,则一旦发生碰撞而导致数据帧重发,则浪费的时间就更多。

虽然如此,协议还是设有三种情况供用户选择:
使用 RTS 帧和 CTS 帧;
只有当数据帧的长度超过某一数值时才使用 RTS 帧和 CTS 帧(显然,当数据帧本身就很短时,再使用 RTS 帧和 CTS 帧只能增加开销);
不使用 RTS 帧和 CTS 帧。
虽然协议经过了精心设计,但碰撞仍然会发生。

CSMA/CA 协议的基本流程图

802.11 局域网的 MAC 帧

802.11 帧共有三种类型:控制帧、数据帧和管理帧。

802.11 数据帧的三大部分

MAC 首部,共 30 字节。帧的复杂性都在帧的首部。
帧主体,也就是帧的数据部分,不超过 2312 字节。这个数值比以太网的最大长度长很多。不过 802.11 帧的长度通常都小于 1500 字节。
帧检验序列 FCS 是尾部,共 4 字节 。

1. 关于 802.11 数据帧的地址

802.11 数据帧最特殊的地方就是有四个地址字段。地址 4 用于自组网络。我们在这里只讨论前三种地址。

无线个人区域网WPAN

无线个人区域网 WPAN(Wireless Personal Area Network) 就是在个人工作地方把属于个人使用的电子设备用无线技术连接起来自组网络,不需要使用接入点 AP。整个网络的范围大约在 10 m 左右。

WPAN 可以是一个人使用,也可以是若干人共同使用。

无线个人区域网 WPAN 和个人区域网 PAN(Personal Area Network) 并不完全等同,因为 PAN 不一定都是使用无线连接的。

WPAN 和 WLAN 并不一样。WPAN 是以个人为中心来使用的无线个人区域网,它实际上就是一个低功率、小范围、低速率和低价格的电缆替代技术。WLAN 却是同时为许多用户服务的无线局域网,它是一个大功率、中等范围、高速率的局域网。

WPAN 的 IEEE 标准由 IEEE 的 802.15 工作组制定,这个标准也是包括MAC层和物理层这两层的标准。WPAN 都工作在 2.4 GHz 的 ISM 频段。顺便指出,欧洲的 ETSI 标准则把无线个人区域网取名为 HiperPAN。

1. 蓝牙系统 (Bluetooth)

最早使用的 WPAN 是蓝牙系统,其标准是 IEEE 802.15.1。蓝牙的数据率为 720 kbit/s,通信范围在 10 米左右。

蓝牙使用 TDM 方式和扩频跳频 FHSS 技术组成不用基站的皮可网(piconet)。Piconet直译就是“微微网”,表示这种无线网络的覆盖面积非常小。

每一个皮可网有一个主设备和最多 7 个工作的从设备。通过共享主设备或从设备,可以把多个皮可网链接起来,形成一个范围更大的扩散网。这种主从工作方式的个人区域网实现起来价格就会比较便宜。

图中标有 M 和 S 的小圆圈分别表示主设备和从设备,标有 P 的小圆圈表示不工作的搁置设备。一个皮可网可以有 255 个搁置设备。

2. 低速 WPAN

低速 WPAN 主要用于工业监控组网、办公自动化与控制等领域,其速率是 2 ~ 250 kbit/s。低速 WPAN 的标准是 IEEE 802.15.4。

低速 WPAN 中最重要的就是 ZigBee。ZigBee 技术主要用于各种电子设备(固定的、便携的或移动的)之间的无线通信,其主要特点是通信距离短(10 ~ 80 m),传输数据速率低,并且成本低廉。

ZigBee 的特点:

  • 功耗非常低。在工作时,信号的收发时间很短;而在非工作时,ZigBee 结点处于休眠状态,非常省电。
  • 网络容量大。一个 ZigBee 的网络最多包括有 255 个结点,其中一个是主设备,其余则是从设备。若是通过网络协调器,整个网络最多可以支持超过 64000 个结点。

ZigBee 的标准是在 IEEE 802.15.4 标准基础上发展而来的。因此,所有 ZigBee 产品也是 802.15.4 产品。

IEEE 802.15.4 只是定义了 ZigBee 协议栈的最低的两层(物理层和 MAC 层),而上面的两层(网络层和应用层)则是由 ZigBee 联盟定义的。

ZigBee 的协议栈。

IEEE 802.15.4 物理层使用的三个频段。

频段 数据率 信道数
2.4 GHz(全球) 250 kbit/s 16
915 MHz(美国) 40 kbit/s 10
868 MHz(欧洲) 20 kbit/s 1

在 MAC 层,主要沿用 802.11 无线局域网标准的 CSMA/CA 协议。

在网络层,ZigBee 可采用星形和网状拓扑,或两者的组合。

一个 ZigBee 网络最多可以有 255 个结点。

ZigBee 的结点按功能的强弱可划分为两大类:

  • 全功能设备 FFD(Full-Function Device),具备控制器的功能,能够提供数据交换。是 ZigBee 网络中的路由器。
  • 精简功能设备 RFD(Reduced-Function Device) ,是 ZigBee 网络中数量最多的端设备。电路简单,存储容量较小,因而成本较低。

RFD 结点只能与处在该星形网中心的 FFD 结点交换数据。

在一个 ZigBee 网络中有一个 FFD 充当该网络的协调器。协调器负责维护整个 ZigBee 网络的结点信息,同时还可以与其他 ZigBee 网络的协调器交换数据。通过各网络协调器的相互通信,可以得到覆盖更大范围、超过 65000 个结点的 ZigBee 网络。

有一个全功能设备 FFD 充当网络的协调器。
ZigBee 网络中数量最多的端设备是精简功能设备 RFD 结点。

3. 高速 WPAN

高速 WPAN 用于在便携式多媒体装置之间传送数据,支持 11 ~ 55 Mbit/s 的数据率,标准是 IEEE 802.15.3。

IEEE 802.15.3a 工作组还提出了更高数据率的物理层标准的超高速 WPAN,它使用超宽带 UWB 技术。UWB 技术工作在 3.1 ~ 10.6 GHz 微波频段,有非常高的信道带宽。超宽带信号的带宽应超过信号中心频率的 25% 以上,或信号的绝对带宽超过 500 MHz。

超宽带技术使用了瞬间高速脉冲,可支持 100 ~ 400 Mbit/s 的数据率,可用于小范围内高速传送图像或 DVD 质量的多媒体视频文件。

无线城域网 WMAN

无线城域网(Wireless Metropolitan Area Network) 标准 IEEE 802.16 又称为 IEEE 无线城域网空中接口标准。欧洲的 ETSI 也制订类似的无线城域网标准 HiperMAN。

WMAN 可提供“最后一英里”的宽带无线接入(固定的、移动的和便携的)。

在许多情况下,无线城域网可用来代替现有的有线宽带接入,因此它有时又称为无线本地环路。

802.16 无线城域网服务范围的示意图:

你是一台电脑,你的名字叫 A。很久很久之前,你不与任何其他电脑相连接,孤苦伶仃。

直到有一天,你希望与另一台电脑 B 建立通信,于是你们各开了一个网口,用一根网线连接了起来。

用一根网线连接起来怎么就能”通信”了呢?如果你纠结,要么去研究一下操作系统是如何处理网络 IO 的,要么去研究一下包是如何被网卡转换成电信号发送出去的,要么就仅仅把它当做电脑里有个小人在开枪吧~

反正,你们就是连起来了,并且可以通信。

第一层

有一天,一个新伙伴 C 加入了,但聪明的你们很快发现,可以每个人开两个网口,用一共三根网线,彼此相连。

随着越来越多的人加入,你发现身上开的网口实在太多了,而且网线密密麻麻,混乱不堪。(而实际上一台电脑根本开不了这么多网口,所以这种连线只在理论上可行)

于是你们发明了一个中间设备,你们将网线都插到这个设备上,由这个设备做转发,就可以彼此之间通信了,本质上和原来一样,只不过网口的数量和网线的数量减少了,不再那么混乱。

你给它取名叫集线器,它仅仅是无脑将电信号转发到所有出口(广播),不做任何处理,你觉得它是没有智商的,因此把人家定性在了物理层。

由于转发到了所有出口,那 BCDE 四台机器怎么知道数据包是不是发给自己的呢?

首先,你要给所有的连接到集线器的设备,都起个名字。原来你们叫 ABCD,但现在需要一个更专业的,全局唯一的名字作为标识,你把这个更高端的名字称为 MAC 地址。

你的 MAC 地址是 aa-aa-aa-aa-aa-aa,你的伙伴 b 的 MAC 地址是 bb-bb-bb-bb-bb-bb,以此类推,不重复就好。

这样,A 在发送数据包给 B 时,只要在头部拼接一个这样结构的数据,就可以了。

B 在收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包的确是发给自己的,于是便收下。

其他的 CDE 收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包并不是发给自己的,于是便丢弃。

虽然集线器使整个布局干净不少,但原来我只要发给电脑 B 的消息,现在却要发给连接到集线器中的所有电脑,这样既不安全,又不节省网络资源。

第二层

如果把这个集线器弄得更智能一些,只发给目标 MAC 地址指向的那台电脑,就好了。

虽然只比集线器多了这一点点区别,但看起来似乎更智能了,你把这东西叫做交换机。也正因为这一点点智能,你把它放在了另一个层级,数据链路层。

交换机内部维护一张 MAC 地址表,记录着每一个 MAC 地址的设备,连接在其哪一个端口上。

MAC 地址 端口
bb-bb-bb-bb-bb-bb 1
cc-cc-cc-cc-cc-cc 3
aa-aa-aa-aa-aa-aa 4
dd-dd-dd-dd-dd-dd 5

假如你仍然要发给 B 一个数据包,构造了如下的数据结构从网口出去。

到达交换机时,交换机内部通过自己维护的 MAC 地址表,发现目标机器 B 的 MAC 地址 bb-bb-bb-bb-bb-bb 映射到了端口 1 上,于是把数据从 1 号端口发给了 B,完事~

你给这个通过这样传输方式而组成的小范围的网络,叫做以太网。

当然最开始的时候,MAC 地址表是空的,是怎么逐步建立起来的呢?

假如在 MAC 地址表为空是,你给 B 发送了如下数据

由于这个包从端口 4 进入的交换机,所以此时交换机就可以在 MAC地址表记录第一条数据:

1
2
MAC:aa-aa-aa-aa-aa-aa-aa
端口:4

交换机看目标 MAC 地址(bb-bb-bb-bb-bb-bb)在地址表中并没有映射关系,于是将此包发给了所有端口,也即发给了所有机器。

之后,只有机器 B 收到了确实是发给自己的包,于是做出了响应,响应数据从端口 1 进入交换机,于是交换机此时在地址表中更新了第二条数据:

1
2
MAC:bb-bb-bb-bb-bb-bb
端口:1

过程如下

经过该网络中的机器不断地通信,交换机最终将 MAC 地址表建立完毕~

随着机器数量越多,交换机的端口也不够了,但聪明的你发现,只要将多个交换机连接起来,这个问题就轻而易举搞定~

你完全不需要设计额外的东西,只需要按照之前的设计和规矩来,按照上述的接线方式即可完成所有电脑的互联,所以交换机设计的这种规则,真的很巧妙。你想想看为什么(比如 A 要发数据给 F)。

但是你要注意,上面那根红色的线,最终在 MAC 地址表中可不是一条记录呀,而是要把 EFGH 这四台机器与该端口(端口6)的映射全部记录在表中。

最终,两个交换机将分别记录 A ~ H 所有机器的映射记录。

左边的交换机

MAC 地址 端口
bb-bb-bb-bb-bb-bb 1
cc-cc-cc-cc-cc-cc 3
aa-aa-aa-aa-aa-aa 4
dd-dd-dd-dd-dd-dd 5
ee-ee-ee-ee-ee-ee 6
ff-ff-ff-ff-ff-ff 6
gg-gg-gg-gg-gg-gg 6
hh-hh-hh-hh-hh-hh 6

右边的交换机

MAC 地址 端口
bb-bb-bb-bb-bb-bb 1
cc-cc-cc-cc-cc-cc 1
aa-aa-aa-aa-aa-aa 1
dd-dd-dd-dd-dd-dd 1
ee-ee-ee-ee-ee-ee 2
ff-ff-ff-ff-ff-ff 3
gg-gg-gg-gg-gg-gg 4
hh-hh-hh-hh-hh-hh 6

这在只有 8 台电脑的时候还好,甚至在只有几百台电脑的时候,都还好,所以这种交换机的设计方式,已经足足支撑一阵子了。

但很遗憾,人是贪婪的动物,很快,电脑的数量就发展到几千、几万、几十万。

第三层

交换机已经无法记录如此庞大的映射关系了。

此时你动了歪脑筋,你发现了问题的根本在于,连出去的那根红色的网线,后面不知道有多少个设备不断地连接进来,从而使得地址表越来越大。

那我可不可以让那根红色的网线,接入一个新的设备,这个设备就跟电脑一样有自己独立的 MAC 地址,而且同时还能帮我把数据包做一次转发呢?

这个设备就是路由器,它的功能就是,作为一台独立的拥有 MAC 地址的设备,并且可以帮我把数据包做一次转发,你把它定在了网络层。

注意,路由器的每一个端口,都有独立的 MAC 地址

好了,现在交换机的 MAC 地址表中,只需要多出一条 MAC 地址 ABAB 与其端口的映射关系,就可以成功把数据包转交给路由器了,这条搞定。

那如何做到,把发送给 C 和 D,甚至是把发送给 DEFGH…. 的数据包,统统先发送给路由器呢?

不难想到这样一个点子,假如电脑 C 和 D 的 MAC 地址拥有共同的前缀,比如分别是

C 的 MAC 地址:FFFF-FFFF-CCCC
D 的 MAC 地址:FFFF-FFFF-DDDD
那我们就可以说,将目标 MAC 地址为 FFFF-FFFF-?开头的,统统先发送给路由器。

这样是否可行呢?答案是否定的。

我们先从现实中 MAC 地址的结构入手,MAC地址也叫物理地址、硬件地址,长度为 48 位,一般这样来表示

00-16-EA-AE-3C-40

它是由网络设备制造商生产时烧录在网卡的EPROM(一种闪存芯片,通常可以通过程序擦写)。其中前 24 位(00-16-EA)代表网络硬件制造商的编号,后 24 位(AE-3C-40)是该厂家自己分配的,一般表示系列号。只要不更改自己的 MAC 地址,MAC 地址在世界是唯一的。形象地说,MAC地址就如同身份证上的身份证号码,具有唯一性。

那如果你希望向上面那样表示将目标 MAC 地址为 FFFF-FFFF-?开头的,统一从路由器出去发给某一群设备(后面会提到这其实是子网的概念),那你就需要要求某一子网下统统买一个厂商制造的设备,要么你就需要要求厂商在生产网络设备烧录 MAC 地址时,提前按照你规划好的子网结构来定 MAC 地址,并且日后这个网络的结构都不能轻易改变。

这显然是不现实的。

于是你发明了一个新的地址,给每一台机器一个 32 位的编号,如:

11000000101010000000000000000001

你觉得有些不清晰,于是把它分成四个部分,中间用点相连。

11000000.10101000.00000000.00000001

你还觉得不清晰,于是把它转换成 10 进制。

192.168.0.1

最后你给了这个地址一个响亮的名字,IP 地址。现在每一台电脑,同时有自己的 MAC 地址,又有自己的 IP 地址,只不过 IP 地址是软件层面上的,可以随时修改,MAC 地址一般是无法修改的。

这样一个可以随时修改的 IP 地址,就可以根据你规划的网络拓扑结构,来调整了。

如上图所示,假如我想要发送数据包给 ABCD 其中一台设备,不论哪一台,我都可以这样描述,”将 IP 地址为 192.168.0 开头的全部发送给到路由器,之后再怎么转发,交给它!”,巧妙吧。

那交给路由器之后,路由器又是怎么把数据包准确转发给指定设备的呢?

别急我们慢慢来。

我们先给上面的组网方式中的每一台设备,加上自己的 IP 地址

现在两个设备之间传输,除了加上数据链路层的头部之外,还要再增加一个网络层的头部。

假如 A 给 B 发送数据,由于它们直接连着交换机,所以 A 直接发出如下数据包即可,其实网络层没有体现出作用。

但假如 A 给 C 发送数据,A 就需要先转交给路由器,然后再由路由器转交给 C。由于最底层的传输仍然需要依赖以太网,所以数据包是分成两段的。

A ~ 路由器这段的包如下:

路由器到 C 这段的包如下:

好了,上面说的两种情况(A->B,A->C),相信细心的读者应该会有不少疑问,下面我们一个个来展开。

A 给 C 发数据包,怎么知道是否要通过路由器转发呢?

答案:子网

如果源 IP 与目的 IP 处于一个子网,直接将包通过交换机发出去。

如果源 IP 与目的 IP 不处于一个子网,就交给路由器去处理。

好,那现在只需要解决,什么叫处于一个子网就好了。

192.168.0.1 和 192.168.0.2 处于同一个子网

192.168.0.1 和 192.168.1.1 处于不同子网

这两个是我们人为规定的,即我们想表示,对于 192.168.0.1 来说:

192.168.0.xxx 开头的,就算是在一个子网,否则就是在不同的子网。

那对于计算机来说,怎么表达这个意思呢?于是人们发明了子网掩码的概念

假如某台机器的子网掩码定为 255.255.255.0

这表示,将源 IP 与目的 IP 分别同这个子网掩码进行与运算,相等则是在一个子网,不相等就是在不同子网,就这么简单。

比如

A电脑:192.168.0.1 & 255.255.255.0 = 192.168.0.0

B电脑:192.168.0.2 & 255.255.255.0 = 192.168.0.0

C电脑:192.168.1.1 & 255.255.255.0 = 192.168.1.0

D电脑:192.168.1.2 & 255.255.255.0 = 192.168.1.0

那么 A 与 B 在同一个子网,C 与 D 在同一个子网,但是 A 与 C 就不在同一个子网,与 D 也不在同一个子网,以此类推。

所以如果 A 给 C 发消息,A 和 C 的 IP 地址分别 & A 机器配置的子网掩码,发现不相等,则 A 认为 C 和自己不在同一个子网,于是把包发给路由器,就不管了,之后怎么转发,A 不关心。

A 如何知道,哪个设备是路由器?

答案:在 A 上要设置默认网关

上一步 A 通过是否与 C 在同一个子网内,判断出自己应该把包发给路由器,那路由器的 IP 是多少呢?

其实说发给路由器不准确,应该说 A 会把包发给默认网关。

对 A 来说,A 只能直接把包发给同处于一个子网下的某个 IP 上,所以发给路由器还是发给某个电脑,对 A 来说也不关心,只要这个设备有个 IP 地址就行。

所以默认网关,就是 A 在自己电脑里配置的一个 IP 地址,以便在发给不同子网的机器时,发给这个 IP 地址。

仅此而已!

路由器如何知道C在哪里?

答案:路由表

现在 A 要给 C 发数据包,已经可以成功发到路由器这里了,最后一个问题就是,路由器怎么知道,收到的这个数据包,该从自己的哪个端口出去,才能直接(或间接)地最终到达目的地 C 呢。

路由器收到的数据包有目的 IP 也就是 C 的 IP 地址,需要转化成从自己的哪个端口出去,很容易想到,应该有个表,就像 MAC 地址表一样。

这个表就叫路由表。

不同于 MAC 地址表的是,路由表并不是一对一这种明确关系,我们下面看一个路由表的结构。

目的地址 子网掩码 下一跳 端口
192.168.0.0 255.255.255.0 0
192.168.0.254 255.255.255.255 0
192.168.1.0 255.255.255.0 1
192.168.1.254 255.255.255.255 1

我们学习一种新的表示方法,由于子网掩码其实就表示前多少位表示子网的网段,所以如 192.168.0.0(255.255.255.0) 也可以简写为 192.168.0.0/24

目的地址 下一跳 端口
192.168.0.0/24 0
192.168.0.254/32 0
192.168.1.0/24 1
192.168.1.254/32 1

这就很好理解了,路由表就表示,192.168.0.xxx 这个子网下的,都转发到 0 号端口,192.168.1.xxx 这个子网下的,都转发到 1 号端口。下一跳列还没有值,我们先不管

配合着结构图来看(这里把子网掩码和默认网关都补齐了)

刚才说的都是 IP 层,但发送数据包的数据链路层需要知道 MAC 地址,可是我只知道 IP 地址该怎么办呢?

答案:arp

假如你(A)此时不知道你同伴 B 的 MAC 地址(现实中就是不知道的,刚刚我们只是假设已知),你只知道它的 IP 地址,你该怎么把数据包准确传给 B 呢?

答案很简单,在网络层,我需要把 IP 地址对应的 MAC 地址找到,也就是通过某种方式,找到 192.168.0.2 对应的 MAC 地址 BBBB。

这种方式就是 arp 协议,同时电脑 A 和 B 里面也会有一张 arp 缓存表,表中记录着 IP 与 MAC 地址的对应关系。

IP 地址 MAC 地址
192.168.0.2 BBBB

一开始的时候这个表是空的,电脑 A 为了知道电脑 B(192.168.0.2)的 MAC 地址,将会广播一条 arp 请求,B 收到请求后,带上自己的 MAC 地址给 A 一个响应。此时 A 便更新了自己的 arp 表。

这样通过大家不断广播 arp 请求,最终所有电脑里面都将 arp 缓存表更新完整。

这就是物理层、数据链路层、网络层这三层所做的事情。

第四层

站在第四层的你,就可以不要脸地利用下三层所做的铺垫,随心所欲地发送数据,而不必担心找不到对方了。

虽然你此时还什么都没干,但你还是给自己这一层起了个响亮的名字,叫做传输层。
你本以为自己所在的第四层万事大吉,啥事没有,但很快问题就接踵而至。

问题来了

前三层协议只能把数据包从一个主机搬到另外一台主机,但是,到了目的地以后,数据包具体交给哪个程序(进程)呢?

所以,你需要把通信的进程区分开来,于是就给每个进程分配一个数字编号,你给它起了一个响亮的名字:端口号。

然后你在要发送的数据包上,增加了传输层的头部,源端口号与目标端口号。

OK,这样你将原本主机到主机的通信,升级为了进程和进程之间的通信。
你没有意识到,你不知不觉实现了 UDP 协议!
(当然 UDP 协议中不光有源端口和目标端口,还有数据包长度和校验值,我们暂且略过)
就这样,你用 UDP 协议无忧无虑地同 B 进行着通信,一直没发生什么问题。

但很快,你发现事情变得非常复杂……

丢包问题

由于网络的不可靠,数据包可能在半路丢失,而 A 和 B 却无法察觉。

对于丢包问题,只要解决两个事就好了。
第一个,A 怎么知道包丢了?
答案:让 B 告诉 A
第二个,丢了的包怎么办?
答案:重传
于是你设计了如下方案,A 每发一个包,都必须收到来自 B 的确认(ACK),再发下一个,否则在一定时间内没有收到确认,就重传这个包。

你管它叫停止等待协议。只要按照这个协议来,虽然 A 无法保证 B 一定能收到包,但 A 能够确认 B 是否收到了包,收不到就重试,尽最大努力让这个通信过程变得可靠,于是你们现在的通信过程又有了一个新的特征,可靠交付。

效率问题

停止等待虽然能解决问题,但是效率太低了,A 原本可以在发完第一个数据包之后立刻开始发第二个数据包,但由于停止等待协议,A 必须等数据包到达了 B ,且 B 的 ACK 包又回到了 A,才可以继续发第二个数据包,这效率慢得可不是一点两点。
于是你对这个过程进行了改进,采用流水线的方式,不再傻傻地等。

顺序问题

但是网路是复杂的、不可靠的。

有的时候 A 发出去的数据包,分别走了不同的路由到达 B,可能无法保证和发送数据包时一样的顺序。

在流水线中有多个数据包和ACK包在乱序流动,他们之间对应关系就乱掉了。
难道还回到停止等待协议?A 每收到一个包的确认(ACK)再发下一个包,那就根本不存在顺序问题。应该有更好的办法!
A 在发送的数据包中增加一个序号(seq),同时 B 要在 ACK 包上增加一个确认号(ack),这样不但解决了停止等待协议的效率问题,也通过这样标序号的方式解决了顺序问题。

而 B 这个确认号意味深长:比如 B 发了一个确认号为 ack = 3,它不仅仅表示 A 发送的序号为 2 的包收到了,还表示 2 之前的数据包都收到了。这种方式叫累计确认或累计应答。

注意,实际上 ack 的号是收到的最后一个数据包的序号 seq + 1,也就是告诉对方下一个应该发的序号是多少。但图中为了便于理解,ack 就表示收到的那个序号,不必纠结。

流量问题

有的时候,A 发送数据包的速度太快,而 B 的接收能力不够,但 B 却没有告知 A 这个情况。

怎么解决呢?
很简单,B 告诉 A 自己的接收能力,A 根据 B 的接收能力,相应控制自己的发送速率,就好了。
B 怎么告诉 A 呢?B 跟 A 说”我很强”这三个字么?那肯定不行,得有一个严谨的规范。
于是 B 决定,每次发送数据包给 A 时,顺带传过来一个值,叫窗口大小(win),这个值就表示 B 的接收能力。同理,每次 A 给 B 发包时也带上自己的窗口大小,表示 A 的接收能力。

B 告诉了 A 自己的窗口大小值,A 怎么利用它去做 A 这边发包的流量控制呢?
很简单,假如 B 给 A 传过来的窗口大小 win = 5,那 A 根据这个值,把自己要发送的数据分成这么几类。

图片过于清晰,就不再文字解释了。
当 A 不断发送数据包时,已发送的最后一个序号就往右移动,直到碰到了窗口的上边界,此时 A 就无法继续发包,达到了流量控制。

但是当 A 不断发包的同时,A 也会收到来自 B 的确认包,此时整个窗口会往右移动,因此上边界也往右移动,A 就能发更多的数据包了。

以上都是在窗口大小不变的情况下,而 B 在发给 A 的 ACK 包中,每一个都可以重新设置一个新的窗口大小,如果 A 收到了一个新的窗口大小值,A 会随之调整。
如果 A 收到了比原窗口值更大的窗口大小,比如 win = 6,则 A 会直接将窗口上边界向右移动 1 个单位。

如果 A 收到了比原窗口值小的窗口大小,比如 win = 4,则 A 暂时不会改变窗口大小,更不会将窗口上边界向左移动,而是等着 ACK 的到来,不断将左边界向右移动,直到窗口大小值收缩到新大小为止。

OK,终于将流量控制问题解决得差不多了,你看着上面一个个小动图,给这个窗口起了一个更生动的名字,滑动窗口。

拥塞问题

但有的时候,不是 B 的接受能力不够,而是网络不太好,造成了网络拥塞。

拥塞控制与流量控制有些像,但流量控制是受 B 的接收能力影响,而拥塞控制是受网络环境的影响。
拥塞控制的解决办法依然是通过设置一定的窗口大小,只不过,流量控制的窗口大小是 B 直接告诉 A 的,而拥塞控制的窗口大小按理说就应该是网络环境主动告诉 A。
但网络环境怎么可能主动告诉 A 呢?只能 A 单方面通过试探,不断感知网络环境的好坏,进而确定自己的拥塞窗口的大小。

拥塞窗口大小的计算有很多复杂的算法,就不在本文中展开了,假如拥塞窗口的大小为 cwnd,上一部分流量控制的滑动窗口的大小为 rwnd,那么窗口的右边界受这两个值共同的影响,需要取它俩的最小值。
窗口大小 = min(cwnd, rwnd)
含义很容易理解,当 B 的接受能力比较差时,即使网络非常通畅,A 也需要根据 B 的接收能力限制自己的发送窗口。当网络环境比较差时,即使 B 有很强的接收能力,A 也要根据网络的拥塞情况来限制自己的发送窗口。正所谓受其短板的影响嘛~

连接问题

有的时候,B 主机的相应进程还没有准备好或是挂掉了,A 就开始发送数据包,导致了浪费。

这个问题在于,A 在跟 B 通信之前,没有事先确认 B 是否已经准备好,就开始发了一连串的信息。就好比你和另一个人打电话,你还没有”喂”一下确认对方有没有在听,你就巴拉巴拉说了一堆。
这个问题该怎么解决呢?
地球人都知道,三次握手嘛!
A:我准备好了(SYN)

B:我知道了(ACK),我也准备好了(SYN)

A:我知道了(ACK)

A 与 B 各自在内存中维护着自己的状态变量,三次握手之后,双方的状态都变成了连接已建立(ESTABLISHED)。
虽然就只是发了三次数据包,并且在各自的内存中维护了状态变量,但这么说总觉得太 low,你看这个过程相当于双方建立连接的过程,于是你灵机一动,就叫它面向连接吧。
注意:这个连接是虚拟的,是由 A 和 B 这两个终端共同维护的,在网络中的设备根本就不知道连接这回事儿!
但凡事有始就有终,有了建立连接的过程,就要考虑释放连接的过程,又是地球人都知道,四次挥手嘛!

A:再见,我要关闭了(FIN)

B:我知道了(ACK)

给 B 一段时间把自己的事情处理完…

B:再见,我要关闭了(FIN)

A:我知道了(ACK)

总结

从各个节点的视角来看

电脑视角:

  • 首先我要知道我的 IP 以及对方的 IP
  • 通过子网掩码判断我们是否在同一个子网
  • 在同一个子网就通过 arp 获取对方 mac 地址直接扔出去
  • 不在同一个子网就通过 arp 获取默认网关的 mac 地址直接扔出去

交换机视角:

  • 我收到的数据包必须有目标 MAC 地址
  • 通过 MAC 地址表查映射关系
  • 查到了就按照映射关系从我的指定端口发出去
  • 查不到就所有端口都发出去

路由器视角:

  • 我收到的数据包必须有目标 IP 地址
  • 通过路由表查映射关系
  • 查到了就按照映射关系从我的指定端口发出去(不在任何一个子网范围,走其路由器的默认网关也是查到了)
  • 查不到则返回一个路由不可达的数据包

如果你嗅觉足够敏锐,你应该可以感受到下面这句话:

网络层(IP协议)本身没有传输包的功能,包的实际传输是委托给数据链路层(以太网中的交换机)来实现的。

涉及到的三张表分别是

  • 交换机中有 MAC 地址表用于映射 MAC 地址和它的端口
  • 路由器中有路由表用于映射 IP 地址(段)和它的端口
  • 电脑和路由器中都有 arp 缓存表用于缓存 IP 和 MAC 地址的映射关系

这三张表是怎么来的

  • MAC 地址表是通过以太网内各节点之间不断通过交换机通信,不断完善起来的。
  • 路由表是各种路由算法 + 人工配置逐步完善起来的。
  • arp 缓存表是不断通过 arp 协议的请求逐步完善起来的。

知道了以上这些,目前网络上两个节点是如何发送数据包的这个过程,就完全可以解释通了!

接下来我们就放上本章 最后一个 网络拓扑图吧,请做好 战斗 准备!

这时路由器 1 连接了路由器 2,所以其路由表有了下一条地址这一个概念,所以它的路由表就变成了这个样子。如果匹配到了有下一跳地址的一项,则需要再次匹配,找到其端口,并找到下一跳 IP 的 MAC 地址。

也就是说找来找去,最终必须能映射到一个端口号,然后从这个端口号把数据包发出去。

| 目的地址 下一跳 端口
192.168.0.0/24 0
192.168.0.254/32 0
192.168.1.0/24 1
192.168.1.254/32 1
192.168.2.0/24 192.168.100.5
192.168.100.0/24 2
192.168.100.4/32 2
这时如果 A 给 F 发送一个数据包,能不能通呢?如果通的话整个过程是怎样的呢?

详细过程动画描述:

详细过程文字描述:

  1. 首先 A(192.168.0.1)通过子网掩码(255.255.255.0)计算出自己与 F(192.168.2.2)并不在同一个子网内,于是决定发送给默认网关(192.168.0.254)
  2. A 通过 ARP 找到 默认网关 192.168.0.254 的 MAC 地址。
  3. A 将源 MAC 地址(AAAA)与网关 MAC 地址(ABAB)封装在数据链路层头部,又将源 IP 地址(192.168.0.1)和目的 IP 地址(192.168.2.2)(注意这里千万不要以为填写的是默认网关的 IP 地址,从始至终这个数据包的两个 IP 地址都是不变的,只有 MAC 地址在不断变化)封装在网络层头部,然后发包
  1. 交换机 1 收到数据包后,发现目标 MAC 地址是 ABAB,转发给路由器1
  2. 数据包来到了路由器 1,发现其目标 IP 地址是 192.168.2.2,查看其路由表,发现了下一跳的地址是 192.168.100.5
  3. 所以此时路由器 1 需要做两件事,第一件是再次匹配路由表,发现匹配到了端口为 2,于是将其封装到数据链路层,最后把包从 2 号口发出去。
  4. 此时路由器 2 收到了数据包,看到其目的地址是 192.168.2.2,查询其路由表,匹配到端口号为 1,准备从 1 号口把数据包送出去。
  5. 但此时路由器 2 需要知道 192.168.2.2 的 MAC 地址了,于是查看其 arp 缓存,找到其 MAC 地址为 FFFF,将其封装在数据链路层头部,并从 1 号端口把包发出去。
  6. 交换机 3 收到了数据包,发现目的 MAC 地址为 FFFF,查询其 MAC 地址表,发现应该从其 6 号端口出去,于是从 6 号端口把数据包发出去。
  7. F 最终收到了数据包!并且发现目的 MAC 地址就是自己,于是收下了这个包

不知道你现在再看下面这句话,是否能理解:
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议

面向连接、可靠,这两个词通过上面的讲述很容易理解,那什么叫做基于字节流呢?
很简单,TCP 在建立连接时,需要告诉对方 MSS(最大报文段大小)。
也就是说,如果要发送的数据很大,在 TCP 层是需要按照 MSS 来切割成一个个的 TCP 报文段 的。
切割的时候我才不管你原来的数据表示什么意思,需要在哪里断句啥的,我就把它当成一串毫无意义的字节,在我想要切割的地方咔嚓就来一刀,标上序号,只要接收方再根据这个序号拼成最终想要的完整数据就行了。
在我 TCP 传输这里,我就把它当做一个个的字节,也就是基于字节流的含义了。
图片
最后留给大家一个作业,模拟 A 与 B 建立一个 TCP 连接。
第一题:A 给 B 发送 “aaa” ,然后 B 给 A 回复一个简单的字符串 “success”,并将此过程抓包。

第二题:A 给 B 发送 “aaaaaa … a” 超过最大报文段大小,然后 B 给 A 回复一个简单的字符串 “success”,并将此过程抓包。

下面是我抓的包(第二题)
三次握手阶段

A -> B [SYN] Seq=0 Win=64240 Len=0

                    MSS=1460 WS=256

B - >A [SYN, ACK] Seq=0 Ack=1 Win=29200 Len=0

                    MSS=1424 WS=512

A -> B [ACK] Seq=1 Ack=1 Win=132352 Len=0

数据发送阶段

A -> B [ACK] Seq=1 Ack=1 Win=132352 Len=1424

A -> B [ACK] Seq=1425 Ack=1 Win=132352 Len=1424

A -> B [PSH, ACK] Seq=2849 Ack=1 Win=132352 Len=1247

B -> A [ACK] Seq=1 Ack=1425 Win=32256 Len=0

B -> A [ACK] Seq=1 Ack=2849 Win=35328 Len=0

B -> A [ACK] Seq=1 Ack=4096 Win=37888 Len=0

B -> A [PSH, ACK] Seq=1 Ack=4096 Win=37888 Len=7

四次挥手阶段

B -> A [FIN, ACK] Seq=8 Ack=4096 Win=37888 Len=0

A -> B [ACK] Seq=4096 Ack=9 Win=132352 Len=0

A -> B [FIN, ACK] Seq=4096 Ack=9 Win=132352 Len=0(下面少复制了一行ACK,抱歉)

访问 Web 服务器并显示网页这一过程包含了浏览器和 Web 服务器之间的一系列交互。

在这一系列交互完成后,浏览器就会将从 Web 服务器接收到的数据显示在屏幕上。虽然显示网页这个过程非常复杂,但浏览器和服务器之间通过网络进行的交互却出乎意料地简单。

浏览器和 Web 服务器等网络应用程序进行交互的层面上来看,其工作方式应该还是比较容易理解的。这个层面上的交互和人类之间的对话非常相似,从这一点来说也更加容易理解。

要实现应用程序之间的交互,我们需要一个能够在浏览器和 Web 服务器之间传递请求和响应的机制。网络是由很多计算机等设备相互连接组成的,因此在通信的过程中需要确定正确的通信对象,并将请求和响应发送给它们。请求和响应在传递的过程中可能会丢失或损坏,因此这些情况也必须要考虑到。所以说,我们需要一种机制,无论遇到任何情况都能够将请求和响应准确无误地发送给对方。由于请求和响应都是由 0 和 1 组成的数字信息,所以可以说,我们需要的是一种能够将数字信息搬运到指定目的地的机制。

这种机制是由操作系统中的网络控制软件,以及交换机、路由器等设备分工合作来实现的,它的基本思路是将数字信息分割成一个一个的小块,然后装入一些被称为“包”的容器中来运送。大家可以这样理解:包相当于信件或者包裹,而交换机和路由器则相当于邮局或快递公司的分拣处理区。包的头部存有目的地等控制信息,通过许多交换机和路由器的接力,就可以根据控制信息对这些包进行分拣,然后将它们一步一步地搬运到目的地。无论是家庭和公司里的局域网,还是外面的互联网,它们只是在规模上有所不同,基本的机制都是相同的。

负责搬运数字信息的机制,再加上浏览器和 Web 服务器这些网络应用程序,这两部分就组成了网络。也就是说,这两部分组合起来,就是网络的全貌。

我们将首先探索浏览器的工作方式。当我们输入下面这样的网址时,浏览器就会按照一定的规则去分析这个网址的含义,然后根据其含义生成请求消息。

1
http://www.lab.glasscom.com/sample1.html

在上面这个例子中,浏览器生成的请求消息表示“请给我sample1.html这一文件中储存的网页数据”,接着浏览器会将请求消息发送给 Web服务器。

当然,浏览器并不会亲自负责数据的传送。传送消息是搬运数字信息的机制负责的工作,因此浏览器会委托它将数据发送出去。具体来说,就是委托操作系统中的网络控制软件将消息发送给服务器。

然后是协议栈(网络控制软件叫作协议栈)。这个软件会将从浏览器接收到的消息打包,然后加上目的地址等控制信息。如果拿邮局来比喻,就是把信装进信封,然后在信封上写上收信人的地址。这个软件还有其他一些功能,例如当发生通信错误时重新发送包,或者调节数据发送的速率等,或许我们可以把它当作一位帮我们寄信的小秘书。

接下来,协议栈会将包交给网卡(负责以太网或无线网络通信的硬件)。然后,网卡会将包转换为电信号并通过网线发送出去。这样一来,包就进入到网络之中了。

接下来出场的物品会根据接入互联网的形式不同而不同。客户端计算机可以通过家庭或公司的局域网接入互联网,也可以单独直接接入互联网。假设客户端计算机是连接到家庭或公司的局域网中,然后再通过 ADSL 和光纤到户(FTTH)等宽带线路接入互联网。

在这样的场景中,网卡发送的包会经过交换机等设备,到达用来接入互联网的路由器。路由器的后面就是互联网,网络运营商会负责将包送到目的地,就好像我们把信投到邮筒中之后,邮递员会负责把信送给收件人一样。

接下来,数据从用来接入互联网的路由器出发,进入了互联网的内部。互联网的入口线路称为接入网。一般来说,我们可以用电话线、ISDN、ADSL、有线电视、光线、专线等多种通信线路来接入互联网,这些通信线路统称为接入网。接入网连接到签约的网络运营商,并接入被称为接入点(Point of Presence,PoP)的设备。

接入点的实体是一台专为运营商设计的路由器,我们可以把它理解为离你家最近的邮局。从各个邮筒中收集来的信件会在邮局进行分拣,然后被送往全国甚至全世界,互联网也是一样,网络包首先通过接入网被发送到接入点,然后再从这里被发送到全国甚至全世界。接入点的后面就是互联网的骨干部分了。

在骨干网中存在很多运营商和大量的路由器,这些路由器相互连接,组成一张巨大的网,而我们的网络包就在其中经过若干路由器的接力,最终被发送到目标 Web 服务器上。其实它的基本原理和家庭、公司中的路由器是相同的。也就是说,无论是在互联网中,还是在家庭、公司的局域网中,包都是以相同的方式传输的,这也是互联网的一大特征。

不过,运营商使用的路由器可跟我们家用的小型路由器不一样,它是一种可以连接几十根网线的高速大型路由器。在互联网的骨干部分,存在着大量的这种路由器,它们之间以复杂的形式连接起来,而网络包就在这些路由器之间穿行。

此外,路由器不但在规模上存在差异,在路由器间的连接方式上也存在差异。家庭和公司局域网中一般采用以太网线进行连接,而互联网中除了以太网线连接之外,还会使用比较古老的电话技术和最新的光通信技术来传送网络包。这一部分所使用的技术是当今网络中最热门的部分,可以说是最尖端技术的结晶。

通过骨干网之后,网络包最终到达了 Web 服务器所在的局域网中。接着,它会遇到防火墙,防火墙会对进入的包进行检查。大家可以把防火墙想象成门口的保安,他会检查所有进入的包,看看有没有危险的包混在里面。检查完之后,网络包接下来可能还会遇到缓存服务器。网页数据中有一部分是可以重复利用的,这些可以重复利用的数据就被保存在缓存服务器中。如果要访问的网页数据正好在缓存服务器中能够找到,那么就可以不用劳烦 Web 服务器,直接从缓存服务器读出数据。此外,在大型网站中,可能还会配备将消息分布到多台 Web 服务器上的负载均衡器,还有可能会使用通过分布在整个互联网中的缓存服务器来分发内容的服务。经过这些机制之后,网络包才会到达 Web 服务器。

当网络包到达 Web 服务器后,数据会被解包并还原为原始的请求消息,然后交给 Web 服务器程序。和客户端一样,这个操作也是由操作系统中的协议栈(网络控制软件)来完成的。接下来,Web 服务器程序分析请求消息的含义,并按照其中的指示将数据装入响应消息中,然后发回给客户端。响应消息回到客户端的过程和之前我们介绍的过程正好相反。

当响应到达客户端之后,浏览器会从中读取出网页的数据并在屏幕上显示出来。到这里,访问 Web 服务器的一系列操作就全部完成了。

每个进程的⽤户地址空间都是独⽴的,⼀般⽽⾔是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。

Linux 内核提供了不少进程间通信的机制。

管道

如果你学过 Linux 命令,那你肯定很熟悉「|」这个竖线。

$ ps auxf | grep mysql
上面命令行里的「|」竖线就是一个管道,它的功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。

同时,我们得知上面这种管道是没有名字,所以「|」表示的管道称为匿名管道,用完了就销毁。

管道还有另外一个类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式。

在使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:

$ mkfifo myPipe
myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思:

$ ls -l
prw-r–r–. 1 root root 0 Jul 17 02:45 myPipe
接下来,我们往 myPipe 这个管道写入数据:

$ echo “hello” > myPipe // 将数据写进管道
// 停住了 …
你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。

于是,我们执行另外一个命令来读取这个管道里的数据:

1
2
$ cat < myPipe  // 读取管道里的数据
hello

可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。

我们可以看出,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。

那管道如何创建呢,背后原理是什么?

匿名管道的创建,需要通过下面这个系统调用:

1
int pipe(int fd[2])

这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。

其实,所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。

看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?

我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。

虚拟内存

单⽚机是没有操作系统的,所以每次写完代码,都需要借助⼯具把程序烧录进去,这样程序才能跑起来。另外,单⽚机的 CPU 是直接操作内存的「物理地址」。

在这种情况下,要想在内存中同时运⾏两个程序是不可能的。如果第⼀个程序在 2000 的位置写⼊⼀个新的值,将会擦掉第⼆个程序存放在相同位置上的所有内容,所以同时运⾏两个程序是根本⾏不通的,这两个程序会⽴刻崩溃。

这⾥关键的问题是这两个程序都引⽤了绝对物理地址,⽽这正是我们最需要避免的。我们可以把进程所使⽤的地址「隔离」开来,即让操作系统为每个进程分配独⽴的⼀套「虚拟地址」,⼈⼈都有,⼤家⾃⼰玩⾃⼰的地址就⾏,互不⼲涉。但是有个前提每个进程都不能访问物理地址,⾄于虚拟地址最终怎么落到物理内存⾥,对进程来说是透明的,操作系统已经把这些都安排的明明⽩⽩了。

操作系统会提供⼀种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运⾏的时候,写⼊的是不同的物理地址,这样就不会冲突了。

于是,这⾥就引出了两种地址的概念:

  • 我们程序所使⽤的内存地址叫做虚拟内存地址
  • 实际存在硬件⾥⾯的空间地址叫物理内存地址

操作系统引⼊了虚拟内存,进程持有的虚拟地址会通过 CPU 芯⽚中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:

操作系统是如何管理虚拟地址与物理地址之间的关系?

主要有两种⽅式,分别是内存分段和内存分⻚,分段是⽐较早提出的,我们先来看看内存分段。

内存分段

程序是由若⼲个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就⽤分段(Segmentation)的形式把这些段分离出来。

分段机制下的虚拟地址由两部分组成,段选择⼦和段内偏移量。

段选择⼦就保存在段寄存器⾥⾯。段选择⼦⾥⾯最重要的是段号,⽤作段表的索引。段表⾥⾯保存的是这个段的基地址、段的界限和特权等级等。
虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
在上⾯,知道了虚拟地址是通过段表与物理地址进⾏映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有⼀个项,在这⼀项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,
如下图

如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500= 7500。
分段的办法很好,解决了程序本身不需要关⼼具体的物理内存地址的问题,但它也有⼀些不⾜之处:
第⼀个就是内存碎⽚的问题。
第⼆个就是内存交换的效率低的问题

我们来看看这样⼀个例⼦。假设有 1G 的物理内存,⽤户执⾏了多个程序,其中:
游戏占⽤了 512MB 内存
浏览器占⽤了 128MB 内存
⾳乐占⽤了 256 MB 内存。
这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。

如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开⼀个 200MB 的程序。

内存分⻚

分段的好处就是能产⽣连续的内存空间,但是会出现内存碎⽚和内存交换的空间太⼤的问题。
要解决这些问题,那么就要想出能少出现⼀些内存碎⽚的办法。另外,当需要进⾏内存交换的时候,让需
要交换写⼊或者从磁盘装载的数据更少⼀点,这样就可以解决问题了。这个办法,也就是内存分⻚
(Paging)。
分⻚是把整个虚拟和物理内存空间切成⼀段段固定尺⼨的⼤⼩。这样⼀个连续并且尺⼨固定的内存空间,
我们叫⻚(Page)。在 Linux 下,每⼀⻚的⼤⼩为 4KB 。
虚拟地址与物理地址之间通过⻚表来映射,如下图:

单体应用改造成微服务的一个好处是可以减少故障影响范围,故障被局限在一个微服务系统本身,而不是整个单体应用都崩溃。那么具体到一个微服务系统,如果出现了故障,应该如何处理呢?

首先,微服务系统可能出现故障的种类,主要有三种故障。

  • 集群故障。根据我的经验,微服务系统一般都是集群部署的,根据业务量大小而定,集群规模从几台到甚至上万台都有可能。一旦某些代码出现 bug,可能整个集群都会发生故障,不能提供对外提供服务。
  • 单 IDC 故障。现在大多数互联网公司为了保证业务的高可用性,往往业务部署在不止一个 IDC。然而现实中时常会发生某个 IDC 的光缆因为道路施工被挖断,导致整个 IDC 脱网。
  • 单机故障。顾名思义就是集群中的个别机器出现故障,这种情况往往对全局没有太大影响,但会导致调用到故障机器上的请求都失败,影响整个系统的成功率。

集群故障

一般而言,集群故障的产生原因不外乎有两种:一种是代码 bug 所导致,比如说某一段 Java 代码不断地分配大对象,但没有及时回收导致 JVM OOM 退出;另一种是突发的流量冲击,超出了系统的最大承载能力,比如“双 11”这种购物活动,电商系统会在零点一瞬间涌入大量流量,超出系统的最大承载能力,一下子就把整个系统给压垮了。

应付集群故障的思路,主要有两种:限流和降级。

1. 限流

顾名思义,限流就是限制流量,通常情况下,系统能够承载的流量根据集群规模的大小是固定的,可以称之为系统的最大容量。当真实流量超过了系统的最大容量后,就会导致系统响应变慢,服务调用出现大量超时,反映给用户的感觉就是卡顿、无响应。所以,应该根据系统的最大容量,给系统设置一个阈值,超过这个阈值的请求会被自动抛弃,这样的话可以最大限度地保证系统提供的服务正常。

除此之外,通常一个微服务系统会同时提供多个服务,每个服务在同一时刻的请求量也是不同的,很可能出现的一种情况就是,系统中某个服务的请求量突增,占用了系统中大部分资源,导致其他服务没有资源可用。因此,还要针对系统中每个服务的请求量也设置一个阈值,超过这个阈值的请求也要被自动抛弃,这样的话不至于因为一个服务影响了其他所有服务。

在实际项目中,可以用两个指标来衡量服务的请求量,一个是 QPS 即每秒请求量,一个是工作线程数。不过 QPS 因为不同服务的响应快慢不同,所以系统能够承载的 QPS 相差很大,因此一般选择工作线程数来作为限流的指标,给系统设置一个总的最大工作线程数以及单个服务的最大工作线程数,这样的话无论是系统的总请求量过大导致整体工作线程数量达到最大工作线程数,还是某个服务的请求量超过单个服务的最大工作线程数,都会被限流,以起到保护整个系统的作用。

2. 降级

降级就是通过停止系统中的某些功能,来保证系统整体的可用性。降级可以说是一种被动防御的措施,为什么这么说呢?因为它一般是系统已经出现故障后所采取的一种止损措施。

那么降级一般是如何实现的呢?一种可行的方案是通过开关来实现。

具体来讲,就是在系统运行的内存中开辟一块区域,专门用于存储开关的状态,也就是开启还是关闭。并且需要监听某个端口,通过这个端口可以向系统下发命令,来改变内存中开关的状态。当开关开启时,业务的某一段逻辑就不再执行,而正常情况下,开关是关闭的状态。

开关一般用在两种地方,一种是新增的业务逻辑,因为新增的业务逻辑相对来说不成熟,往往具备一定的风险,所以需要加开关来控制新业务逻辑是否执行;另一种是依赖的服务或资源,因为依赖的服务或者资源不总是可靠的,所以最好是有开关能够控制是否对依赖服务或资源发起调用,来保证即使依赖出现问题,也能通过降级来避免影响。

在实际业务应用的时候,降级要按照对业务的影响程度进行分级,一般分为三级:一级降级是对业务影响最小的降级,在故障的情况下,首先执行一级降级,所以一级降级也可以设置成自动降级,不需要人为干预;二级降级是对业务有一定影响的降级,在故障的情况下,如果一级降级起不到多大作用的时候,可以人为采取措施,执行二级降级;三级降级是对业务有较大影响的降级,这种降级要么是对商业收入有重大影响,要么是对用户体验有重大影响,所以操作起来要非常谨慎,不在最后时刻一般不予采用。

单 IDC 故障

在现实情况下,整个 IDC 脱网的事情时有发生,多半是因为不可抗力比如机房着火、光缆被挖断等,如果业务全部部署在这个 IDC,那就完全不可访问了,所以国内大部分的互联网业务多采用多 IDC 部署。具体来说,有的采用同城双活,也就是在一个城市的两个 IDC 内部署;有的采用异地多活,一般是在两个城市的两个 IDC 内部署;当然也有支付宝这种金融级别的应用采用了“三地五中心”部署,这种部署成本显然高比两个 IDC 要高得多,但可用性的保障要更高。

采用多 IDC 部署的最大好处就是当有一个 IDC 发生故障时,可以把原来访问故障 IDC 的流量切换到正常的 IDC,来保证业务的正常访问。

流量切换的方式一般有两种,一种是基于 DNS 解析的流量切换,一种是基于 RPC 分组的流量切换。

  1. 基于 DNS 解析的流量切换
    基于 DNS 解析流量的切换,一般是通过把请求访问域名解析的 VIP 从一个 IDC 切换到另外一个 IDC。比如访问“www.weibo.com”,正常情况下北方用户会解析到联通机房的 VIP,南方用户会解析到电信机房的 VIP,如果联通机房发生故障的话,会把北方用户访问也解析到电信机房的 VIP,只不过此时网络延迟可能会变长。
  2. 基于 RPC 分组的流量切换
    对于一个服务来说,如果是部署在多个 IDC 的话,一般每个 IDC 就是一个分组。假如一个 IDC 出现故障,那么原先路由到这个分组的流量,就可以通过向配置中心下发命令,把原先路由到这个分组的流量全部切换到别的分组,这样的话就可以切换故障 IDC 的流量了。

单机故障

单机故障是发生概率最高的一种故障了,尤其对于业务量大的互联网应用来说,上万台机器的规模也是很常见的。这种情况下,发生单机故障的概率就很高了,这个时候只靠运维人肉处理显然不可行,所以就要求有某种手段来自动处理单机故障。

根据我的经验,处理单机故障一个有效的办法就是自动重启。具体来讲,你可以设置一个阈值,比如以某个接口的平均耗时为准,当监控单机上某个接口的平均耗时超过一定阈值时,就认为这台机器有问题,这个时候就需要把有问题的机器从线上集群中摘除掉,然后在重启服务后,重新加入到集群中。

不过这里要注意的是,需要防止网络抖动造成的接口超时从而触发自动重启。一种方法是在收集单机接口耗时数据时,多采集几个点,比如每 10s 采集一个点,采集 5 个点,当 5 个点中有超过 3 个点的数据都超过设定的阈值范围,才认为是真正的单机问题,这时会触发自动重启策略。

除此之外,为了防止某些特殊情况下,短时间内被重启的单机过多,造成整个服务池可用节点数太少,最好是设置一个可重启的单机数量占整个集群的最大比例,一般这个比例不要超过 10%,因为正常情况下,不大可能有超过 10% 的单机都出现故障。

总结

在遇到实际的故障时,往往多个手段是并用的,比如在出现单 IDC 故障,首先要快速切换流量到正常的 IDC,但此时可能正常 IDC 并不足以支撑两个 IDC 的流量,所以这个时候首先要降级部分功能,保证正常的 IDC 顺利支撑切换过来的流量。

而且要尽量让故障处理自动化,这样可以大大减少故障影响的时间。因为一旦需要引入人为干预,往往故障处理的时间都得是 10 分钟以上,这对大部分用户敏感型业务的影响是巨大的,如果能做到自动化故障处理的话,可以将故障处理的时间降低到 1 分钟以内甚至秒级别,这样的话对于用户的影响最小。

微服务相比于单体应用最大的不同之处在于,服务的调用从同一台机器内部的本地调用变成了不同机器之间的远程方法调用,但是这个过程也引入了两个不确定的因素。

一个是调用的执行是在服务提供者一端,即使服务消费者本身是正常的,服务提供者也可能由于诸如 CPU、网络 I/O、磁盘、内存、网卡等硬件原因导致调用失败,还有可能由于本身程序执行问题比如 GC 暂停导致调用失败。

另一个不确定因素是调用发生在两台机器之间,所以要经过网络传输,而网络的复杂性是不可控的,网络丢包、延迟以及随时可能发生的瞬间抖动都有可能造成调用失败。

所以,单体应用改造为微服务架构后,要针对服务调用失败进行特殊处理。
超时
首先你要知道的是,单体应用被改造成微服务架构后,一次用户调用可能会被拆分成多个系统之间的服务调用,任何一次服务调用如果发生问题都可能会导致最后用户调用失败。而且在微服务架构下,一个系统的问题会影响所有调用这个系统所提供服务的服务消费者,如果不加以控制,严重的话会引起整个系统雪崩。

所以在实际项目中,针对服务调用都要设置一个超时时间,以避免依赖的服务迟迟没有返回调用结果,把服务消费者拖死。这其中,超时时间的设定也是有讲究的,不是越短越好,因为太短可能会导致有些服务调用还没有来得及执行完就被丢弃了;当然时间也不能太长,太长有可能导致服务消费者被拖垮。根据我的经验,找到比较合适的超时时间需要根据正常情况下,服务提供者的服务水平来决定。具体来说,就是按照服务提供者线上真实的服务水平,取 P999 或者 P9999 的值,也就是以 99.9% 或者 99.99% 的调用都在多少毫秒内返回为准。

重试
虽然设置超时时间可以起到及时止损的效果,但是服务调用的结果毕竟是失败了,而大部分情况下,调用失败都是因为偶发的网络问题或者个别服务提供者节点有问题导致的,如果能换个节点再次访问说不定就能成功。而且从概率论的角度来讲,假如一次服务调用失败的概率为 1%,那么连续两次服务调用失败的概率就是 0.01%,失败率降低到原来的 1%。

所以,在实际服务调用时,经常还要设置一个服务调用超时后的重试次数。假如某个服务调用的超时时间设置为 100ms,重试次数设置为 1,那么当服务调用超过 100ms 后,服务消费者就会立即发起第二次服务调用,而不会再等待第一次调用返回的结果了。

双发
正如我刚才讲的那样,假如一次调用不成功的概率为 1%,那么连续两次调用都不成功的概率就是 0.01%,根据这个推论,一个简单的提高服务调用成功率的办法就是每次服务消费者要发起服务调用的时候,都同时发起两次服务调用,一方面可以提高调用的成功率,另一方面两次服务调用哪个先返回就采用哪次的返回结果,平均响应时间也要比一次调用更快,这就是双发。

但是这样的话,一次调用会给后端服务两倍的压力,所要消耗的资源也是加倍的,所以一般情况下,这种“鲁莽”的双发是不可取的。我这里讲一个更为聪明的双发,即“备份请求”(Backup Requests),它的大致思想是服务消费者发起一次服务调用后,在给定的时间内如果没有返回请求结果,那么服务消费者就立刻发起另一次服务调用。这里需要注意的是,这个设定的时间通常要比超时时间短得多,比如超时时间取的是 P999,那么备份请求时间取的可能是 P99 或者 P90,这是因为如果在 P99 或者 P90 的时间内调用还没有返回结果,那么大概率可以认为这次请求属于慢请求了,再次发起调用理论上返回要更快一些。

在实际线上服务运行时,P999 由于长尾请求时间较长的缘故,可能要远远大于 P99 和 P90。在我经历的一个项目中,一个服务的 P999 是 1s,而 P99 只有 200ms、P90 只有 50ms,这样的话,如果备份请求时间取的是 P90,那么第二次请求等待的时间只有 50ms。不过这里需要注意的是,备份请求要设置一个最大重试比例,以避免在服务端出现问题的时,大部分请求响应时间都会超过 P90 的值,导致请求量几乎翻倍,给服务提供者造成更大的压力。我的经验是这个最大重试比例可以设置成 15%,一方面能尽量体现备份请求的优势,另一方面不会给服务提供者额外增加太大的压力。

熔断
前面讲得一些手段在服务提供者偶发异常时会十分管用,但是假如服务提供者出现故障,短时间内无法恢复时,无论是超时重试还是双发不但不能提高服务调用的成功率,反而会因为重试给服务提供者带来更大的压力,从而加剧故障。

针对这种情况,就需要服务消费者能够探测到服务提供者发生故障,并短时间内停止请求,给服务提供者故障恢复的时间,待服务提供者恢复后,再继续请求。这就好比一条电路,电流负载过高的话,保险丝就会熔断,以防止火灾的发生,所以这种手段就被叫作“熔断”。

首先我们先来简单了解一下熔断的工作原理。

简单来讲,熔断就是把客户端的每一次服务调用用断路器封装起来,通过断路器来监控每一次服务调用。如果某一段时间内,服务调用失败的次数达到一定阈值,那么断路器就会被触发,后续的服务调用就直接返回,也就不会再向服务提供者发起请求了。

再来看下面这张图,熔断之后,一旦服务提供者恢复之后,服务调用如何恢复呢?这就牵扯到熔断中断路器的几种状态。

Closed 状态:正常情况下,断路器是处于关闭状态的,偶发的调用失败也不影响。

Open 状态:当服务调用失败次数达到一定阈值时,断路器就会处于开启状态,后续的服务调用就直接返回,不会向服务提供者发起请求。

Half Open 状态:当断路器开启后,每隔一段时间,会进入半打开状态,这时候会向服务提供者发起探测调用,以确定服务提供者是否恢复正常。如果调用成功了,断路器就关闭;如果没有成功,断路器就继续保持开启状态,并等待下一个周期重新进入半打开状态。

关于断路器的实现,最经典也是使用最广泛的莫过于 Netflix 开源的 Hystrix 了,下面我来给你介绍下 Hystrix 是如何实现断路器的。

Hystrix 的断路器也包含三种状态:关闭、打开、半打开。Hystrix 会把每一次服务调用都用 HystrixCommand 封装起来,它会实时记录每一次服务调用的状态,包括成功、失败、超时还是被线程拒绝。当一段时间内服务调用的失败率高于设定的阈值后,Hystrix 的断路器就会进入进入打开状态,新的服务调用就会直接返回,不会向服务提供者发起调用。再等待设定的时间间隔后,Hystrix 的断路器又会进入半打开状态,新的服务调用又可以重新发给服务提供者了;如果一段时间内服务调用的失败率依然高于设定的阈值的话,断路器会重新进入打开状态,否则的话,断路器会被重置为关闭状态。

其中决定断路器是否打开的失败率阈值可以通过下面这个参数来设定:

HystrixCommandProperties.circuitBreakerErrorThresholdPercentage()
而决定断路器何时进入半打开的状态的时间间隔可以通过下面这个参数来设定:

HystrixCommandProperties.circuitBreakerSleepWindowInMilliseconds()
断路器实现的关键就在于如何计算一段时间内服务调用的失败率,那么 Hystrix 是如何做的呢?

答案就是下图所示的滑动窗口算法,下面我来解释一下具体原理。

Hystrix 通过滑动窗口来对数据进行统计,默认情况下,滑动窗口包含 10 个桶,每个桶时间宽度为 1 秒,每个桶内记录了这 1 秒内所有服务调用中成功的、失败的、超时的以及被线程拒绝的次数。当新的 1 秒到来时,滑动窗口就会往前滑动,丢弃掉最旧的 1 个桶,把最新 1 个桶包含进来。

任意时刻,Hystrix 都会取滑动窗口内所有服务调用的失败率作为断路器开关状态的判断依据,这 10 个桶内记录的所有失败的、超时的、被线程拒绝的调用次数之和除以总的调用次数就是滑动窗口内所有服务的调用的失败率。

总结
微服务架构下服务调用失败的几种常见手段:超时、重试、双发以及熔断,实际使用时,具体选择哪种手段要根据具体业务情况来决定。

根据我的经验,大部分的服务调用都需要设置超时时间以及重试次数,当然对于非幂等的也就是同一个服务调用重复多次返回结果不一样的来说,不可以重试,比如大部分上行请求都是非幂等的。至于双发,它是在重试基础上进行一定程度的优化,减少了超时等待的时间,对于长尾请求的场景十分有效。采用双发策略后,服务调用的 P999 能大幅减少,经过我的实践证明是提高服务调用成功率非常有效的手段。而熔断能很好地解决依赖服务故障引起的连锁反应,对于线上存在大规模服务调用的情况是必不可少的,尤其是对非关键路径的调用,也就是说即使调用失败也对最终结果影响不大的情况下,更加应该引入熔断。

  • Copyrights © 2017-2023 WSQ
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信