论go语言中goroutine的使用

go中的goroutine是go语言在语言级别支持并发的一种特性。初接触go的时候对go的goroutine的欢喜至极,实现并发简便到简直bt的地步。但是在项目过程中,越来越发现goroutine是一个很容易被大家滥用的东西。goroutine是一把双面刃。这里列举一下goroutine使用的几宗罪:

1 goroutine的指针传递是不安全的


1

2

3

4

5

6

7

8

fun main() {

    request := request.NewRequest() //这里的NewRequest()是传递回一个type Request的指针

    go saveRequestToRedis1(request)

    go saveReuqestToRedis2(request)

     

    select{}

 

}

非常符合逻辑的代码:

主routine开一个routine把request传递给saveRequestToRedis1,让它把请求储存到redis节点1中

同时开另一个routine把request传递给saveReuqestToRedis2,让它把请求储存到redis节点2中

然后主routine就进入循环(不结束进程)

 

问题现在来了,saveRequestToRedis1和saveReuqestToRedis2两个函数其实不是我写的,而是团队另一个人写的,我对其中的实现一无所知,也不想去仔细看内部的具体实现。但是根据函数名,我想当然地把request指针传递进入。

 

好了,实际上saveRequestToRedis1和saveRequestToRedis2 是这样实现的:


1

2

3

4

5

6

7

func saveRequestToRedis1(request *Request){

     

     request.ToUsers = []int{1,2,3} //这里是一个赋值操作,修改了request指向的数据结构

     

    redis.Save(request)

    return

}

这样有什么问题?saveRequestToRedis1和saveReuqestToRedis2两个goroutine修改了同一个共享数据结构,但是由于routine的执行是无序的,因此我们无法保证request.ToUsers设置和redis.Save()是一个原子操作,这样就会出现实际存储redis的数据错误的bug。

 

好吧,你可以说这个saveRequestToRedis的函数实现的有问题,没有考虑到会是使用go routine调用。请再想一想,这个saveRequestToRedis的具体实现是没有任何问题的,它不应该考虑上层是怎么使用它的。那就是我的goroutine的使用有问题,主routine在开一个routine的时候并没有确认这个routine里面的任何一句代码有没有修改了主routine中的数据。对的,主routine确实需要考虑这个情况。但是按照这个思路,所以呢?主goroutine在启用go routine的时候需要阅读子routine中的每行代码来确定是否有修改共享数据??这在实际项目开发过程中是多么降低开发速度的一件事情啊!

 

go语言使用goroutine是想减轻并发的开发压力,却不曾想是在另一方面增加了开发压力。

 

上面说的那么多,就是想得出一个结论:

gorotine的指针传递是不安全的!!

 

如果上一个例子还不够隐蔽,这里还有一个例子:


1

2

3

4

5

6

7

8

fun (this *Request)SaveRedis() {

    redis1 := redis.NewRedisAddr("xxxxxx")

    redis2 := redis.NewRedisAddr("xxxxxx")

    go this.saveRequestToRedis(redis1)

    go this.saveRequestToRedis(redis2)

     

    select{}

}

很少人会考虑到this指针指向的对象是否会有问题,这里的this指针传递给routine应该说是非常隐蔽的。

 

2 goroutine增加了函数的危险系数

这点其实也是源自于上面一点。上文说,往一个go函数中传递指针是不安全的。那么换个角度想,你怎么能保证你要调用的函数在函数实现内部不会使用go呢?如果不去看函数体内部具体实现,是没有办法确定的。

例如我们将上面的典型例子稍微改改


1

2

3

4

5

6

func main() {

    request := request.NewRequest()

    saveRequestToRedis1(request)

    saveRequestToRedis2(request)

    select{}

}

这下我们没有使用并发,就一定不会出现这问题了吧?追到函数里面去,傻眼了:


1

2

3

4

5

6

7

8

9

func saveReqeustToRedis1(request *Request) {

           

            go func() {

          

          request.ToUsers = []{1,2,3}

         ….

         redis.Save(request)

    }

}

我勒个去啊,里面起了一个goroutine,并修改了request指针指向的对象。这里就产生了错误了。好吧,如果在调用函数的时候,不看函数内部的具体实现,这个问题就无法避免。所以说呢?所以说,从最坏的思考角度出发,每个调用函数理论上来说都是不安全的!试想一下,这个调用函数如果不是自己开发组的人编写的,而是使用网络上的第三方开源代码...确实无法想象找出这个bug要花费多少时间。

