Blog

太超前好不好

Content #

超前不一定是坏事,有时候因为自己走在别人前面,就有了明显的竞争优势。但是太超前就不一定是好事了,因为这种公司不一定能够有时间等到概念成熟的时候,可能在这之前就破产了。

理念太超前到底好不好?这是一个非常不好回答的问题。我这里先来列举一些具体的例子吧。

2000 年前后,Sun 公司提出了现在大家耳熟能详的云计算的概念。Sun 公司觉得将来的计算资源就应该和水电一样,开起来就能用,用多少付多少钱。这个想法可能你今天听起来并不陌生,但是在 2000 年的时候,很多公司、很多人都在嘲笑 Sun。

当然,在那个时候,硬件的发展不足以支撑这个想法的实现。互联网在 2000 年的下载速度,连看个高清电影点播都是很困难的,又何谈如水、如电那样让人随便用,并按使用付费呢?

后来 Sun 公司当然是没有成为改变世界的云计算的大厂商。而 2006 年开始提供云计算服务的亚马逊成了全球最大的云计算厂商,2009 年开始大张旗鼓宣传云计算的阿里巴巴成了中国最大的云计算厂商。

再看另外一个例子:Salesforce。Salesforce 成立的 1999 年,创始人贝尼奥夫就觉得买软件和硬件的方式已经过时了,到了软件开发厂商消灭掉软件、改为提供服务的时候了。

这个想法是不是很超前?当然是的。因为大概是到了 2015 年以后大家才开始在普遍的层面上认可和接受了这个方向。即使这样,不得不说,现在依然还是软件和云计算并存的局面。一切都上云,所有的软件都基于软件服务,这恐怕还得等很久。

Salesforce 成立于 1999 年,和 Sun 公司不一样的是,它的创始人在提出一个超前的概念之后,不仅仅让这个公司存活了下来,而且随着人们对 SaaS 以及云计算的认可越来越深,公司逐渐成长起来,业绩越做越大。Salesforce 并没有因为概念超前而倒在沙滩上。因此,“概念超前,公司要死”这个说法也不完全是正确的。

那么 Sun 和 Salesforce 的区别在于什么地方呢?

我觉得有两个非常不同的区别。

第一个是,这个超前的概念到底是基于什么层面的超前。 具体一点来说,如果一个超前的概念是本质性的,需要延生到整个领域的方方面面的,比如操作系统的改变、硬件层面的改变。这种层面的改变要实施起来,无论财力、人力、物力以及投资的持续性都是非常高的,并非一般企业就可以做下来的。

但如果说这个超前的概念是针对某一个特定领域,并且这个领域又相对比较狭窄,领域内有足够多“愿意吃螃蟹的人”。那么这种概念的超前就要好很多。

第二个是,提出这个概念的人或者公司到底是以一种什么样的态度去对待这个概念。 简单来说,是踏踏实实一点一点地把事情做出来呢?还是“放一个大炮”,除了让大家觉得很牛或者很不切实际之外,就没有其他有价值的东西了呢?

Sun 公司并未在其提出的超前概念上投入足够多的研发,更不用提坚持下去了。而 Salesforce 则很不一样。Salesforce 选择了某一个特定的领域,深耕下去,把产品做出来,把市场打出去,吸引一部分用户来用。这样的话,即使作为一个非常超前的概念,但是在具体领域、具体问题上,这种超前的距离感已经被现实存在的产品拉近了。

在我看来,“只是说说”与“脚踏实地去一点一点地解决问题”有非常大的差别。很多时候,公司可以提出超前的概念,可却不一定愿意投入资源去付诸实施。但是从现实来讲,任何产业都是“读万卷书,行万里路”的。倘若一个公司不愿意做实际的事情的话,那么再多超前的概念也没意义。

而一个超前的概念到底能不能够帮助一个公司活下来,还有另外两个方面的考量。

第一个方面是,概念到底有多超前。

假设我们知道 100 年后人工智能会全面接管人类的日常生产生活,那么是不是今天我们提出“100 年以后人工智能会全面接管人类的日常生产生活”就是一个很好的超前概念呢?我觉得不是。因为这个概念太超前,没有任何的可执行性,对当下的实际没有任何改变。

