Go 语言中的通道和多线程

我搭建了个人博客主页, 欢迎访问: http://blog.joelzho.com/

零. 说

Go 的多线程和通道我感觉还是比较好玩的, 特别是 Channel.
Channel 真是多线程通讯的利器, 就像 C 中多进程通讯的 pipe 一样.

我这里以网易2015 的一道多线程面试题为例子, 用 GoLang 来实现.

其中涉及到的知识有:

  1. 结构和接口
  2. 文件的读写
  3. 多线程(goroutine)
  4. 互斥锁
  5. Channel

一. 网易2015多线程面试题

1.1 面试题:

一个文件中有10000个数, 用Java实现一个多线程程序将这个10000个数输出到5个不用文件中(不要求输出到每个文件中的数量相同).
需求:

  1. 要求启动10个线程, 两两一组, 分为5组.
  2. 每组两个线程分别将文件中的奇数和偶数输出到该组对应的一个文件中, 需要偶数线程每打印10个偶数以后, 就将奇数线程打印10个奇数, 如此交替进行.
  3. 同时需要记录输出进度, 每完成1000个数就在控制台中打印当前完成数量, 并在所有线程结束后, 在控制台打印"Done".

1.2 分析

  1. 有一个文件, 所以要读文件
  2. “输出到5个不用文件中”, 需要写文件
  3. “要求启动10个线程”, 需要用到多线程
  4. “如此交替进行”, 需要线程之间进行通信.
  5. “并在所有线程结束后”, 需要等待支线程.

三. Go 读文件和写文件

3.1 读文件

从面试题中可以出, 我们首先需要将一个有10000个数的文件读到内存中来.
下面列出了 Go 语言中最简单的读文件方法

// 读取文件
// 读取成功返回字节数组
// 读取失败返回nil
func ReadBytesFromFile(fileName string) []byte  {
	// 打开文件
	fs, err := os.Open(fileName) //os 是系统包
	if err != nil {
		fmt.Println("无法读取输入文件.")
		return nil
	}
	// 读取文件的所有数据到内存中
	bytes, err := ioutil.ReadAll(fs) // ioutil 是系统包
	if err != nil {
		fmt.Println("输入文件读取出错.")
	}
	// 关闭文件对象, 忽略关闭错误
	err = fs.Close()
	return bytes
}

3.2 写文件

现在, 我们假设某个线程已经收集到了 10 个偶数或者是奇数, 现在将它写入到文件中

func WriteNumber2File(outputFile string, numbers []int)  {
	//将int数组拼接, 成字符串, 以换行符分隔
	var strBuf string
	for n := range numbers {
		strBuf += fmt.Sprintf("%d\n", n)
	}
	// 字符串转字节数组
	bytes := []byte(strBuf)
	//输出到文件
	err := ioutil.WriteFile(outputFile, bytes, os.ModeAppend)
	if err != nil {
		fmt.Printf("不能写入文件: %s\n", err)
	}
}

四. 结构与接口

4.1 结构的定义

既然要每组线程中要分别拿到奇数10个和偶数10个,且最多5个线程一起拿某一类数.
所以有了以下数据结构(或者说是类):

type NumberContainer struct {
	oddNumbers []int //奇数数组
	evenNumbers []int //偶数数组

	oddNumbersIndex int //奇数数组当前下标
	evenNumbersIndex int //偶数数组当前下标

	oddNumbersLock sync.Mutex //奇数数组锁
	evenNumbersLock sync.Mutex //偶数数组锁
}

4.2 接口的定义.

此处提供两个线程安全的方法给来获取数字的线程调用,
接口定义如下:

type NumberContainerMethods interface {
	GetOddNumbers(count int) []int //获取 count 个奇数
	GetEvenNumbers(count int) []int //获取 count 个偶数
}

4.3 实现接口, 并使用互斥锁

func (numberContainer *NumberContainer) GetOddNumbers(count int) []int  {

	var result []int = nil

	numberContainer.oddNumbersLock.Lock()

	more := len(numberContainer.oddNumbers) - numberContainer.oddNumbersIndex
	if more >= count {
		endIndex := numberContainer.oddNumbersIndex + count
		result = numberContainer.oddNumbers[numberContainer.oddNumbersIndex : endIndex]
		numberContainer.oddNumbersIndex += count
	} else if more > 0 {
		count = more
		endIndex := numberContainer.oddNumbersIndex + count
		result = numberContainer.oddNumbers[numberContainer.oddNumbersIndex : endIndex]
		numberContainer.oddNumbersIndex += count
	}

	numberContainer.oddNumbersLock.Unlock()

	return result
}

func (numberContainer *NumberContainer) GetEvenNumbers(count int) []int  {
	var result []int = nil

	numberContainer.evenNumbersLock.Lock()

	more := len(numberContainer.evenNumbers) - numberContainer.evenNumbersIndex
	if more >= count {
		endIndex := numberContainer.evenNumbersIndex + count
		result = numberContainer.evenNumbers[numberContainer.evenNumbersIndex : endIndex]
		numberContainer.evenNumbersIndex += count
	} else if more > 0 {
		count = more
		endIndex := numberContainer.evenNumbersIndex + count
		result = numberContainer.evenNumbers[numberContainer.evenNumbersIndex : endIndex]
		numberContainer.evenNumbersIndex += count
	}

	numberContainer.evenNumbersLock.Unlock()

	return result
}

注意: 接口实现的数据结构一定要传 指针进来,
因为 Go 语言中的接口是值类型, 不是引用类型.
如果不是指针, 在方法内部的改动不会应用到结构内部.
这个 C++ 的对象差不多.

五. 文本转对象

前面我们已经将文本读取到内存中, 它是一个 byte 数组.
现在我们需要将它转成文本, 然后封装成 NumberContainer结构, 供多线程使用,

假设面试中提到的文本是以换行符分隔的, 那么有如下代码:

const inputFileName = "/Users/joel/Desktop/input.txt"
bytes := ReadBytesFromFile(inputFileName) //读取文件成byte 数组

if bytes == nil {
	return
}

fileContent := string(bytes) //字节数组转字符串
lines := strings.Split(fileContent, "\n") //字符串分隔

oddNumbers, evenNumbers := GroupBy(lines)

var oddNumbersRdLock sync.Mutex
var evenNumbersRdLock sync.Mutex

// Go语言结构的初始化和 C++ 11 的语法很像.
// 按照成员的定义顺序, 或者像 swift 语言那样指定要赋值的成员.
numberContainer := NumberContainer {
oddNumbers,
evenNumbers,
0,
0,
oddNumbersRdLock,
evenNumbersRdLock,
}

其中 GroupBy 函数如下:

// 奇偶分组
func GroupBy(lines []string) (oddNumbers []int, evenNumbers []int)  {

	for line := range lines { //foreach 循环
		num := int(line)
		if num % 2 == 0 {
			evenNumbers = append(evenNumbers, num)
		} else {
			oddNumbers = append(oddNumbers, num)
		}
	}

	return oddNumbers, evenNumbers
}

六. channel (通道)

通道, 形象化来说就像是我们生活中的水管, 要源头注入水, 末端才能喝到水.
如果你会Socket 编程的话, 那么这就更好理解什么是 channel 了.
在 Socket 编程中, 服务端启动一个 FD 进行阻塞监听, 客户端向socket FD写入一个 4 个字节的 int,
服务端按照顺序读出4个字节, 然后继续阻塞等待数组.

channel 也一样, 也需要有人充当客户端的角色, 需要有人充当服务端的角色.

6.1 声名channel的语法如下:

//声名通道的语法语法
// chan 是关键字
var varible_name chan channel_type

哈哈, 很少见声名一个类型需要四部分的语言吧 ?

6.2 创建 channel 的常用语法如下:

//构建一个chan 实例
//make 是一个系统函数
var myChan chan int = make(chan int)

//常用简写
myChan := make(chan int)

也没见过函数里面填写 类似于两个参数 还不要逗号分隔的吧?

6.3 只读的channel

创建只读通道

myChan := make(chan <-int)

这语法看起来怪怪的…

这样看来 chain int 其实合起来是一个类型, 意思就类似于 Java 中的泛型.
如果要在Java 自己实现通道的话, 声名差不多是 : Channel

6.4 只写的channel

创建只写通道

myChan := make(<- chan int)

嗯… 这语法看起来更怪异.

6.5 channel的读写.

上面我们提到了如何创建仅可读或者仅可写的通道(可读又可写的在6.2小节).
其实, 创建只有一个方向的通道没啥意义, 我写进去每人读, 我这通道有何用?
但是, 对于形参来说这是很重要的.
例如某个线程或者说某个方法, 它只需要对某个通道进行写入, 为了告诉调用者: 我在这个方法只写, 绝对不读它.
那么这个函数的声名应该如下:

//此函数接收一个int类型的只写通道
func MyFunc(wChan chan <- int)

同样的, 你应该会在形参上声名需要一个只写通道.

对于通道的读和写, 无非就是参数方向不一样, 看如下代码:

//声名string 类型的通道
myChan := make(chain string)

//将字符串写入到通道中
myChan <- "HelloWorld!"

//从通道中读取字符串
str := <- myChan

6.6 channel 使用的注意事项

上面我们提到过用 Socket 来理解通道是最简单的,
其实 通道也是阻塞的,

如果你向一个没有写入任何数据的通道进行读取的话, 当前线程会阻塞,直到读到数据或报错为止.
就像 6.5 的示例, 如果把第三行代码与第二行换一个顺序, 会导致程序卡死.

我们知道socket 编程中, 是有内核缓冲区大小的,
如果客户端缓冲区满了, 操作系统会发送ACK信号告诉服务端慢点发消息, 我处理不过来了!
如果服务端继续发送数据的话就会堵塞(如果FD 设置了非阻塞,函数直接返回, 实际发送长度小于数据长度.)

Go 语言的通道也是有缓冲区的, 默认大小只有一个,
也就是说, 如果已经写入一个数据之后再写一次就会等待之前写入的数据被读出之后才能返回.

如何设置默认缓冲区:

//make的第二个参数指定缓冲区大小
myChan := make(chan int, 100)

七. 工作线程的定义

上面我们已经讲了如何使用通道, 那么接下来我们需要继续完成面试题.
从面试题中得到, 我们的线程需要以下参数:

  1. 需要写到文件里面: 需要一个字符串, 是当前线程要写出的文件路径.
  2. 需要一个通道, 负责与另一个线程通讯, 什么时候你(打印偶数或者奇数的线程)该工作了.
  3. 需要另一个通道, 当前线程结束时, 与主线程通讯.

那么集合实际的代码, 我们的工作线程函数定义为如下

func WorkThread (
	outputFile string, //输出文件
	outChan chan <-int, // 线程退出通信通道
	threadChan chan int, // 线程之间通信通道
	threadNum int, // 线程编号
	isOddWorker bool, //是否是奇数输出线程
	numContainer *NumberContainer,
	)

八. 线程(goroutine)

提示一下, goroutine 并不是真线程(自行百度), 不过我很乐意叫它线程.
就像Linux 下的线程也不是真线程(自行百度), 我们还是叫它线程.

8.1 启动线程

Go 语言中启动线程和常见的语言不一样, 它是用一个关键字来完成的,
而这个关键字正是: go

语法如下:

//启动一个线程,这个线程没有参数
go functionName()

//启动一个需要参数的线程
//函数: 
func functionName(a int)
//启动
var a int = 10
go functionName(a)

嗯… 这里涉及到一个问题, 我想没人想问: 一个有返回值的函数可以做线程函数吗?
其实是可以的, 不过这返回值… 我觉得你拿不到,我没深究这个问题.

8.2 结束

最后, 我将此面试题的主函数里面的内容, 由于这篇博客已经很长了, 其他代码可以到我的 github 中查看.
当然, 我的代码是否符合网易出这道面试题的初衷就不得而知了, 哈哈~~.

func main() {
	
	const inputFileName = "/Users/joel/Desktop/input.txt"
	bytes := ReadBytesFromFile(inputFileName)

	if bytes == nil {
		return
	}

	fileContent := string(bytes)
	lines := strings.Split(fileContent, "\n")

	oddNumbers, evenNumbers := GroupBy(lines)

	var oddNumbersRdLock sync.Mutex
	var evenNumbersRdLock sync.Mutex
	numberContainer := NumberContainer {
	oddNumbers,
	evenNumbers,
	0,
	0,
	oddNumbersRdLock,
	evenNumbersRdLock,
	}

	const threadCount = 10

	//线程退出通讯通道
	threadExitChan := make(chan int, threadCount)

	const outputFilePre = "/Users/joel/Desktop/output/thread_grp_%d.txt"
	for i := 0; i < threadCount; i += 2 {

		//指定线程组的输出文件
		outputFile := fmt.Sprintf(outputFilePre, i)

		//创建当前线程工作的通讯通道
		//写入 1 表示奇数线程工作
		//写入 2 表示偶数线程工作
		//写入 3 表示偶数线程退出
		//写入 4 表示奇数线程退出
		threadWorkChan := make(chan int, 1)

		//偶数输出线程
		go WorkThread(outputFile, threadExitChan, threadWorkChan, i, false, &numberContainer)
		//奇数输出线程
		go WorkThread(outputFile, threadExitChan, threadWorkChan, i + 1, true, &numberContainer)
		//先让偶数线程开始工作
		threadWorkChan <- 2
	}

	for j := 0; j < threadCount; j++ {
		t := <- threadExitChan
		fmt.Printf("thread %d exit\n", t)
	}

	fmt.Println("Done")
}

九. 结束

github 链接: https://github.com/joelcho/learn/tree/master/letsgo/02

你可能感兴趣的:(GoLang,Go,多线程,通道)