3 goroutine的滥用陷阱

看一下这个例子:


1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

func main() {

    go saveRequestToRedises(request)

}

 

func saveRequestToRedieses(request *Request) {

    for _, redis := range Redises {

        go redis.saveRequestToRedis(request)

    }

}

 

func saveRequestToRedis(request *Request) {

            ….

            go func() {

                     request.ToUsers = []{1,2,3}

                        

                        redis.Save(request)

            }

 

}

神奇啊,go无处不在,好像眨眨眼就在哪里冒出来了。这就是go的滥用,到处都见到go,但是却不是很明确,哪里该用go?为什么用go?goroutine确实会有效率的提升么?

c语言的并发比go语言的并发复杂和繁琐地多,因此我们在使用之前会深思,考虑使用并发获得的好处和坏处。go呢?几乎不。

 

处理方法

下面说几个我处理这些问题的方法:

1 当启动一个goroutine的时候,如果一个函数必须要传递一个指针,但是函数层级很深,在无法保证安全的情况下,传递这个指针指向对象的一个克隆,而不是直接传递指针


1

2

3

4

5

6

7

8

fun main() {

    request := request.NewRequest()

    go saveRequestToRedis1(request.Clone())

    go saveReuqestToRedis2(request.Clone())

     

    select{}

 

}

Clone函数需要另外写。可以在结构体定义之后简单跟上这个方法。比如:


1

2

3

4

5

6

func (this *Request)Clone(){

    newRequest := NewRequst()

    newRequest.ToUsers = make([]int, len(this.ToUsers))

    copy(newRequest.ToUsers, this.ToUsers)

 

}

其实从效率角度考虑这样确实会产生不必要的Clone的操作,耗费一定内存和CPU。但是在我看来,首先,为了安全性,这个尝试是值得的。其次,如果项目对效率确实有很高的要求,那么你不妨在开发阶段遵照这个原则使用clone,然后在项目优化阶段,作为一种优化手段,将不必要的Clone操作去掉。这样就能在保证安全的前提下做到最好的优化。

2 什么时候使用go的问题

有两种思维逻辑会想到使用goroutine:

1 业务逻辑需要并发

比如一个服务器,接收请求,阻塞式的方法是一个请求处理完成后,才开始第二个请求的处理。其实在设计的时候我们一定不会这么做,我们会在一开始就已经想到使用并发来处理这个场景,每个请求启动一个goroutine为它服务,这样就达到了并行的效果。这种goroutine直接按照思维的逻辑来使用goroutine

2 性能优化需要并发

一个场景是这样:需要给一批用户发送消息,正常逻辑会使用


1

2

3

4

for _, user := range users {

    sendMessage(user)

 

}


1

  

但是在考虑到性能问题的时候,我们就不会这样做,如果users的个数很大,比如有1000万个用户?我们就没必要将1000万个用户放在一个routine中运行处理,考虑将1000万用户分成1000份,每份开一个goroutine,一个goroutine分发1万个用户,这样在效率上会提升很多。这种是性能优化上对goroutine的需求

 

按照项目开发的流程角度来看。在项目开发阶段,第一种思路的代码实现会直接影响到后续的开发实现,因此在项目开发阶段应该马上实现。但是第二种,项目中是由很多小角落是可以使用goroutine进行优化的,但是如果在开发阶段对每个优化策略都考虑到,那一定会直接打乱你的开发思路,会让你的开发周期延长,而且很容易埋下潜在的不安全代码。因此第二种情况在开发阶段绝不应该直接使用goroutine,而该在项目优化阶段以优化的思路对项目进行重构。

 

总结

总结下,文章写了这么多,并不是想让你对goroutine的使用产生畏惧,而是想强调一个观点:

goroutine的使用应该是保守型的。

在你敲下go这两个字母之前请仔细思考是否应该使用goroutine这柄利刃。

 

后续

在你看完这篇以后,也建议看看stevewang的这篇吧:

http://blog.sina.com.cn/s/blog_9be3b8f10101dsr6.html

时间: 2024-08-31 15:13:25

论go语言中goroutine的使用的相关文章

Go语言中Select语句用法实例_Golang

