go语言多核并行化

Go语言具有支持高并发的特性,可以很方便地实现多线程运算,充分利用多核心 cpu 的性能。

众所周知服务器的处理器大都是单核频率较低而核心数较多,对于支持高并发的程序语言,可以充分利用服务器的多核优势,从而降低单核压力,减少性能浪费。

  • go语言实现多核多线程并发运行是非常方便的,下面举个例子:
var wg sync.WaitGroup
func main() {
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go AsyncFun(i)
	}
	wg.Wait()
}
func AsyncFun(index int) {
	defer wg.Done()
	sum := 0
	for i := 0; i < 10000; i++ {
		sum++
	}
	fmt.Printf("线程:%d, 结果:%d\n", index, sum)
}

运行结果:

线程:4, 结果:10000
线程:1, 结果:10000
线程:3, 结果:10000
线程:0, 结果:10000
线程:2, 结果:10000

在执行一些昂贵的计算任务时,我们希望能够尽量利用现代服务器普遍具备的多核特性来尽量将任务并行化,从而达到降低总计算时间的目的。此时我们需要了解 CPU 核心的数量,并针对性地分解计算任务到多个 goroutine 中去并行运行。

下面我们来模拟一个完全可以并行的计算任务:计算 N 个整型数的总和。我们可以将所有整型数分成 M 份,M 即 CPU 的个数。让每个 CPU 开始计算分给它的那份计算任务,最后将每个 CPU 的计算结果再做一次累加,这样就可以得到所有 N 个整型数的总和:

var wg sync.WaitGroup
func main() {
	s1 := []int{11, 22, 33, 44, 55, 66, 77, 88, 99, 100}
	// 计算cpu核心数量
	numCpu := runtime.NumCPU()
	// 设置最大并行数量为CPU核心数
	runtime.GOMAXPROCS(numCpu)
	ch := make(chan int, numCpu)

	tmp := len(s1) / numCpu
	start := 0
	for i := 0; i < numCpu; i++ {
		var s []int
		if i < numCpu -1 {
			s = s1[start:start+tmp]
			start += tmp
		}
		if i == numCpu - 1 {
			s = s1[start:]
		}
		wg.Add(1)
		go calculate(s, ch, i)
	}

	wg.Wait()
	ret := 0
	for i := 0; i < numCpu; i++ {
		ret += <-ch
	}
	fmt.Println(ret)
}
func calculate(s []int, ch chan int, ii int) {
	defer wg.Done()
	l := len(s)
	ret := 0
	for i := 0; i < l; i++ {
		ret += s[i]
	}
	ch <- ret
	fmt.Printf("goroutine %d 计算完了,结果是:%d\n", ii, ret)
}

互斥锁和读写互斥锁

go语言中的sync包提供了两种锁类型:sync.Mutex和sync.RWMutex
Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。

RWMutex 相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁(调用 RLock() 方法;而写锁(调用 Lock() 方法)会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占。从 RWMutex 的实现看,RWMutex 类型其实组合了 Mutex:

type RWMutex struct {
    w Mutex
    writerSem uint32
    readerSem uint32
    readerCount int32
    readerWait int32
}

对于这两种锁类型,任何一个Lock()或者RLock()均需保证对应有Unlock()或者RUnlock()调用与之对应,否则可能导致等待该锁的所有goroutine处于饥饿状态,甚至导致死锁,锁的典型应用场景:

var (
	count int
	countGuard sync.Mutex
)

func main() {
	// 可以进行并发安全的设置
	setCount(100)
	// 可以进行并发安全的获取
	fmt.Println(getCount())
}
func getCount() int {
	countGuard.Lock()
	defer countGuard.Unlock()
	return count
}
func setCount(c int) {
	countGuard.Lock()
	count = c
	countGuard.Unlock()
}

在读多写少的环境中,可以优先使用读写互斥锁(sync.RWMutex),它比互斥锁更高效,sync包中的RWMutex提供了读写互斥锁的封装,
读写互斥锁案例:

var (
	count int
	countGuard sync.RWMutex
)
func getCount() int {
	countGuard.RLock()
	defer countGuard.RUnlock()
	return count
}

go语言等待组

go语言中除了使用通道channel和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务。
在 sync.WaitGroup(等待组)类型中,每个 sync.WaitGroup 值在内部维护着一个计数,此计数的初始默认值为零。

等待组有下面几个方法可用,如下表所示。

等待组的方法

方法名

功能

(wg * WaitGroup) Add(delta int)

等待组的计数器 +1

(wg * WaitGroup) Done()

等待组的计数器 -1

(wg * WaitGroup) Wait()

当等待组计数器不等于 0 时阻塞直到变 0。

对于一个可寻址的 sync.WaitGroup 值 wg:

  • 我们可以使用方法调用 wg.Add(delta) 来改变值 wg 维护的计数。
  • 方法调用 wg.Done() 和 wg.Add(-1) 是完全等价的。
  • 如果一个 wg.Add(delta) 或者 wg.Done() 调用将 wg 维护的计数更改成一个负数,一个恐慌将产生。
  • 当一个协程调用了 wg.Wait() 时,
  • 如果此时 wg 维护的计数为零,则此 wg.Wait() 此操作为一个空操作(noop);
  • 否则(计数为一个正整数),此协程将进入阻塞状态。当以后其它某个协程将此计数更改至 0 时(一般通过调用 wg.Done()),此协程将重新进入运行状态(即 wg.Wait() 将返回)。

等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当我们添加了 N 个并发任务进行工作时,就将等待组的计数器值增加 N。每个任务完成时,这个值减 1。同时,在另外一个 goroutine 中等待这个等待组的计数器值为 0 时,表示所有任务已经完成。

func main() {
	var wg sync.WaitGroup
	urls := []string{
		"https://www.github.com",
		"https://www.qiqiu.com",
		"https://www.golangtc.com",
	}
	for _, url := range urls {
		wg.Add(1)
		go func(url string) {
			defer wg.Done()
			_, err := http.Get(url)
			fmt.Println(url, err)
		}(url)
	}
	wg.Wait()
	fmt.Println("over")
}

死锁、活锁和饥饿概念

  1. 死锁
    死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

死锁发生的条件有如下几种:

  1. 互斥条件
    线程对资源的访问是排他性的,如果一个线程对占用了某资源,那么其他线程必须处于等待状态,直到该资源被释放。
  2. 请求和保持条件
    线程 T1 至少已经保持了一个资源 R1 占用,但又提出使用另一个资源 R2 请求,而此时,资源 R2 被其他线程 T2 占用,于是该线程 T1 也必须等待,但又对自己保持的资源 R1 不释放。
  3. 不剥夺条件
    线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。
  4. 环路等待条件
    在死锁发生时,必然存在一个“进程 - 资源环形链”,即:{p0,p1,p2,...pn},进程 p0(或线程)等待 p1 占用的资源,p1 等待 p2 占用的资源,pn 等待 p0 占用的资源。

最直观的理解是,p0 等待 p1 占用的资源,而 p1 而在等待 p0 占用的资源,于是两个进程就相互等待。

死锁解决办法:

  • 如果并发查询多个表,约定访问顺序;
  • 在同一个事务中,尽可能做到一次锁定获取所需要的资源;
  • 对于容易产生死锁的业务场景,尝试升级锁颗粒度,使用表级锁;
  • 采用分布式事务锁或者使用乐观锁。

死锁程序是所有并发进程彼此等待的程序,在这种情况下,如果没有外界的干预,这个程序将永远无法恢复。

  1. 活锁
    活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复同样的操作,而且总会失败。

例如线程 1 可以使用资源,但它很礼貌,让其他线程先使用资源,线程 2 也可以使用资源,但它同样很绅士,也让其他线程先使用资源。就这样你让我,我让你,最后两个线程都无法使用资源。

活锁通常发生在处理事务消息中,如果不能成功处理某个消息,那么消息处理机制将回滚事务,并将它重新放到队列的开头。这样,错误的事务被一直回滚重复执行,这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误认为是可修复的错误。

当多个相互协作的线程都对彼此进行相应而修改自己的状态,并使得任何一个线程都无法继续执行时,就导致了活锁。这就像两个过于礼貌的人在路上相遇,他们彼此让路,然后在另一条路上相遇,然后他们就一直这样避让下去。

要解决这种活锁问题,需要在重试机制中引入随机性。例如在网络上发送数据包,如果检测到冲突,都要停止并在一段时间后重发,如果都在 1 秒后重发,还是会冲突,所以引入随机性可以解决该类问题。

  1. 饥饿
    饥饿是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况。

与死锁不同的是,饥饿锁在一段时间内,优先级低的线程最终还是会执行的,比如高优先级的线程执行完之后释放了资源。

活锁与饥饿是无关的,因为在活锁中,所有并发进程都是相同的,并且没有完成工作。更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个并发进程,以尽可能有效地完成工作,或者阻止全部并发进程。

  1. 总结
    不适用锁肯定会出问题。如果用了,虽然解了前面的问题,但是又出现了更多的新问题。

死锁:是因为错误的使用了锁,导致异常;
活锁:是饥饿的一种特殊情况,逻辑上感觉对,程序也一直在正常的跑,但就是效率低,逻辑上进行不下去;
饥饿:与锁使用的粒度有关,通过计数取样,可以判断进程的工作效率。
只要有共享资源的访问,必定要使其逻辑上进行顺序化和原子化,确保访问一致,这绕不开锁这个概念。

go语言CSP,通信顺序进程简述

Go实现了两种并发形式,第一种是大家普遍认知的多线程共享内存,其实就是 Java 或 C++ 等语言中的多线程开发;另外一种是Go语言特有的,也是Go语言推荐的 CSP(communicating sequential processes)并发模型。

CSP并发模型是上个世纪70年代提出来的,用于描述两个独立的并发实体通过共享channel(管道)进行通信的并发模型。

Go语言就是借用 CSP 并发模型的一些概念为之实现并发的,但是Go语言并没有完全实现了 CSP 并发模型的所有理论,仅仅是实现了process和channel这两个概念。

process就是go语言中的goroutine,每个goroutine之间通过channel通信来实现数据的共享。

这里我们要明确的是“并发不是并行”。并发更关注的是程序的设计层面,并发的程序是可以完全顺序执行的,只有在真正的多核 CPU 上才可能真正地同时运行;并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如 GPU 中对图像处理都会有大量的并行运算。

为了更好地编写并发程序,从设计之初Go语言就注重如何在编程语言层级上设计一个简洁安全高效的抽象模型,让开发人员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些烦琐的操作分散精力。

在并发编程中,对共享资源的正确访问需要精确的控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而go语言另辟蹊径,它将共享的值通过通道传递(实际上多个独立执行的线程很少主动共享资源)。

并发编程的核心概念是同步通信,但是同步的方式却有多种,先以大家熟悉的互斥量实现同步通信:

func main() {
	var mu sync.Mutex
	mu.Lock()
	go func() {
		fmt.Println("go语言中文社区")
		mu.Unlock()
	}()
	mu.Lock()
}

使用sync.Mutex互斥锁同步是比较低级的做法,我们改用无缓冲通道实现同步,推荐方法2

# 方法1
func main() {
	ch := make(chan struct{})
	go func() {
		fmt.Println("go语言中文社区")
		<-ch
	}()
	ch <- struct{}{}
}

# 方法2
func main() {
	ch := make(chan struct{})
	go func() {
		fmt.Println("go语言中文社区")
		ch <- struct{}{}
	}()
	<-ch
}

基于带缓冲通道,我们可以很容易把打印协程扩展到N个,下面开启10个后台协程分别打印

func main() {
	ch := make(chan struct{}, 10)
        // 开启n个后台协程
	for i := 0; i < cap(ch); i++ {
		go func(i int) {
			fmt.Println("go语言中文社区-", i)
			ch <- struct{}{}
		}(i)
	}

        // 等待n个后台协程完成打印
	for i := 0; i < cap(ch); i++ {
		<-ch
	}
}

对于这种要等待 N 个线程完成后再进行下一步的同步操作有一个简单的做法,就是使用 sync.WaitGroup 来等待一组事件:

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			fmt.Println("go语言中文社区-", i)
		}(i)
	}

	wg.Wait()
}

其中 wg.Add(1) 用于增加等待事件的个数,必须确保在后台线程启动之前执行(如果放到后台线程之中执行则不能保证被正常执行到)。当后台线程完成打印工作之后,调用 wg.Done() 表示完成一个事件,main() 函数的 wg.Wait() 是等待全部的事件完成。

聊天服务器

server.go

func main() {
	listener, err := net.Listen("tcp", "127.0.0.1:8000")
	if err != nil {
		log.Fatalln(err)
	}
	go broadcaster()
	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Println(err)
			continue
		}
		go handleConn(conn)
	}
}
type client chan<- string
var (
	entering = make(chan client)
	leaving = make(chan client)
	messages = make(chan string)
)
func broadcaster() {
	clients := make(map[client]bool)
	for {
		select {
		case msg := <-messages:
			for cli := range clients {
				cli <- msg
			}
		case cli := <-entering:
			clients[cli] = true
		case cli := <-leaving:
			delete(clients, cli)
			close(cli)
		}
	}
}
func handleConn(conn net.Conn) {
	ch := make(chan string)  // 对外发送客户消息的通道
	go clientWriter(conn, ch)
	who := conn.RemoteAddr().String()
	ch <- "欢迎" + who
	messages <- who + "上线了"
	entering <- ch
	input := bufio.NewScanner(conn)
	for input.Scan() {
		messages <- who + ":" + input.Text()
	}
	leaving <- ch
	messages <- who + "下线了"
	conn.Close()
}
func clientWriter(conn net.Conn, ch <-chan string) {
	for msg := range ch {
		fmt.Fprintln(conn, msg)
	}
}

client.go

func main() {
	conn, err := net.Dial("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	done := make(chan struct{})
	go func() {
		io.Copy(os.Stdout, conn) // 注意:忽略错误
		log.Println("done")
		done <- struct{}{} // 向主Goroutine发出信号
	}()
	mustCopy(conn, os.Stdin)
	conn.Close()
	<-done // 等待后台goroutine完成
}
func mustCopy(dst io.Writer, src io.Reader) {
	if _, err := io.Copy(dst, src); err != nil {
		log.Fatal(err)
	}
}