如果我们今天就创建一个公司,把这个超前的概念付诸实施,最后的结果又会不会是技术还没有准备好,企业很快就倒塌了呢?我觉得这是必然的。因为现在发展的技术 100 年以后才能用,谁也不能保证有足够多的钱烧到技术可以盈利的那一天。

第二个方面是,一个企业能否运用这个超前的概念在一定的领域范围内具备赢利的能力。

这个之于不同公司,适用程度会不同。其中对创业公司尤其重要。大公司,比如亚马逊和阿里巴巴,都是长年累月地从其他行业里面赚钱来补贴云计算,这才有足够的钱让云计算持续烧下去,烧到赚钱。

而小企业不可能这样搞。这样搞的小企业,投资人早就要疯掉了。所以对于小企业来说,一个超前的概念,不但需要在特定的领域里面可以落实下去,而且还要在那个领域里面能够创造利润。从这个角度来讲,能赢的概率很低。

总结一下来说,太超前了肯定不是好事情。尤其是一些涉及基础性的、宽度和深度都有可能很大的变革性的观念,这种东西太超前了往往不一定能够成功。如果可以结合具体的行业,把超前的概念在一定范围内具体实现起来,并具备赢利能力,那么这种程度的超前,很有可能是可以给公司带来一片新天地的。

Viewpoint #

From #

141 | 太超前好不好

...

用select实现心跳机制

实现心跳机制 #

结合 time 包的 Ticker,我们可以实现带有心跳机制的 select。这种机制让我们可以在监听 channel 的同时,执行一些周期性的任务,比如下面这段代码:

func worker() {
  heartbeat := time.NewTicker(30 * time.Second)
  defer heartbeat.Stop()
  for {
    select {
    case <-c:
      // ... do some stuff
    case <- heartbeat.C:
      //... do heartbeat stuff
    }
  }
}

这里我们使用 time.NewTicker,创建了一个 Ticker 类型实例 heartbeat。这个实例包含一个 channel 类型的字段 C,这个字段会按一定时间间隔持续产生事件,就像“心跳”一样。这样 for 循环在 channel c 无数据接收时,会每隔特定时间完成一次迭代,然后回到 for 循环进行下一次迭代。

和 timer 一样,我们在使用完 ticker 之后,也不要忘记调用它的 Stop 方法,避免心跳事件在 ticker 的 channel(上面示例中的 heartbeat.C)中持续产生。

Viewpoint #

From #

33|并发:小channel中蕴含大智慧

用select实现超时机制

实现超时机制 #

带超时机制的 select,是 Go 中常见的一种 select 和 channel 的组合用法。通过超时事件,我们既可以避免长期陷入某种操作的等待中,也可以做一些异常处理工作。

比如,下面示例代码实现了一次具有 30s 超时的 select:

func worker() {
  select {
  case <-c:
       // ... do some stuff
  case <-time.After(30 *time.Second):
      return
  }
}

不过,在应用带有超时机制的 select 时,我们要特别注意 timer 使用后的释放,尤其在大量创建 timer 的时候。

Go 语言标准库提供的 timer 实际上是由 Go 运行时自行维护的,而不是操作系统级的定时器资源,它的使用代价要比操作系统级的低许多。但即便如此,作为 time.Timer 的使用者,我们也要尽量减少在使用 Timer 时给 Go 运行时和 Go 垃圾回收带来的压力,要及时调用 timer 的 Stop 方法回收 Timer 资源。

Viewpoint #

From #

33|并发:小channel中蕴含大智慧

用nil channel解决关闭的channel输出0的问题

Content #

func main() {
    ch1, ch2 := make(chan int), make(chan int)
    go func() {
        time.Sleep(time.Second * 5)
        ch1 <- 5
        close(ch1)
    }()

    go func() {
        time.Sleep(time.Second * 7)
        ch2 <- 7
        close(ch2)
    }()

    var ok1, ok2 bool
    for {
        select {
        case x := <-ch1:
            ok1 = true
            fmt.Println(x)
        case x := <-ch2:
            ok2 = true
            fmt.Println(x)
        }

        if ok1 && ok2 {
            break
        }
    }
    fmt.Println("program end")
}

我们期望程序在接收完 ch1 和 ch2 两个 channel 上的数据后就退出。但实际的运行情况却是这样的:

5
0
0
0
... ... //循环输出0
7
program end

这是怎么回事呢?我们简单分析一下这段代码的运行过程:

...

将判空与读取放在一个事务中

Content #

为了不阻塞在 channel 上,常见的方法是将“判空与读取”放在一个“事务”中,将“判满与写入”放在一个“事务”中,而这类“事务”我们可以通过 select 实现。我们来看下面示例:

func producer(c chan<- int) {
    var i int = 1
    for {
        time.Sleep(2 * time.Second)
        ok := trySend(c, i)
        if ok {
            fmt.Printf("[producer]: send [%d] to channel\n", i)
            i++
            continue
        }
        fmt.Printf("[producer]: try send [%d], but channel is full\n", i)
    }
}

func tryRecv(c <-chan int) (int, bool) {
    select {
    case i := <-c:
        return i, true

    default:
        return 0, false
    }
}

func trySend(c chan<- int, i int) bool {
    select {
    case c <- i:
        return true
    default:
        return false
    }
}

func consumer(c <-chan int) {
    for {
        i, ok := tryRecv(c)
        if !ok {
            fmt.Println("[consumer]: try to recv from channel, but the channel is empty")
            time.Sleep(1 * time.Second)
            continue
        }
        fmt.Printf("[consumer]: recv [%d] from channel\n", i)
        if i >= 3 {
            fmt.Println("[consumer]: exit")
            return
        }
    }
}

func main() {
    var wg sync.WaitGroup
    c := make(chan int, 3)
    wg.Add(2)
    go func() {
        producer(c)
        wg.Done()
    }()

    go func() {
        consumer(c)
        wg.Done()
    }()

    wg.Wait()
}

由于用到了 select 原语的 default 分支语义,当 channel 空的时候, tryRecv 不会阻塞;当 channel 满的时候,trySend 也不会阻塞。

...

为什么不能用len来判满或判空

Content #

针对 channel ch 的类型不同,len(ch) 有如下两种语义:

  1. 当 ch 为无缓冲 channel 时,len(ch) 总是返回 0;
  2. 当 ch 为带缓冲 channel 时,len(ch) 返回当前 channel ch 中尚未被读取的元素个数。

这样一来,针对带缓冲 channel 的 len 调用似乎才是有意义的。那我们是否可以使用 len 函数来实现带缓冲 channel 的“判满”、“判有”和“判空”逻辑呢?就像下面示例中伪代码这样:

var ch chan T = make(chan T, capacity)

// 判空
if len(ch) == 0 {
    // 此时channel ch空了?
}

// 判有
if len(ch) > 0 {
    // 此时channel ch中有数据?
}

// 判满
if len(ch) == cap(ch) {
    // 此时channel ch满了?
}

channel 原语用于多个 Goroutine 间的通信,一旦多个 Goroutine 共同对 channel 进行收发操作,len(channel) 就会在多个 Goroutine 间形成“竞态”。单纯地依靠 len(channel) 来判断 channel 中元素状态,是不能保证在后续对 channel 的收发时 channel 状态是不变的。

...

CPU的乱序执行

CPU的乱序执行 #

使用乱序执行技术后,CPU 里的流水线就和 5 级流水线不太一样了。我们一起来看一看下面这张图。

  1. 在取指令和指令译码的时候,乱序执行的 CPU 和其他使用流水线架构的 CPU 是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
  2. 在指令译码完成之后,就不一样了。CPU 不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站(Reservation Stations)的地方。顾名思义,这个保留站,就像一个火车站一样。发送到车站的指令,就像是一列列的火车。
  3. 这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
  4. 一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元(Function Unit,FU),其实就是 ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样,有些从上海北上,可以到北京和哈尔滨;有些是南下的,可以到广州和深圳。
  5. 指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方。
  6. 在重排序缓冲区里,我们的 CPU 会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果。
  7. 实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer 面,最终才会写入到高速缓存和内存里。

可以看到,在乱序执行的情况下,只有 CPU 内部指令的执行层面,可能是“乱序”的。只要我们能在指令的译码阶段正确地分析出指令之间的数据依赖关系,这个“乱序”就只会在互相没有影响的指令之间发生。

即便指令的执行过程中是乱序的,我们在最终指令的计算结果写入到寄存器和内存之前,依然会进行一次排序,以确保所有指令在外部看来仍然是有序完成的。

整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。

Viewpoint #

From #

24 | 冒险和预测(三):CPU里的“线程池”

CPU Store Buffer

GMP模型示意图

Content #

在 Go 1.1 版本中实现了 G-P-M 调度模型和work stealing 算法,这个模型一直沿用至今。模型如下图所示:

有人说过:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”,德米特里 - 维尤科夫的 G-P-M 模型恰是这一理论的践行者。你可以看到,德米特里 - 维尤科夫通过向 G-M 模型中增加了一个 P,让 Go 调度器具有很好的伸缩性。

P 是一个“逻辑 Proccessor”,每个 G(Goroutine)要想真正运行起来,首先需要被分配一个 P,也就是进入到 P 的本地运行队列(local runq)中。对于 G 来说,P 就是运行它的“CPU”,可以说:在 G 的眼里只有 P。但从 Go 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。

Viewpoint #

From #

32|并发:聊聊Goroutine调度器的原理

next与step的区别

Content #

区别在于next不会进入子函数,而step会进入子函数。

//file: main.c
#include "squareit.h"
#include <stdio.h>
int main(void) {
    int x = 5;
    printf("%d squared is %d\n", x, squareit(x));
}

//file: squareit.h
#ifndef SQUAREIT_H
#define SQUAREIT_H
// return the square of an integer
int squareit(int x);
#endif // SQUAREIT_H

//file: squareit.c
#include "squareit.h"
int squareit(int x) {
    return x*x;
}

假设gdb调试时,当前行为printf所在的行。这时如果执行step,则会进入 squareit函数。如果执行next,则squareit和printf都执行完成,当前位置为 main函数的最后一行。

Viewpoint #

From #

性能提示

性能提示 #

Go 语言是一个高性能的语言,但并不是说这样我们就不用关心性能了,我们还是需要关心的。下面我给你提供一份在编程方面和性能相关的提示。

  1. 如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。
  2. 尽可能避免把String转成[]Byte ,这个转换会导致性能下降。
  3. 如果在 for-loop 里对某个 Slice 使用 append(),请先把 Slice 的容量扩充到位,这样可以避免内存重新分配以及系统自动按 2 的 N 次方幂进行扩展但又用不到的情况,从而避免浪费内存。
  4. 使用StringBuffer 或是StringBuild 来拼接字符串,性能会比使用 + 或 +=高三到四个数量级。
  5. 尽可能使用并发的 goroutine,然后使用 sync.WaitGroup 来同步分片操作。
  6. 避免在热代码中进行内存分配,这样会导致 gc 很忙。尽可能使用 sync.Pool 来重用对象。
  7. 使用 lock-free 的操作,避免使用 mutex,尽可能使用 sync/Atomic包(关于无锁编程的相关话题,可参看《无锁队列实现》或《无锁 Hashmap 实现》)。
  8. 使用 I/O 缓冲,I/O 是个非常非常慢的操作,使用 bufio.NewWrite() 和 bufio.NewReader() 可以带来更高的性能。
  9. 对于在 for-loop 里的固定的正则表达式,一定要使用 regexp.Compile() 编译正则表达式。性能会提升两个数量级。
  10. 如果你需要更高性能的协议,就要考虑使用 protobuf 或 msgp 而不是 JSON,因为 JSON 的序列化和反序列化里使用了反射。
  11. 你在使用 Map 的时候,使用整型的 key 会比字符串的要快,因为整型比较比字符串比较要快。

Viewpoint #

From #

107 | Go编程模式:切片、接口、时间和性能

...