本文实例讲述了Go语言中Select语句用法.分享给大家供大家参考.具体分析如下: select 语句使得一个 goroutine 在多个通讯操作上等待. select 会阻塞,直到条件分支中的某个可以继续执行,这时就会执行那个条件分支.当多个都准备好的时候,会随机选择一个. 复制代码 代码如下: package main import "fmt" func fibonacci(c, quit chan int) {         x, y := 1, 1         for {

c语言-C语言中的rand()函数的问题

问题描述 C语言中的rand()函数的问题 代码如下,为什么a总是输出0,而b却能正常输出?rand()的返回值不是在0~RAND_MAX之间的整数吗? #include <stdlib.h> #include int main (void) { int a; int b; int i; for (i=0;i<5;i++) { a=10*rand()/RAND_MAX; printf ("a=%dn",a); } for (i=0;i<5;i++) { b=10

Java 语言中 Enum 类型的使用介绍

Enum 类型的介绍 枚举类型(Enumerated Type) 很早就出现在编程语言中,它被用来将一组类似 的值包含到一种类型当中.而这种枚举类型的名称则会被定义成独一无二的类型描述符,在这一点上和常量的 定义相似.不过相比较常量类型,枚举类型可以为申明的变量提供更大的取值范围. 举个例子来说明 一下,如果希望为彩虹描绘出七种颜色,你可以在 Java 程序中通过常量定义方式来实现. 清单 1. 常量定义 Public static class RainbowColor { // 红橙黄绿青蓝紫

C语言中trim的实现

本文详细介绍C语言中trim的实现 描述 自己用ATL写了个COM,不支持MFC,所以无法用CString,但支持C编码,遇到字符串(字符数组),想去掉字符串中的空格,C下没有TRIM函数,找又没找到,几行代码自己写吧.往后大家万一遇到用着也方便. 说明 1.seps是需要去除的字符数组,可以有几个字符,也可以一个.这里是空格,最常用的. 2.参数也很简单,第一个是结果数组指针,第二个是原字符数组指针,第三个是需要去掉的字符数组指针.返回的是结果数组指针. #include "stdafx.h&

详解Ruby语言中的String

Ruby语言中的String是mutable的,不像java.C#中的String是immutable的.比如 str1="abc" str2="abc" 在java中,对于字面量的字符串,jvm内部维持一张表,因此如果在java中,str1和str2是同一个 String对象.而在Ruby中, str1和str2是完全不同的对象.同样,在java中对于String对象的操作都将 产生一个新的对象,而Ruby则是操纵同一个对象,比如: str="abc&q

c语言中static用法总结

一.c程序存储空间布局   C程序一直由下列部分组成: 正文段--CPU执行的机器指令部分:一个程序只有一个副本:只读,防止程序由于意外事故而修改自身指令: 初始化数据段(数据段)--在程序中所有赋了初值的全局变量,存放在这里. 非初始化数据段(bss段)--在程序中没有初始化的全局变量:内核将此段初始化为0. 栈--增长方向:自顶向下增长:自动变量以及每次函数调用时所需要保存的信息(返回地址:环境信息). 堆--动态存储分. |-----------||           ||-------

对C语言中sizeof细节的三点分析介绍

以下是对C语言中sizeof的细节进行了详细的分析介绍,需要的朋友可以参考下   1.sizeof是运算符,跟加减乘除的性质其实是一样的,在编译的时候进行执行,而不是在运行时才执行.那么如果编程中验证这一点呢?ps:这是前两天朋友淘宝面试的一道题,小编理解: 复制代码 代码如下: #include<iostream> using namespace std; int main() {     int i=1;     cout<<i<<endl;     sizeof(

C语言中printf(),sprintf(),scanf(),sscanf()的用法和区别

以下是对C语言中printf(),sprintf(),scanf(),sscanf()的用法以及区别进行了详细的分析介绍,需要的朋友可以参考下   printf语法: #include <stdio.h> int printf( const char *format, ... ); printf()函数根据format(格式)给出的格式打印输出到STDOUT(标准输出)和其它参数中.返回值是输出的字符数量.sprintf语法: #include <stdio.h> int spri

C语言中assert的用法

以下是对C语言中assert的使用方法进行了介绍,需要的朋友可以参考下   assert宏的原型定义在<assert.h>中,其作用是如果它的条件返回错误,则终止程序执行,原型定义:#include <assert.h> void assert( int expression );assert的作用是现计算表达式 expression ,如果其值为假(即为0),那么它先向stderr打印一条出错信息, 然后通过调用 abort 来终止程序运行.请看下面的程序清单badptr.c: