普通视图

发现新文章,点击刷新页面。
昨天以前jtr109's Castle

Paste Image in VSCode

2022年10月24日 11:10

Preface

You may know I used Typora as the editor for my blog. If you are find the way to make your images compatible between Typora and Hugo1. Here is the article you need.

Project architecture

Here is the architecture of the project I created by Hugo. You can see the posts are hosted in content/posts and all images are in directories with the corresponding filename of posts without extension in static/images.

File architecture

What we need is making my utils match these requirements:

  1. Images can be pasted into the markdown file with a shortcut.
  2. Storing images in a specific directory like /images/${currentFileNameWithoutExt}.assets/xxx.png.
  3. Make pasted URL suit to the Hugo configuration.
  4. The images can be previewed in the Preview Panel in VSCode.

Pasting with a shortcut

Images, such as a screenshot, cannot be pasted into a markdown file in VSCode automatically with Ctrl + V. With the extension, Paste Image by mushan, you can pressing Ctrl + Alt + V (or, Cmd + Alt + V on Mac) to paste the image.

The preview in VSCode works good and the directory for pasting was created. But the link is a relative path from current file path.

![File architecture](2022-10-24-12-54-23.png)

Storing images in a specific path

By default, the images are store in the same directory with current post. But we expect it can be stored in a subdirectory of static/images. It can be set with the following configuration.

"pasteImage.path": "${projectRoot}/static/images/${currentFileNameWithoutExt}.assets"

Then, when you paste a image, it will be stored in the static/images/paste-image-in-vscode.assets/ directory. And the URL should be:

![File architecture](../../static/images/paste-image-in-vscode.assets/2022-10-24-12-54-23.png)

Making static path as the base path of images

However, the image is broken in the local service we lauch because the Hugo need a relative path from project path as root to show the image. Otherwise, it will broken as below.

File architecture

The expected path should be like below:

![The relative image path from current file](/static/images/paste-image-in-vscode.assets/2022-10-24-11-27-33.png)

We can change the base path from current file to the path of current project and add a slash before the relative path.

"pasteImage.basePath": "${projectRoot}/static",
"pasteImage.prefix": "/"

Preview support in the Preview Panel of VSCode

For now, our workspace is compatible with Hugo. But all images in the preview of VSCode are broken because the preview extension cannot realize the correct base path of images.

Sadly, I haven’t found a good way for it. So, we have to rise a local service to preview the post. Here is a feature requirement for specifying the image root path for Markdown preview. If it resolved, the images can be previewed in VSCode.


  1. Typora is still the best Markdown editor I have ever used and I bought a license from it when it went to 1.0. But the device count limitation is not suit to my use case. So, I move into VsCode. ↩︎

Golang LeetCode 工具集

2021年12月8日 15:51

在使用 LeetCode 进行算法练习的过程中,我通常会把代码复制到本地编辑,这样我可以更方便地使用编辑器提供的语法校验能力。

类型定义

但是经常会出现链表和二叉树题型,但是每次都需要定义一遍 ListNode 或者 TreeNode 之类的还是很麻烦。所以我将其打包1,每次只要通过导入和类型重命名即可在本地使用预定义的类型。

以下是使用 TreeNode 的示例:

package main

import "github.com/jtr109/lcutils/treenode"

type TreeNode = treenode.TreeNode

这样你就可以在 main 包中随意使用 TreeNode 了,这个结构体和 LeetCode 题目中的二叉树节点的结构体定义一致。

类型转换

我们在本地执行算法代码的另一个主要原因就是希望可以在本地根据题目中提供的示例进行测试。

细心的朋友肯定发现了问题,以任何一道题为例,LeetCode 在 Examples 中提供的 Input 通常是一个难看的列表,如果没有其他工具的帮助,我们只能每次手动构建数据结构。例如一个二叉树:

// Input: root = [3,9,20,null,null,15,7]
root := &TreeNode {
	Val: 3,
	Left: &TreeNode {
		Val: 9,
	},
	Right: &TreeNode {
		Val: 20,
		Left: &TreeNode {
			Val: 15,
		},
		Right: &TreeNode {
			Val: 7,
		}
	}
}

这样手动构建二叉树工作量很大,而且很难保证不出错。作为一个 DRY 爱好者,我已经帮你把转换函数包装好了,用法如下:

// Input: root = [3,9,20,null,null,15,7]
root := treenode.FromSlice([]nilint.NilInt{
	nilint.NewInt(3),
	nilint.NewInt(9),
	nilint.NewInt(20),
	nilint.NewNil(),
	nilint.NewNil(),
	nilint.NewInt(15),
	nilint.NewInt(7),
})

可以注意到这里引入了一个新的结构体 NilInt,这是因为 LeetCode 示例中的列表是层序遍历二叉树的结果,为了兼顾列表中存在的 null,选择了这样的实现方式。

在测试用例中进行比较

截止目前我们可以在单元测试中方便地构建一个二叉树了,但是还有一个问题:如何对二叉树进行比较?

如果我们构建一个二叉树作为 expected,虽然它和 actual 的值是相等的,但是因为 TreeNodeLeftRight 字段使用的是索引,所以地址不一致,不能使用 assert.Equal 直接比较。如果构建一个 func Equal(expected, actual *TreeNode) bool,那么虽然可以用来判断两个二叉树是否相等,但当出现不想等的情况,不能明确定位到两个二叉树不相等的位置。最终使用 ToSlice 将二叉树再转换回层序遍历的结果:

treenode.ToSlice(root) // []nilint.NilInt

以题目 Convert BST to Greater Tree 为例,完整的测试用例可以参考我项目中的代码

总结

为了在本地编写和测试 LeetCode 代码,我开发和维护了 Golang 包 github.com/jtr109/lcutils,希望能帮助你将更多精力集中在算法学习上。


  1. 本文以版本 1.0.16 为例,我还会不断迭代,推荐查看和使用最新版。 ↩︎

如何优雅地关闭 Pods

2021年11月23日 09:41

理论

当我们在 Kubernetes 中触发停止一个 Pod 时,它会向每个容器中的进程发送一个 SIGTERM 信号,等待一段时间,这段时间被称为终止宽限期(termination grace period)。如果超过宽限期 Pod 中仍有容器进程没有被终止,会发送 SIGKILL 强行终止该进程。

所以作为开发者要注意配置:

  1. 正确处理应用对 SIGTERM 信号的响应方式
  2. 指定更加合理的宽限期

设置宽限期

在配置文件和命令行中都可以设置宽限期。

配置文件

使用 kubectl explain 配置我们可以知道默认的终止宽限期为30秒。

$ kubectl explain pod.spec.terminationGracePeriodSeconds
KIND:     Pod
VERSION:  v1

FIELD:    terminationGracePeriodSeconds <integer>

DESCRIPTION:
     Optional duration in seconds the pod needs to terminate gracefully. May be
     decreased in delete request. Value must be non-negative integer. The value
     zero indicates delete immediately. If this value is nil, the default grace
     period will be used instead. The grace period is the duration in seconds
     after the processes running in the pod are sent a termination signal and
     the time when the processes are forcibly halted with a kill signal. Set
     this value longer than the expected cleanup time for your process. Defaults
     to 30 seconds.

通过配置 pod.spec.terminationGracePeriodSeconds 可以修改终止宽限期。例如:

apiVersion: v1
kind: Pod
metadata:
  name: busybox
  labels:
    app: busybox
spec:
  containers:
  - name: busybox
    image: busybox
    command: ["/bin/sh", "-ec", "sleep 1000"]
  terminationGracePeriodSeconds: 10 // change termination grace period

命令行

我们还可以在终止一个 Pod(或者 Deployment)的时候设置 --grace-period 来指定终止宽限期1

kubectl delete pod <name> --grace-period=<seconds>

对无法优雅关闭的应用进行处理

如果有一些应用不是我们开发的,那么可能无法修改它处理 SIGTERM 信号的方式。这时该如何优雅地关闭该应用呢?这时我们需要用到配置 pod.spec.containers.lifecycle.preStop。我们先看一下官方文档的解释:

PreStop hooks are not executed asynchronously from the signal to stop the Container; the hook must complete its execution before the TERM signal can be sent. If a PreStop hook hangs during execution, the Pod’s phase will be Terminating and remain there until the Pod is killed after its terminationGracePeriodSeconds expires.

所以在 preStop hook 执行完之前,不会向容器发送 SIGTERM 信号,并且 Pod 会处于 Terminating 状态。

例如 Nginx 接收到 SIGTERM 信号会立即退出进程,必须通过在 preStop 中执行命令 /usr/sbin/nginx -s quit 来优雅地结束进程。指定触发 Pod 停止的时候容器中的,来执行命令关闭 Nginx。示例代码2

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  containers:
  - name: nginx
    image: nginx:1.14.2
    ports:
    - containerPort: 80
    lifecycle:
      preStop:
        exec:
          # SIGTERM triggers a quick exit; gracefully terminate instead
          command: ["/usr/sbin/nginx","-s","quit"]

总结

从运维角度我们可以在部署时通过配置文件指定 Pods 的终止宽限期,也可以在手动终止时指定宽限期。

从开发者的角度,我们应该保证自己的应用可以正确处理 Kubernetes 对 Pods 的终止动作。一般情况下我们需要正确处理 SIGTERM 信号;如果有些服务无法正确处理 SIGTERM 信号,但是提供了终止命令,我们可以在 preStop 中指定。


  1. 细节可以参考官方文档。 ↩︎

  2. 参考文章 Graceful shutdown of pods with Kubernetes。 ↩︎

Golang 中切片的浅拷贝和深拷贝

2021年11月18日 15:54

本文将介绍 slice 的底层原理,在此基础上,我们能更好地了解深拷贝和浅拷贝具体做了什么。根据示例解释各种特殊情况下两种拷贝方式的影响。

基础知识

在分析拷贝结果之前,我们需要了解以下概念:

  1. slice 实际上是一个结构体,它保存了1
    1. 底层 array 片段在内存中的地址
    2. 底层 array 的长度(capacity)
    3. slice 自身的长度(length)
  2. 内存地址
    1. 修改 slice 中的元素不会影响 slice 指向的底层 array 的地址
    2. 如果 slice 追加元素没有超过底层 array 的长度(capacity),那么不会影响 slice 指向的底层 array 的地址,只是修改了对应地址的元素,并修改了 slice 的 length 属性。
    3. 如果 slice 追加元素超过了 capacity,那么 Golang 会给 slice 重新分配一块更大的内存空间,将原内存空间中的数据复制到新空间中,并追加新元素。这时 slice 指向了新的地址。

浅拷贝

浅拷贝复制的是 slice 对象,也就是上面提到的三个字段。复制的 slice 和源 slice 的指向的地址空间、length、capacity 都相同。

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	slice1 := []int{1, 2, 3, 4, 5}
	slice1 = slice1[:4]
	slice2 := slice1
	fmt.Println(slice1)                                          // [1 2 3 4]
	fmt.Println(slice2)                                          // [1 2 3 4]
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // &{824634441776 4 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // &{824634441776 4 5}

	slice1[1] = 100
	fmt.Println(slice1)                                          // [1 2 3 4]
	fmt.Println(slice2)                                          // [[1 2 3 4]
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // &{824634441776 4 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // &{824634441776 4 5}

	slice1 = append(slice1, 200)
	fmt.Println(slice1)                                          // [1 100 3 4 200]
	fmt.Println(slice2)                                          // [1 100 3 4]
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // &{824634441776 5 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // &{824634441776 4 5}

	slice2 = append(slice2, 400)
	fmt.Println(slice1)                                          // [1 100 3 4 400]
	fmt.Println(slice2)                                          // [1 100 3 4 400]
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // &{824634441776 5 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // &{824634441776 4 5}

	slice1 = append(slice1, 300)
	fmt.Println(slice1)                                          // [1 100 3 4 400 300]
	fmt.Println(slice2)                                          // [1 100 3 4 400]
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // &{824634499072 6 10}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // &{824634441776 4 5}
}

现在我们根据示例代码来分析两个 slices 指向同一个内存地址意味着什么:

  1. 如果修改两个 slices 都包含的元素,会相互影响。

  2. slice1 后面追加元素,没有导致重新分配地址,两个 slices 还指向同一个底层 array。但是只有 slice1 的 length 发生了改变,所以两个 slices 的值不相同。

  3. slice2 后面追加元素,会修改 index 4 位置的元素,所以也同时修改了 slice1 中对应位置的元素。

  4. slice1 后追加元素超过了底层 array 的大小(capacity),所以触发重新分配地址,两个 slices 指向不同地址,不再相互影响。

深拷贝

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	slice1 := []int{1, 2, 3, 4, 5, 6}
	slice1 = slice1[:5]
	slice2 := make([]int, 5, 5)
	slice3 := make([]int, 3, 4)
	slice4 := make([]int, 6, 6)
	copy(slice2, slice1)
	copy(slice3, slice1)
	copy(slice4, slice1)
	fmt.Println(slice1)                                          // [1 2 3 4 5]
	fmt.Println(slice2)                                          // [1 2 3 4 5]
	fmt.Println(slice3)                                          // [1 2 3]
	fmt.Println(slice4)                                          // [1 2 3 4 5 0]
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // &{824634818560 5 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // &{824634818608 5 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice3))) // &{824634826752 3 4}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice4))) // &{824634818656 6 6}

	slice1[1] = 100
	fmt.Println(slice1)                                          // [1 100 3 4 5]
	fmt.Println(slice2)                                          // [1 2 3 4 5]
	fmt.Println(slice3)                                          // [1 2 3]
	fmt.Println(slice4)                                          // [1 2 3 4 5 0]
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // &{824634818560 5 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // &{824634818608 5 5}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice3))) // &{824634826752 3 4}
	fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&slice4))) // &{824634818656 6 6}
}

深拷贝是使用 copy 函数将源 slice 包含的元素拷贝到目标 slice 中。

从示例可以看到,只属于底层 array 的元素不会被拷贝。同时 copy 只会影响目标 slice 中索引小于源 slice length 的元素,其他元素将保持不变。

Golang 中控制 print formatting 的结果

2021年11月17日 17:54

问题

如果对 Golang 中的结构体进行格式化打印,会返回一个带花括号的复合结构,表示结构体的各个字段值。

那么是否可以通过修改结构体来实现格式化输出特定的字符串呢?或者说怎么控制对结构体执行 fmt.Printf("%s", xxx) 的返回呢?

String 方法

查看 Effective Go 中的 Printing 段落可以知道,我们需要修改结构体的 String 方法。

package main

import (
	"fmt"
)

type Foo struct {
	name string
}

type Bar Foo

func (b *Bar) String() string {
	return fmt.Sprintf("%s", string(b.name))
}

type Biz Foo

func (b Biz) String() string {
	return fmt.Sprintf("%s", string(b.name))
}

func main() {
	foo := Foo{"Tom"}
	bar := Bar{"Tom"}
	biz := Biz{"Tom"}
	fmt.Printf("%s %s\n", foo, &foo) // {Tom} &{Tom}
	fmt.Printf("%s %s\n", bar, &bar) // {Tom} Tom
	fmt.Printf("%s %s\n", biz, &biz) // Tom Tom
}

需要注意:如果定义的 String 方法的接收器是指针而非类型,只会修改指针的格式化输出结果。如果希望控制对象本身的格式化输出结果,需要将结构体作为接收器。

String 递归问题

fmt.Sprintf 会调用对象的 String 方法,所以如下代码会导致递归调用。

type MyString string

func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", m) // Error: will recur forever.
}

所以需要在方法定义中使用一些手段来打破递归,例如使用 string(m) 进行类型转换。

type MyString string

func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", string(m)) // OK: note conversion.
}

其他问题

那么应该如何控制 %d%g 等输出格式呢?

Golang 中的匿名字段和提升字段

2021年11月17日 17:09

有时你会看到在 Golang 中定义结构体时,有种写法是只定义类型而不定义字段名。下面将介绍这种被称为匿名字段的语法,以及它能够实现的作用。

匿名字段

在定义结构体的时候,如果一个字段没有声明名称而只声明了类型,那么这个字段就是一个匿名字段(anonymous fields)。该字段的名称和类型一致。

package main

import (
	"fmt"
)

type User struct {
	name string
}

type Service struct {
	User
	string
	int
}

func main() {
	s := Service{
		User: User{
			name: "Jack",
		},
		string: "foo",
		int:    1,
	}
	fmt.Printf("%s %s %d\n", s.User, s.string, s.int) // {Jack} foo 1
}

https://play.golang.org/p/oDE7NxiTWWf

提升字段

同时匿名字段还有一个附带效果,就是提升(promotion)。如果在结构体中定义一个子结构体作为匿名字段,那么这个子结构体的字段(promoted fields)和方法(promoted methods)都会被提升到父结构体中。

package main

import (
	"fmt"
)

type user struct {
	name string
}

func (u *user) Greet() {
	fmt.Printf("Hello, I'm %s\n", u.name)
}

type Service struct {
	name string
	user
}

func main() {
	s := Service{
		name: "test service",
		user: user{
			name: "Jack",
		},
	}
	fmt.Printf("%s %s\n", s.name, s.user.name) // Jack Jack
	s.Greet()                                  // Hello, I'm Jack
	s.user.Greet()                             // Hello, I'm Jack
}

https://play.golang.org/p/zs_d4uknP8Z

需要注意,提升字段实际上是一个语法糖,被提升的方法中调用的属性还是子结构体的,并不能通过提升获取父结构体中的属性。

江苏中国移动更换为 8 元自由选套餐的方法

2021年11月3日 11:53

原文发布在 Notion 中,不过未来计划主要在博客更新。

用卡思路

现代社会我们很多帐号和身份信息都是和自己的手机号紧密绑定在一起的,所以注销一个号码对我们来说几乎是不可能的事情。同时我们可能会因为各种原因不再将一张手机卡作为主力。

好在很多手机提供了双卡双待能力,给了我们另一种思路:

  • 我们可以保留一张主卡作为自己所有帐号的绑定号码,给它选择最低的月租套餐保证其存活。这张卡不绑定任何针对特定场景的优惠套餐,只为了保留纯粹的电话和短信功能。同时可以根据不同时期、不同场景的不同需求
  • 选择另一张卡作为副卡,比如特定的上网卡或者工作专用的号码。

通过这样的配置,彻底将选择权把握在自己的手里。

更换移动 8 元保号套餐

按照上述模式,我们需要做的就是给主卡选择最低月消费的套餐。目前各个运营商都有最低8元的保号套餐,以中国移动为例,价格最低的是8月的自由选套餐。但是各个运营商都不会积极提供这个套餐,所以套餐更换经历必然是曲折的。这里将以我的江苏中国移动号码更换套餐的经历给出一些建议。

注意:如果号码正绑定着有协议规定截止日期前不能更换的套餐,需要先想办法取消这些套餐,具体方法不在本文讨论范围内。

截至目前,用户是在移动掌上营业厅 App 中选择「自由选」套餐的。如果通过在线客服咨询,会告知用户需要致电 10086 或者前往线下营业厅办理。不过现在大家都很忙,如果能通过电话解决,我们没必要跑一趟营业厅,所以我选择了致电 10086。

致电 10086 后转人工服务,你要求更换「自由选」套餐,客服会告诉你他们将安排专员和您联系更换套餐,或者收到办理成功的短信提醒。但是按照我的经验,实际上并不会有专员和你联系,套餐也不会自动完成变更。

这个时候你可以选择的一种方式是使用强硬态度表示会去工信部投诉。如果还是无法立即解决,就需要去工信部网站上进行投诉,之后移动会派人和你联系,为你修改套餐。

·但是上述方法的问题是流程比较繁琐,很多人看到就选择放弃了。不过其实还有另一个方法可以用。

江苏中国移动电话客服只有在每月的最后一天有权限将致电用户的套餐更改为 8 元或者 18 元套餐,所以我们需要做的就是等到这一天致电要求修改。通常电话客服都会在确认用户更换意图、告知变更结果后立即着手办理。

