Blog

iota

iota #

iota 是 Go 语言的一个预定义标识符,它表示的是 const 声明块(包括单行声明)中,每个常量所处位置在块中的偏移值(从零开始)。同时,每一行中的 iota 自身也是一个无类型常量,可以像前面我们提到的无类型常量那样,自动参与到不同类型的求值过程中来,不需要我们再对它进行显式转型操作。

你可以看看下面这个 Go 标准库中 sync/mutex.go 中的一段基于 iota 的枚举常量的定义:

// $GOROOT/src/sync/mutex.go
const (
    mutexLocked = 1 << iota
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
    starvationThresholdNs = 1e6
)

首先,这个 const 声明块的第一行是 mutexLocked = 1 << iota ,iota 的值是这行在 const 块中的偏移,因此 iota 的值为 0,我们得到 mutexLocked 这个常量的值为 1 << 0,也就是 1。

第二行:mutexWorken 。因为这个 const 声明块中并没有显式的常量初始化表达式,所以我们根据 const 声明块里“隐式重复前一个非空表达式”的机制,这一行就等价于 mutexWorken = 1 << iota。而且,又因为这一行是 const 块中的第二行,所以它的偏移量 iota 的值为 1,我们得到 mutexWorken 这个常量的值为 1 << 1,也就是 2。

...

隐式重复前一个非空表达式

隐式重复前一个非空表达式 #

Go 的 const 语法提供了“隐式重复前一个非空表达式”的机制,比如下面代码:

const (
    Apple, Banana = 11, 22
    Strawberry, Grape
    Pear, Watermelon
)

这个代码里,常量定义的后两行并没有被显式地赋予初始值,所以 Go 编译器就为它们自动使用上一行的表达式,也就获得了下面这个等价的代码:

const (
    Apple, Banana = 11, 22
    Strawberry, Grape  = 11, 22 // 使用上一行的初始化表达式
    Pear, Watermelon  = 11, 22 // 使用上一行的初始化表达式
)

Viewpoint #

From #

14|常量:Go在“常量”设计上的创新有哪些?

无类型常量与隐式转型

无类型常量 #

Go 语言对类型安全是有严格要求的:即便两个类型拥有着相同的底层类型,但它们仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算。这一要求不仅仅适用于变量,也同样适用于有类型常量(Typed Constant)中,你可以在下面代码中看出这一点:

type myInt int
const n myInt = 13
// 编译器报错:cannot use n + 5 (type myInt) as type int in const initializer
const m int = n + 5
func main() {
    var a int = 5
    // 编译器报错:invalid operation: a + n (mismatched types int and myInt)
    fmt.Println(a + n)
}

而且,有类型常量与变量混合在一起进行运算求值的时候,也必须遵守类型相同这一要求,否则我们只能通过显式转型才能让上面代码正常工作,比如下面代码中,我们就必须通过将常量 n 显式转型为 int 后才能参与后续运算:

type myInt int
const n myInt = 13
const m int = int(n) + 5  // OK
func main() {
    var a int = 5
    fmt.Println(a + int(n))  // 输出:18
}

我们也可以使用 Go 中的无类型常量来实现,你可以看看这段代码:

type myInt int
const n = 13
func main() {
    var a myInt = 5
    fmt.Println(a + n)  // 输出:18
}

你可以看到,在这个代码中,常量 n 在声明时并没有显式地被赋予类型,在 Go 中,这样的常量就被称为无类型常量(Untyped Constant)。

...

引用计数算法的优缺点

引用计数算法的优缺点 #

从算法描述中容易推知,引用计数具备以下优点:

  1. 可以立即回收垃圾。因为每个对象在被引用次数为 0 的时候,是立即就可以知道的,所以一旦一个对象成为垃圾,它将立即被释放;
  2. 没有暂停时间。对象的回收根本不需要另外的 GC 线程专门去做,业务线程自己就搞定了,所以引用计数算法不需要停顿时间。

同时,引用计数也存在以下缺点:

  1. 在每次赋值操作的时候都要做额外的计算。在多线程的情况下,为了正确地维护引用计数,需要同步和互斥操作,这往往需要通过锁来实现,这会对多线程程序性能带来比较大的损失;
  2. 会有链式回收的情况。比如多个对象对链表形式串在一起,它们的引用计数都为 1,当链表头被回收时,整个链表都会回收,这可能会导致一次回收所使用的时间过长;
  3. 循环引用。如果 objA 引用了 objB,objB 也引用了 objA,但是除此之外,再没有其他的地方引用这两个对象了,这两个对象的引用计数就都是 1。这种情况下,这两个对象是不能被回收的。如果说上面两条缺陷还可以克服的话,那么循环引用就是比较致命的。

在使用引用计数算法进行内存管理的语言中,比如 Python 和 Swift,都会存在循环引用的问题。Python 在引用计数之外,另外引入了三色标记算法,保证了在出现循环引用的情况下,垃圾对象也能被正常回收。

Viewpoint #

From #

19 | 垃圾回收:如何避免内存泄露?

常用的评价GC算法的标准

常用的评价GC算法的标准 #

  1. 分配的效率主要考察在创建对象时,申请空闲内存的效率;
  2. 回收的效率它是指回收垃圾时的效率;
  3. 是否产生内存碎片碎片是指活跃对象之间存在空闲内存,但这一部分内存又不能被有效利用。比如内存里有两块不连续的 16 字节空闲空间,此时分配器要申请一块 32 字节的空间,虽然总的空闲空间也是 32 字节,但由于它们不连续,不能满足分配器的这次申请。这就是碎片空间;
  4. 空间利用率这里主要是衡量堆空间是否能被有效利用。比如基于复制的算法无论何时都会保持一部分内存是空闲的,那么它的空间利用率就无法达到 100%,这是由算法本身决定的;
  5. 是否停顿 Collector 在整理内存的时候会存在搬移对象的情况,因为修改指针是一种非常敏感的操作,有时候它会要求 Mutator 停止工作。是否需要 Mutator 停顿,以及停顿时长是多少,是否会影响业务的正常响应等。停顿时长在某些情况下是一个关键性指标;
  6. 实现的复杂度有些算法虽然看上去很美妙,但因为其实现起来太复杂,代码难以维护,所以无法真正地商用落地。这也会影响到 GC 算法的选择。

Viewpoint #

From #

19 | 垃圾回收:如何避免内存泄露?

Go字符串类型的内部表示

Go字符串类型的内部表示 #

Go 字符串类型的内部表示究竟是什么样的呢?在标准库的 reflect 包中,我们找到了答案,你可以看看下面代码:

// $GOROOT/src/reflect/value.go
// StringHeader是一个string的运行时表示
type StringHeader struct {
    Data uintptr
    Len  int
}

我们可以看到,string 类型其实是一个“描述符”,它本身并不真正存储字符串数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成的。我也画了一张图,直观地展示了一个 string 类型变量在 Go 内存中的存储:

你看,Go 编译器把源码中的 string 类型映射为运行时的一个二元组(Data, Len),真实的字符串值数据就存储在一个被 Data 指向的底层数组中。通过 Data 字段,我们可以得到这个数组的内容,你可以看看下面这段代码:

func dumpBytesArray(arr []byte) {
    fmt.Printf("[")
    for _, b := range arr {
        fmt.Printf("%c ", b)
    }
    fmt.Printf("]\n")
}

func main() {
    var s = "hello"
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s)) // 将string类型变量地址显式转型为reflect.StringHeader
    fmt.Printf("0x%x\n", hdr.Data) // 0x10a30e0
    p := (*[5]byte)(unsafe.Pointer(hdr.Data)) // 获取Data字段所指向的数组的指针
    dumpBytesArray((*p)[:]) // [h e l l o ]   // 输出底层数组的内容
}

这段代码利用了 unsafe.Pointer 的通用指针转型能力,按照 StringHeader 给出的结构内存布局,“顺藤摸瓜”,一步步找到了底层数组的地址,并输出了底层数组内容。

...

字节视角与字符视角

字节视角与字符视角 #

Go 语言在看待 Go 字符串组成这个问题上,有两种视角。一种是字节视角,也就是和所有其它支持字符串的主流语言一样,Go 语言中的字符串值也是一个可空的字节序列,字节序列中的字节个数称为该字符串的长度。一个个的字节只是孤立数据,不表意。

比如在下面代码中,我们输出了字符串中的每个字节,以及整个字符串的长度:

var s = "中国人"
fmt.Printf("the length of s = %d\n", len(s)) // 9

for i := 0; i < len(s); i++ {
  fmt.Printf("0x%x ", s[i]) // 0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
}
fmt.Printf("\n")

我们看到,由“中国人”构成的字符串的字节序列长度为 9。并且,仅从某一个输出的字节来看,它是不能与字符串中的任一个字符对应起来的。

如果要表意,我们就需要从字符串的另外一个视角来看,也就是字符串是由一个可空的字符序列构成。这个时候我们再看下面代码:

var s = "中国人"
fmt.Println("the character count in s is", utf8.RuneCountInString(s)) // 3

for _, c := range s {
  fmt.Printf("0x%x ", c) // 0x4e2d 0x56fd 0x4eba
}
fmt.Printf("\n")

在这段代码中,我们输出了字符串中的字符数量,也输出了这个字符串中的每个字符。前面说过,Go 采用的是 Unicode 字符集,每个字符都是一个 Unicode 字符,那么这里输出的 0x4e2d、0x56fd 和 0x4eba 就应该是某种 Unicode 字符的表示了。没错,以 0x4e2d 为例,它是汉字“中”在 Unicode 字符集表中的码点(Code Point)。

...

八条荒谬的分布式假设

八条荒谬的分布式假设 #

“8 条荒谬的分布式假设(Fallacies of Distributed Computing)”

  1. 网络是稳定的。
  2. 网络传输的延迟是零。
  3. 网络的带宽是无穷大。
  4. 网络是安全的。
  5. 网络的拓扑不会改变。
  6. 只有一个系统管理员。
  7. 传输数据的成本为零。
  8. 整个网络是同构的。

阿尔农·罗特姆 - 盖尔 - 奥兹(Arnon Rotem-Gal-Oz)写了一篇长文 Fallacies of Distributed Computing Explained 来解释为什么这些观点是错误的。另外,加勒思·威尔逊(Gareth Wilson)的文章 则用日常生活中的例子,对这些点做了通俗的解释。为什么我们深刻地认识到这 8 个错误?是因为,这要我们清楚地认识到——在分布式系统中错误是不可能避免的,我们在分布式系统中,能做的不是避免错误,而是要把错误的处理当成功能写在代码中。

Viewpoint #

From #

https://shimo.im/docs/gYpKDyQv6CXGgHTr/read

全加器

全加器 #

我们用两个半加器和一个或门,就能组合成一个全加器。第一个半加器,我们用和个位的加法一样的方式,得到是否进位 X 和对应的二个数加和后的结果 Y,这样两个输出。然后,我们把这个加和后的结果 Y,和个位数相加后输出的进位信息 U,再连接到一个半加器上,就会再拿到一个是否进位的信号 V 和对应的加和后的结果 W。 全加器就是两个半加器加上一个或门

这个 W 就是我们在二位上留下的结果。我们把两个半加器的进位输出,作为一个或门的输入连接起来,只要两次加法中任何一次需要进位,那么在二位上,我们就会向左侧的四位进一位。因为一共只有三个 bit 相加,即使 3 个 bit 都是 1,也最多会进一位。

这样,通过两个半加器和一个或门,我们就得到了一个,能够接受进位信号、加数和被加数,这样三个数组成的加法。这就是我们需要的全加器。

有了全加器,我们要进行对应的两个 8 bit 数的加法就很容易了。我们只要把 8 个全加器串联起来就好了。个位的全加器的进位信号作为二位全加器的输入信号,二位全加器的进位信号再作为四位的全加器的进位信号。这样一层层串接八层,我们就得到了一个支持 8 位数加法的算术单元。如果要扩展到 16 位、32 位,乃至 64 位,都只需要多串联几个输入位和全加器就好了。 8 位加法器可以由 8 个全加器串联而成

唯一需要注意的是,对于这个全加器,在个位,我们只需要用一个半加器,或者让全加器的进位输入始终是 0。因为个位没有来自更右侧的进位。而最左侧的一位输出的进位信号,表示的并不是再进一位,而是表示我们的加法是否溢出了。

这也是很有意思的一点。以前我自己在了解二进制加法的时候,一直有这么个疑问,既然 int 这样的 16 位的整数加法,结果也是 16 位数,那我们怎么知道加法最终是否溢出了呢?因为结果也只存得下加法结果的 16 位数。我们并没有留下一个第 17 位,来记录这个加法的结果是否溢出。

看到全加器的电路设计,相信你应该明白,在整个加法器的结果中,我们其实有一个电路的信号,会标识出加法的结果是否溢出。我们可以把这个对应的信号,输出给到硬件中其他标志位里,让我们的计算机知道计算的结果是否溢出。而现代计算机也正是这样做的。这就是为什么你在撰写程序的时候,能够知道你的计算结果是否溢出在硬件层面得到的支持。

Viewpoint #

From #