在一维数组算法题中使用双指针

2021年10月20日 14:15

什么是双指针

双指针就是指为数组定义两个指针,通过移动两个指针来读取数组相应位置的数据,并对数据进行处理。这种方式也可以根据题型不同推广到「三指针」甚至「多指针」,但是因为理念上是相同的,多指针的题型徒增题目的复杂度,所以通常面试类问题中出现「双指针」的概率更大。

题型特征

那么如何判断一道题目适合使用双指针作为解题方式呢?我们先举几个例子1

解决这类题目的直观想法是题目可以直接通过定义两个下标 ij,移动下标实现内外两个迭代。在这样的解法中,通常在两个迭代过程中 i 是不会走回头路的,但是 j 一直会回到 i 当前或之后的位置。所以这类题型会有另一个特征就是不走回头路,下标 ji 移动时,应该保持当前位置或者继续向右前进。

在单纯的两层循环中,复杂度只能控制在 $O(NlogN)$。而使用双指针的题型中,因为两个指针都只从左到右移动一轮,所以复杂度只有 $O(2N)$,即 $O(N)$。

如果一道数组类算法题只需要两层循环就可以解决,那它就没有的意义了。所以遇到一道数组类型的题目,可以先将问题简化成两层循环,再基于这样的解决将思路转换为两个指针,并思考基于双指针,是否能进一步优化解答。


  1. 例子参考了代码随想录。 ↩︎

使用 Mac 抢救变砖的小米路由器

2021年9月1日 23:21

背景

在对小米路由器进行刷机的时候可能因为各种原因导致路由器无法启动,这种情况下无法再刷入新系统,即我们俗称的「变砖」。但是路由器本身提供了一种方式来修复问题。

原理

小米路由器在进入 recovery 模式后,会向 192.168.31.1 地址发送 TFTP 协议的请求,请求名为 test.bin 的文件,并通过该文件进行刷机。我们要做的就是利用这个方式将确认可用的镜像1刷到路由器中。

准备工作

我们需要用到的硬件有:

  • 网线一根
  • 如果 Mac 没有网口,需要准备一个 USB 网卡

需要的软件有:

  • 下载路由器对应的可用镜像1
  • Wireshark 不是必须的,不过最好准备一个,否则只能靠猜,并不知道路由器的状态是否符合预期
  • 其他必需软件都是 Mac 自带的

首先将路由器断电,将路由器其他端口上的连接线断开,仅保留一个 LAN 口通过网线和 Mac 连接。

设置网卡

为了应对路由器在 recovery 模式下的行为,在 Mac 中将有线网卡的 IP 和子网掩码设置如下。其中路由一栏不填:

image-20210901232508683

启动 DHCP 服务

首先备份 /etc/bootpd.plist 文件,并使用下面的配置代替。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>bootp_enabled</key>
    <false/>
    <key>detect_other_dhcp_server</key>
    <integer>1</integer>
    <key>dhcp_enabled</key>
    <array>
        <string>en6</string>
    </array>
    <key>reply_threshold_seconds</key>
    <integer>0</integer>
    <key>Subnets</key>
    <array>
        <dict>
            <key>allocate</key>
            <true/>
            <key>lease_max</key>
            <integer>86400</integer>
            <key>lease_min</key>
            <integer>86400</integer>
            <key>name</key>
            <string>192.168.31</string>
            <key>net_address</key>
            <string>192.168.31.0</string>
            <key>net_mask</key>
            <string>255.255.255.0</string>
            <key>net_range</key>
            <array>
                <string>192.168.31.2</string>
                <string>192.168.31.254</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

需要注意,我的网卡是 en6 所以 dhcp_enabled 对应的参数是 en6

网卡的名称可以使用 ifconfig 查看。不会使用的朋友可以使用 Wireshark 辅助确认:首先通过网络选项查看网卡的名字(如我的网卡叫 USB 10/100/1000 LAN,这个因人而异),再通过 Wireshark 找到对应名称的网卡,名称后面显示的就是网卡序号。另外需要注意的是,设备不插入的时候可能不会显示对应的网卡,遇到这样的情况可以先将路由器供电后和 Mac 连接,记下网卡序号后再断电即可。

完成配置后执行如下命令启动 DHCP 服务:

sudo /bin/launchctl load -w /System/Library/LaunchDaemons/bootps.plist

启动 Wireshark

启动 Wireshark 并监测 Mac 和路由器连接的网口,在筛选框中输入 arp || tftp 筛选这两种协议的记录。

启动路由器 Recovery 模式

在路由器不通电的情况下长按 reset 按钮,并重新接入电源,这时橙色提示灯常亮,等待几秒橙色提示灯开始闪烁,此时可以松开按钮。

在 Wireshark 中应该看到路由器询问 192.168.31.1 的设备2,再通过 TFTP 协议请求 test.bin 文件3

image-20210901232535559

在 Mac 中启动 TFTP 服务器

我们可以看到路由器(192.168.31.2)请求 test.bin 文件超时,因为我们还没有启动 TFTP Server。

将我们的镜像放到目录 /private/tftpboot 下,命名为 test.bin。执行命令启动 TFTP Server

sudo launchctl load -F /System/Library/LaunchDaemons/tftp.plist

应该看到 TFTP 服务很快开始传入并传输完毕。

image-20210901232557933

image-20210901232629308

大约两三分钟后就可以看到路由器上蓝灯闪烁,说明烧录完毕,可以重启路由器。我们先将路由器电源断开。

验证结果

首先修改 Mac 的网络配置,将之前配置的固定 IP 改为 DHCP 自动配置 IP。否则 Mac 会和我们的路由器抢 192.168.31.1 这个 IP。

在路由器断电大概10秒过后,插入电源启动路由器。路由器应该会显示橙色灯常亮,之后蓝灯常亮。这说明我们的路由器恢复正常了。

环境恢复

停止 TFTP 服务器:

sudo launchctl unload -F /System/Library/LaunchDaemons/tftp.plist

停止 DHCP 服务器

sudo /bin/launchctl unload -w /System/Library/LaunchDaemons/bootps.plist

相关链接


  1. 建议先使用官网提供的 ROM 完成修复。 ↩︎ ↩︎

  2. 记录 No. 50 ↩︎

  3. 记录 No. 53 ↩︎

记录一次 Golang 二维 slice 处理问题

2021年8月4日 15:00

问题描述

在编写一个算法代码的过程中遇到了异常现象。精简成如下代码:

package main

import (
	"fmt"
)

func main() {
	result := [][]int{}
	result = append(result, []int{1, 2})

	current1 := [][]int{}
	for _, v := range result {
		current1 = append(current1, append(v, 3))
	}
	result = append(result, current1...)
	fmt.Println("result", result)

	current2 := [][]int{}
	for _, v := range result[1:2] {
		current2 = append(current2, append(v, 4))
	}
	result = append(result, current2...)
	fmt.Println("result", result)

	current3 := [][]int{}
	for _, v := range result[1:2] {
		current3 = append(current3, append(v, 5))
	}
	result = append(result, current3...)
	fmt.Println("result", result)

}

执行代码会输出如下结果:

result [[1 2] [1 2 3]]
result [[1 2] [1 2 3] [1 2 3 4]]
result [[1 2] [1 2 3] [1 2 3 5] [1 2 3 5]]

可以发现我们只对 result 做了 append 操作,但是第三次的结果对 index 2 的元素产生了影响。

原因分析

以代码为例:

a := []int{1}
b := append(a, 2)

append 在执行时:

首先判断 a 指向的内存空间,即 capability,是否足够增加新的元素。

如果空间不足,将会分配一块更大的内存空间,将这块内存空间分配给变量 b。然后将 a 中的元素复制到这块内存中,同时在这些元素的末尾追加新的元素。

但是如果空间足够,Go 会直接在指向的 slice 中的元素后追加新元素,并将 a 的内存地址共享给 b。虽然 ab 的内存地址是共享的,但是由于他们的 length 不同,所以 a 的值为 [1]b[1 2]

而我们的例子中,由于 index 为 1 的元素 [1 2 3] 和 2 的元素 [1 2 3 4] 共享了一块内存地址,此时虽然看不到,但是 [1 2 3] 对应的内存地址中有 4 个元素 [1 2 3 4]。所以给 [1 2 3] 追加新的值时,会修改 3 后面的值,将内存中的值修改为 [1 2 3 5],这个修改同时影响了原本 index 为 2 位置的元素。

将 minikube 的服务暴露到宿主机外

2021年6月24日 22:45

背景

minikube 是一款基于 Kubernetes 的定位于快速验证功能的小型容器编排环境。

由于它的定位特性,我们在使用中会发现 minikube 虚拟出了一个 IP 作为自身的节点 IP,该 IP 和宿主机不同。对于 NodePort 类型的 Service 也没有办法通过 127.0.0.1 访问。

Host 内访问

The minikube VM is exposed to the host system via a host-only IP address, that can be obtained with the minikube ip command.

我们必须通过 minikube ip 找到 minikube 的 IP 并通过它来访问 NodePort 类型的 service

另外可以留意到 LoadBalancer 类型的 service 在默认情况下 external IP 为 <pending>,为了要能够访问到 service,需要通过 minikube tunnel 建立隧道和服务通信。在执行 minikube tunnel 后,可以发现 external IP 被设置成和 cluster IP 一样的值,这时候就可以通过 <minikube-ip>:<service-port> 来访问 service 了。

Host 外访问

通过 minikube 可以很方便地在本机访问,同时避免了对宿主机端口的占用。但是也带来了另一个问题:无法直接通过访问宿主机的端口来访问 services 进行调试。

比如我的实验机是一台云服务虚拟机,我在自己的电脑上可以访问这台虚拟机,但是不能访问虚拟机上 minikube 暴露的服务。

虽然没有办法让 minikube 直接通过宿主机端口对外暴露 services,但是如果我们把问题换个角度思考,就很容易找到解决办法:如何将一个 service(不特定类型)暴露到本机。

这时候最简单的办法就是通过 kubectl port-forward 转发端口。

假设我有 service/istio-ingressgateway,service 监听 80 端口,我希望暴露在宿主机的 31303 端口,就可以使用以下命令1:·

kubectl port-forward --address 0.0.0.0 -n istio-system service/istio-ingressgateway 31303:80

查看 Rust Crates 的 Features

2021年5月29日 16:24

背景

在 Rust 学习过程中,我发现很多库的示例中会制定 features flag,我理解 features 是用来指定使用库的部分功能。例如 Tokio 的文档说明:

Tokio has a lot of functionality (TCP, UDP, Unix sockets, timers, sync utilities, multiple scheduler types, etc). Not all applications need all functionality. When attempting to optimize compile time or the end application footprint, the application can decide to opt into only the features it uses.

原文链接

查看可选的 Features

但是作为一个使用者我应该怎么知道一个库有哪些 features 可以选择,这些 features 分别代表什么含义呢?

在 Tokio 的 docs.rs 文档 上,我发现了最上方导航栏中的 Feature flags 栏,点击即可查看到可选的 features 的选项以及这些选项提供哪些功能的信息。

声明可选的 Features

那下一个疑问就是作为 crate 作者应该怎样声明支持的 features 呢?查看 The Cargo Book 中的解释,是在库的 Cargo.toml 中通过 [features] 段落指定的。

另外也建议 crate 的作者在文档中解释 features 的用法:

You are encouraged to document which features are available in your package. This can be done by adding doc comments at the top of lib.rs.

多种 Git Pull 模式差异分析

2021年5月8日 11:37

背景

在执行 git pull 命令时,我们可能会看到这样的提示信息:

hint: Pulling without specifying how to reconcile divergent branches is
hint: discouraged. You can squelch this message by running one of the following
hint: commands sometime before your next pull:
hint: 
hint:   git config pull.rebase false  # merge (the default strategy)
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
hint: 
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.

显然 Git 希望我们指定一种 pull 的方式,它提供了三种模式:

  • merge
  • rebase
  • fast-forward only

区分

这三种模式都是为了指定 git pull 时如果本地和远端代码发生冲突时应该使用什么策略处理。

     A---B---C origin/master
    /
D---E---F---G master
    ^
    origin/master in your repository

Merge

Merge 模式会在发生冲突时,将远端分支上的改动 merge 到本地。官方文档中的实例说明了在触发 git pull --no-rebase (merge 模式)之后,变化如下:

          A---B---C origin/master
         /         \
    D---E---F---G---H master

可以看到会多出一个 commit H,这个结果可以理解为在本地 master 分支上,执行了 git merge origin/master1 将远端分支合并到了本地分支上。但是可以看到,远端的 master 分支和本地 master 分支还是不同步的。通常情况下这不符合我们的预期。

Rebase

Rebase 模式和 merge 模式类似,可以理解为在本地 master 分支上执行 git rebase origin/master1 将本地 master 分支基于远端 master 分支进行 rebase。执行完成后,本地的 master 分支会_超前于_远端的 master 分支。

         A---B---C origin/master
        /
    D---E---A---B---C---F'---G' master

Fast-Forward Only

To phrase that another way, when you try to merge one commit with a commit that can be reached by following the first commit’s history, Git simplifies things by moving the pointer forward because there is no divergent work to merge together — this is called a “fast-forward.”

首先我们需要了解 fast-forward 模式2,该模式在 git merge 中也有,该模式下,如果 Git 可以 resolve the merge,Git 只会移动指针,而不会新建 commit。

加入有下面这样两个分支:

* c5571a5 (HEAD -> master) feat: edit A on master branch
| * b56e7d6 (develop) feat: edit A in develop
|/  
* ea5e820 feat: edit A
* 3af1481 feat: add A

即使此时 master 和 develop 分支的结果是一样的,如果我们执行 git merge develop --ff-only,会报错:

fatal: Not possible to fast-forward, aborting.

我们回到 git pull,如果本地和远端代码如下,A 和 A’ 中的实际内容是一致的:

     A---B---C origin/master
    /
D---E---A' master
    ^
    origin/master in your repository

在执行带有 --ff-onlygit pull 命令后,会发生报错。由于两地 commit 不一致,Git 无法通过 fast-forward 实现更新。

下面我们直接看文档来学习集中 fast-forward 模式:

With --ff, when possible resolve the merge as a fast-forward (only update the branch pointer to match the merged branch; do not create a merge commit). When not possible (when the merged-in history is not a descendant of the current history), create a merge commit.

With --no-ff, create a merge commit in all cases, even when the merge could instead be resolved as a fast-forward.

With --ff-only, resolve the merge as a fast-forward when possible. When not possible, refuse to merge and exit with a non-zero status.

可以看到,如果对于上面这种情况,我们使用 --ff,达到的效果和 git pull --merge 是一样的。


  1. 注意!只是打个比方,没有这种操作方式。 ↩︎ ↩︎

  2. 参考[官方文档](https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging#:~:text=to phrase that another way%2C when you try to merge one commit with a commit that can be reached by following the first commit’s history%2C git simplifies things by moving the pointer forward because there is no divergent work to merge together — this is called a “fast-forward.")。 ↩︎

TCP 挥手动作大赏

2021年4月22日 15:43

四次挥手还是三次挥手

我们通常说的四次挥手是指:

  1. 客户端完成数据传输后,发送 FIN
  2. 服务端接收到客户端的 FIN 后发送 ACK 确认收到
  3. 服务端完成消息发送后,发送 FIN
  4. 客户端接收到服务端端 FIN 后发送 ACK 确认收到

但是实践中也有很多情况下服务端会把 2、3 阶段的 ACK 和 FIN 合并发送,从而通过三次消息发送实现了挥手1

形态各异的挥手

标准是标准,但是各个网站对挥手的处理方式可谓五花八门,让我们来见识见识不同的网站是怎么处理的。

微信

image-20210422155103336

微信的服务端在挥手时(No. 17995)没有把 Ack 加 1,导致客户端混乱。客户端只能理解为服务端中断,发送了一个正常的分组(No. 17997)来结束 TCP 连接。

样本来自我 Mac 上微信客户端的一个 HTTP 请求,我也不知道是干嘛的。

image-20210422162514104

百度

通过下面的命令请求百度首页。

curl http://www.baidu.com

image-20210422155732834

可以看到百度的挥手协议非常标准,可喜可贺。

GitHub

通过下面的命令请求 GitHub 首页。

curl http://www.github.com

image-20210422155854529

可以看到 GitHub 的服务端将 FIN 和 ACK 合并在了一起,只发送了一个请求。所以挥手只进行了三步。

知乎

通过下面的命令请求知乎首页。

curl -v http://www.zhihu.com

image-20210422160446638

可以看到知乎也只进行三次挥手。

新浪

通过以下命令请求新浪首页。

curl -v http://www.sina.com

image-20210422160942682

新浪也只做了三次握手。


  1. 参考 Wikipedia 的说明。 ↩︎

通过 Wireshark 学习 TCP 序号和确认号

2021年4月22日 10:31

概述

我们知道 TCP 连接通过序号(sequence number)和确认号(acknowledgment number)来避免消息的乱序。发送端通过分组头部的 序号声明分组的第一个字节的序号,通过头部的确认号声明预期接收的下一个字节的序号。

image-20210422104637194

图片来源:Wikipedia

让我们通过 Wireshark 抓取一个完整的 TCP 连接来看看具体是怎么实现的,着重关注 Seq 和 Ack 的变化。

概览

首先我们观察一个 TCP 请求的完整生命周期。可以看到:

  1. 前三次传输的方向和标记位展示了 TCP 三次握手动作。
  2. 从 No. 17466 开始的四次请求完成了 HTTP 相关的数据传输。
  3. 从 No. 17940 开始的最后三次(除去黑色的那条)传输完成了挥手动作。

由于样本中挥手阶段的特殊性,如果只希望了解正确的 TCP 请求中 Seq 和 Ack 变化,请参考上述步骤中的前两个阶段(握手和数据传输)。

握手阶段

客户端 SYN

image-20210422105759101

我们仔细查看客户端 SYN 分组,这是整个生命周期的第一个请求,可以看到 info 中显示 Seq=0。但是这个 Seq 是否真的是 0 呢?从下面的详情中可以看到,序号 0 是一个相对值,实际上客户端生成了一个随机数作为序号(2777949690)。标记位 SYN 说明了这个分组中的 Seq 是一个初始值,这对接收方来说很重要。

所有序号都是基于一个初始的随机值,如果没有特殊说明,后续讨论中出现的序号均为相对序号。

第一条 SYN 消息中的 ACK 标记位没有激活,所以消息头中的 Acknowledgment Number 是没有意义的,所以默认给了 0。

  • 方向:客户端 -> 服务端
  • Sequence Number: 0

服务端 SYN,ACK

image-20210422110816857

服务端作为接收方收到客户端发送的 SYN 分组后,确认 SYN 标记位激活,Seq 相对序号为 0。开始准备返回的分组。

在分组中,服务端会激活 ACK 标记位,同时将接收到的分组序号加 1 作为 Ack 的值,即 Ack 置为 1,表示接收到了 Seq 为 0 的分组。

为了建立全双工通道,服务端也需要向客户端发送 SYN 信息,服务端激活 SYN 标记为,随机生成 Seq 值(相对序号为 0),组成分组发送给客户端。

  • 方向:服务端 -> 客户端
  • Sequence Number: 0
  • Acknowledgment Number: 1

客户端 ACK

image-20210422112007827

客户端接收到服务端的 SYN,ACK 分组,开始构成分组。

客户端为了表示确认收到服务端发来的分组,解析分组中的 Seq 相对序号为 0,在即将发送的分组中将 ACK 标记位激活,Ack 置为 1。

同时通过接收到的来自服务端的分组中 Ack 为 1,知道自己发送的 SYN 分组已经被确认接收,把即将发送的分组的 Seq 置为 1。

  • 方向:客户端 -> 服务端
  • Sequence Number: 1
  • Acknowledgment Number: 1

经过以上步骤,TCP 三次握手就完成了。

数据传输

完成握手后,客户端准备发送 HTTP 请求。由概览中的截图可以看出,数据传输的四个分组可以分为两组,分别是:

  1. 客户端发送的携带 HTTP 请求数据的 TCP 分组,和服务端接收到该分组返回的 ACK 分组。以及
  2. 服务端发送的携带 HTTP 响应数据的 TCP 分组,和客户端接收到该分组返回的 ACK 分组。

我们这里传输的数据都比较小,不考虑 TCP 拆分数据的情况。

客户端发送 HTTP 请求

image-20210422114037381

客户端将 HTTP 请求数据放在 TCP 分组中,指定 Seq=1,Ack=1 发送数据。这里需要留意的是,该分组大小为 935。

  • 方向:客户端 -> 服务端
  • Sequence Number: 1
  • Acknowledgment Number: 1
  • 荷载大小:935

服务端确认收到 HTTP 请求

image-20210422114530373

服务端接收到搭载 HTTP 请求的 TCP 分组后,开始构建 ACK 分组。

服务端解析收到的分组发现这是一个 Seq=1 的长度为 935 的分组,即收到的最后一个字节的序号为 935。为了答复确认收到该分组,将即将发送的分组 ACK 标记为激活,Ack 置为 936。

接收到的分组中,Ack=1,所以即将发送的分组中将 Seq=1。

  • 方向:服务端 -> 客户端
  • Sequence Number: 1
  • Acknowledgment Number: 936

服务端发送 HTTP 响应

image-20210422115538165

服务端将 HTTP 响应数据放在 TCP 分组中,发送给客户端。这里需要注意的是,这个请求是基于上述 [客户端发送的请求](#客户端发送 HTTP 请求),所以他的序号和确认序号设计的依据是和[前一个 ACK 分组](#服务端确认收到 HTTP 请求)是一致的。

  • 方向:服务端 -> 客户端
  • Sequence Number: 1
  • Acknowledgment Number: 936
  • 荷载大小:1403

客户端确认收到响应

image-20210422133518796

客户端收到包含 HTTP 响应的 TCP 分组后,准备向服务端发送 ACK 确认分组。

由于接收到的分组 Seq=1,荷载大小为 1403,所以返回 ACK 标记为激活,Ack=1404。

由于接收到的分组中 Ack=936,所以 Seq=936。

经过以上四个步骤,HTTP 的请求和响应就都完成了。

  • 方向:服务端 -> 客户端
  • Sequence Number: 936
  • Acknowledgment Number: 1404

挥手阶段

你可能会注意到,这里的挥手只经历了三步。这个问题我们会在之后的文章中讨论,现在先看看挥手时的序号变化。

客户端发送 FIN 分组

image-20210422144424231

当客户端发送完所有消息后,就会发送 FIN 分组(这个分组里同时激活了 ACK 标记位,原因不明)。

该 FIN 分组基于[服务端发来的 HTTP 响应](#服务端发送 HTTP 响应),所以 Seq 和 Ack 和客户端发送的确认响应的 TCP 相同。

  • 方向:服务端 -> 客户端
  • Sequence Number: 936
  • Acknowledgment Number: 1404

服务端同时发送 FIN,ACK 分组

理论上服务端应该先返回 ACK 分组,表示确认收到了客户端发送的 FIN 分组。但是也可以将 ACK 分组和声明服务器停止发送的 FIN 标记一起返回。所以 FIN 和 ACK 标记位同时被激活,分组被发送给客户端。

image-20210422145339782

到了这里,我们应该已经有纠错的能力了,能够分析为什么 Wireshark 认为出现了乱序。因为这里的 FIN,ACK 分组应该返回的序号为:

  • 方向:服务端 -> 客户端
  • Sequence Number: 1404
  • Acknowledgment Number: 937(不应该是 936)

客户端发送 FIN,ACK 分组

image-20210422150857634

客户端的 TCP 服务接收到上述分组后,发现了异常,只能当作接收到了 FIN 请求,并重新发送了 FIN,ACK 分组。

  • 方向:客户端 -> 服务端
  • Sequence Number: 936
  • Acknowledgment Number: 1405

服务端发送 ACK 分组

image-20210422150912351

服务端收到了客户端发送到 FIN,ACK 分组,返回了 ACK 分组。

  • 方向:服务端 -> 客户端
  • Sequence Number: 1405
  • Acknowledgment Number: 937

总结

我们按照理想情况(握手和数据传输阶段和样本一致,挥手阶段按照四次挥手)梳理一下整个请求流程。

握手阶段

sequenceDiagram
    Client ->> Server: [SYN] Seq=0 Len=0
    Server ->> Client: [SYN, ACK] Seq=0 Ack=1 Len=0
    Client ->> Server: [ACK] Seq=1 Ack=1 Len=0

数据传输

客户端发送请求

sequenceDiagram
    Client ->> Server: [PSH, ACK] Seq=1 Ack=1 Len=935
    Server ->> Client: [ACK] Seq=1 Ack=936 Len=0

服务端发送响应

sequenceDiagram
    participant Client
    participant Server
    Server ->> Client: [PSH, ACK] Seq=1 Ack=936 Len=1403
    Client ->> Server: [ACK] Seq=936 Ack=1404 Len=0

挥手阶段

sequenceDiagram
    Client ->> Server: [FIN] Seq=936 Ack=1404 Len=0
    Server ->> Client: [ACK] Seq=1404 Ack=937 Len=0
    Server ->> Client: [FIN] Seq=1404 Ack=937 Len=0
    Client ->> Server: [ACK] Seq=937 Ack=1405 Len=0

可以这样理解:

  1. ACK 分组是用来确认收到某个分组 B 的。假设分组 B 中 Seq=$n$ Ack=$m$。
    1. 如果分组 B 中 Len=0,那么 ACK 分组中 Seq=$m$ Ack=$n+1$
    2. 如果分组 B 中 Len 为 $i$,那么 ACK 分组中 Seq=$m$ Ack=$n+i$
  2. 如果准备发送的分组是基于发送端已经发送的 ACK 分组,那么 Seq 和 Ack 和已经发送的 ACK 分组保持一致。

HTTP 消息结束的标志

2021年4月21日 16:46

请求和响应

首先需要明确的一点是,无论是 HTTP 请求还是 HTTP 响应,都是由相同的 HTTP 消息构成1。服务端判断 HTTP 请求结束和客户端判断 HTTP 响应结束的依据是一样的。

HTTP 消息结构

首先让我们来了解一下 HTTP 消息的结构,从 RFC 2616 是这样描述2的:

Both types of message consist of a start-line, zero or more header fields (also known as “headers”), an empty line (i.e., a line with nothing preceding the CRLF) indicating the end of the header fields, and possibly a message-body.

让我们看看更直观的语法定义:

        generic-message = start-line
                          *(message-header CRLF)
                          CRLF
                          [ message-body ]
        start-line      = Request-Line | Status-Line

判断方法

接收方先将接收到的 HTTP 消息第一行作为起始行,在读到空行前的每一行作为消息头部。

一般情况

一般情况下,当读取完头部信息(读到两个连续的 CRLF3),需要解析头部中的 Content-Length 消息头,根据消息头确认是否有消息体,以及消息体长度。之后即可读取指定长度的数据。当读取完消息体,意味着消息结束。

Chunked

如果消息头中声明 Transfer-Encoding: chunked,那么意味着使用 chunked transfer encoding。我们先看一下 Chunked-Body 的定义:

       Chunked-Body   = *chunk
                        last-chunk
                        trailer
                        CRLF
       chunk          = chunk-size [ chunk-extension ] CRLF
                        chunk-data CRLF
       chunk-size     = 1*HEX
       last-chunk     = 1*("0") [ chunk-extension ] CRLF
       chunk-extension= *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
       chunk-ext-name = token
       chunk-ext-val  = token | quoted-string
       chunk-data     = chunk-size(OCTET)
       trailer        = *(entity-header CRLF)

可以看到 Chunked-Body 是 trailer 加上 CRLF3 结尾。所以接收方应该不断读取消息体,直到读到两个连续的 CRLF3,说明消息结束。


  1. RFC 2616 中是这样描述的:HTTP messages consist of requests from client to server and responses from server to client. ↩︎

  2. 引用自 HTTP/1.1: HTTP Message ↩︎

  3. CRLF 的符号化表示是 \r\n,十六进制编码表示为 0d0a。 ↩︎ ↩︎ ↩︎

TCP 滑动窗口

2021年4月21日 14:08

背景

TCP 滑动窗口协议是为了帮助发送端了解数据处理的带宽或者数据处理速度,避免拥塞,从而解决包乱序问题,提供可靠传输1

工作机制

整理概念

TCP 协议中,当接收方接收到发送方发送的数据时,会返回一个 ACK 确认信息。ACK 中包含了两个重要信息:

  • 序号(sequence number)

  • 窗口大小(window)

如果接收方已经接收了前 $n-1$ 个字节,ACK 中返回的序号为 $n$,代表希望接收到从第 $n$ 个字节开始的数据。发送方应该根据序号确认接收方已经接收到了前 $n-1$ 个字节,而不用重复发送。

发送 ACK 前,接收方根据自己的缓存区空间计算还可以接受多少字节数据,记录在 ACK 的 window 中,我们假设值为 $m$。发送方收到确认消息后,知道自己最多再发送 $m$ 个字节的数据。

但是虽然此时发送方收到了接收方确认的前 $n-1$ 个字节,但是在此之前发送方可能已经发送到了序号 $p$。这时候发送方为了避免发生拥塞,最多只能发送 $m - (p - n)$ 个字节的数据。2

TCP 头的格式

图片来源:TCP Header | Nmap Network Scanning

从发送方视角理解滑动窗口

图片来源:The TCP/IP Guide - TCP Sliding Window Acknowledgment System For Data Transport, Reliability and Flow Control

发送方发送完一部分数据后,不会等待接收方的确认消息,而是在继续发送其余的数据。所以在某个时间节点,对发送方而言存在 4 类数据:

  1. 已经确认接收成功的数据
  2. 已经发送但接收方还没有确认接收到的数据
  3. 还未发送的处于窗口范围内的数据
  4. 还未发送的处于窗口范围外的数据

上面的 2、3 部分构成了窗口数据,发送方根据接收方返回的 ACK 中的 wIndow 计算第 3 部分的数据量,并继续发送。

需要注意的是,这个窗口的位置并不是静止的,当发送方收到了新的 ACK 响应时,会根据 ACK 中的信息进行窗口滑动。

图片来源:The TCP/IP Guide - TCP Sliding Window Acknowledgment System For Data Transport, Reliability and Flow Control

延伸阅读

以上只是对 TCP 滑动窗口的粗略介绍,进一步理解可以参考:


  1. 参考酷壳中对滑动窗口的介绍。 ↩︎

  2. 参考知乎回答 ↩︎

网络时延相关知识梳理

2021年4月20日 11:07

背景

最近网络上有一则新闻引发了讨论:华为战略部总裁张文林:实验室内可做到2千公里0.1毫秒时延 满足车联网精确需要

主要原因是根据标题中的描述,这种通讯方式达到了「2千公里0.1毫秒时延」。这个描述引发了两种争论:

  • 这种通讯方式的传输速度为 $2\times10^6\div10^{-4}=2\times10^{10}(m/s)$,已经超过了光速,根本是不可能的。
  • 新闻中描述的时延指的不是传播速度。

也有报道1称原文中的描述应该是:0.1 毫秒的「抖动时延」。

借此机会,我们来复习一下网络中的时延概念。

概念

时延是一个链路层概念,指的是一个分组在链路中传输所需的时间。这个时间不是 $链路长度/光速$ 那么简单,还需要考虑其他因素。

我们通常所说的时延包括几个部分:

  • 处理时延
  • 排队时延
  • 传输时延
  • 传播时延

处理时延

处理时延是指一个节点检查分组请求头和决定将分组发送到何处(物理端口)所需的时间,包括分析首部,提取数据,差错检验,路由选择。

这个时延通常是微秒级的。

排队时延

分组在从节点进入链路前会进入一个传输队列,需要等待在它之前被分配的分组完成传输后再进行传输。

这个时延长度会根据队列中积压的分组数量决定,如果队列中没有积压的分组,那么排队时延为 0。

传输时延

由于分组是有体积的,而链路接收数据的速度是有限的。整个分组从网卡或者路由器发送到链路中所消耗的时间就是传输时延。

决定传输时延的因素是分组的大小和物理链路的传输速率(也就是带宽)。如果用 $L$ 比特表示分组的长度,用 $R$ bps 表示从路由器 A 到路由器 B 的链路传输速度。那么传输时延为 $L/R$。这个时延通常也是毫秒到微妙级别的。

传播时延

传播时延是指分组在两个节点之间,经由链路传播所需的时间。

传播时延由物理链路的长度和传输速度2决定。如果使用 $d$ 表示链路的长度(或者说节点间的距离),用 $s$ 表示信号在链路中的传输速度3(通常小于或略小于光速),那么传播时延为 $d/s$。


  1. 华为战略部总裁张文林:实验室内可做到2千公里0.1毫秒抖动时_7x24小时财经新闻_新浪网 ↩︎

  2. 链路中的传输速度取决于使用的物理介质。 ↩︎

  3. 这里可能会有疑问,链路传输速率 $R$ 和链路传播速度 $s$,是成正比的,只是单位不一样而已。 ↩︎

❌
❌