Golang并发编程


1 进程、线程、协程

1.1 什么是进程?

  进程是计算机中的一个执行中的程序实例,由操作系统管理和调度。一个进程包括程序代码,运行时数据和状态信息,在计算机中拥有独立的地址空问和系统资源,如文件句柄,网格连接等。每个进程都可以在其自己的地址空间中运行,而不会与其他进程互相干扰。

  在操作系统中,每个进权都有自己的进程标识符(PID),用于唯一标识一个进权。进程可以通过操作系统提供的API来与其他进程通信,如管道、共享内存、消息队列等。进程可以创建新的进程或者终止自己。

  在操作系统中,进程是一种重量级的资源,需要操作系统分配大量的资源来维护。每个进程需要占用一定的内存、CPU时间、I/O资源等,如果系统中同时运行大量进程,就会导致系统负载过高,甚至引发系统崩溃。因此,操作系统通常会对进程进行调度,以便在不同进程之间平衡资源的使用。

  进程是多任务操作系统中的基本执行单位,可以同时运行多个进程,并且不同的进程可以并发地执行,操作系统为每个进程分配了一定的时间片,以便让不同的进程轮流执行,在单核处理器上,操作系统通过时间分片来实现多进程的并发执行;而在多核处理器上,每个核心都可以独立地执行不同的进程,从而实现更高效的并发处理。

  可以把进程想象成运行中的一个应用程序,比如打开了“微信”或者“浏览器”,各自就是一个进程。

  每个进程就像一个独立的小世界,有:

   自己的内存(数据只能自己用)

   自己的身份编号(PID)

   自己的资源(打开的文件、网络连接等)

一个电脑上可以同时运行多个进程,它们互不干扰,各干各的。

比如一边听歌(一个进程),一边看电影(另一个进程),互相不会打架。

每个进程占用的资源比较多,开启太多会让电脑变卡

1.2 什么是线程?

  线程是进程中的一个执行单元,它是操作系统中的最小执行单位,也是进程中实际执行任务的部分,一个进程可以包含多个线程,每个线程可以独立地执行不同的任务,并且可以共享进程的内存和其他资源。

  每个线程有自己的线程栈,用于存储局部变量、函数参数和返回值等数据。不同的线程可以共享进程的堆内存、全局变量和静态变量等资源。因为线程之间共享内存,所以线程之间可以直接进行通信,而不需要像进程那样使用IPC(进程间通信)机制。

  线程相比进程的优势在于,线程的创建和销毁开销比校小,可以更快速地响应用户操作,更容易实现并发处理。例如,在一个多线程的网络服务器中,每个客户端连接可以对应一个独立的线程来处理,这样可以实现更高效的并发处理。

  线程也存在一些问题,比如线程之间共享内存可能号致数据竞争和死锁等问题,需要使用问步机制来解决;另外,如果线程数量过多,会导致线程上下文切换的开销过大,影响系统的性能,因此,在设计多线程应用程序时,需要仔细权衡线程数量和线程间通信的开销,并采取合适的同步机制来保证线程之间的正确性。

  线程就像是进程里面的“小工人”。

  比如在微信(一个进程)里:一边打字聊天、一边上传文件、一边收消息通知。

  这些不同的任务就是由多个线程同时完成的。

  特点:

   一个进程可以有多个线程

   多个线程之间可以共享数据

   创建线程比创建进程要轻便,速度快,占用资源少

  线程之间共享数据容易出问题,比如两个线程同时修改一个数据,可能导致混乱,这种问题叫“数据冲突”或“死锁”。

1.3 什么是协程?

  协程可以理解为比线程更轻便的小任务,是线程里的“更小单位”。

  可以这样理解:

   线程像“员工”

   协程像“员工的手头任务”

   一个员工(线程)可以切换做多个小任务(协程)

协程的好处:

  • 非常轻便:创建一个协程的开销非常小,可以同时开启几万个。
  • 效率高:不需要操作系统帮忙调度,是程序自己管理的,切换更快。
  • 不会乱抢资源:协程一般是一个接一个地运行,不会出现线程那种抢数据的问题

1.4 Golang中的线程与协程

  Go语言中有协程(goroutine),虽然底层依然用到了线程,但程序员根本不用操心线程,只要写:

go func() {
    // 这里写你要做的事,比如下载图片
}()

  以上写法就启动了一个新的“协程任务”,Go 会自动调度安排。

  可以理解为:Golang 中的线程已经被“包装”得很轻便,只用“go”这个关键词,就能轻松开启任务。

Golang 中协程的原理

  Go会把所有任务(协程)分配给一个叫 调度器 的东西。

  调度器会用少量的操作系统线程(比如几个员工)来跑大量的协程任务(上万个小任务)。

  如果某个任务卡住(比如在等网络),Go 会让线程先去干别的,等准备好了再回来继续。

  任务不会阻塞其他任务,效率高。

原理中提到的调度方式叫做 M:N 模型,意思是:

  • M 个操作系统线程(员工)
  • N 个 goroutine(任务)
  • Go 运行时自动安排它们之间的运行关系

1.5 进程、线程、协程的总结

  • 进程:运行的程序(微信、浏览器)
  • 线程:程序中的多个任务(微信里聊天、接收消息)
  • 协程:线程里的更小任务,更轻更快(Go语言的 goroutine)

在 Golang 中,只用“协程”就能写出高并发的程序


在 Go 里,go 关键字就能轻松并发执行任务,不需要考虑复杂的线程管理问题!

2 串行、并发、并行

  串行(Serial)、 并发(Concurrency)、并行(Parallel)看起来很“技术”,但日常生活中早就见过它们—比如:上厕所

2.1 串行

  串行就是:一个人用完,另一个再用,排好队,依次进行。 没有人能“插队”或“同时来”。

  厕所只有 1个蹲位,3个人排队:

A 用 → B 等 → A 出来 → B 用 → C 等 → B 出来 → C 用

  特点:简单、不会冲突、速度慢,只能一个一个来

2.2 并发

  并发的英文是“concurrency,是指多个任务之间的交替执行,虽然它们看起来同时执行,但是实际上是通过操作系统的调度器轮流执行的。

  在计算机系统中,常常需要同时执行多个任务,比如在Web服务器中,需要同时处理多个客户端的请求。在这种情况下,可以使用并发来实现多个任务的交替执行,从而提高系统的处理能力和吞吐量。

  在并发计算中,不同的任务之间可能会共享资源,因此需要特别注察对资源的管理和保护,避免出现竞争条件和死锁等问题。

  并发和并行是不同的概念。并发是指多个任务之间的交替执行,而并行是指多个任务同时执行,每个任务都在不同的处理器或计算核心上独立运行,相互之间没有干扰,虽然并发和并行有所不同,但是在实际应用中常常需要同时使用它们来提高系统的处理能力和效率。

  并发就像:厕所只有1个,但3个人轮流抢着用, 每个人都进去一会儿就出来换另一个人,来回切换。

A、B、C 三人都很急,只能一个厕所用

  A 进去 30 秒 → 出来换 B

  B 用了 30 秒 → 出来换 C

  C 用了 30 秒 → 再轮到 A…

虽然只有一个人真正用着,但从外面看,好像大家都在用

  特点

   一个厕所轮流“切换”使用

   看起来是同时,其实是快速轮换

   有“调度器”安排谁先谁后(像 CPU 调度线程)

2.3 并行

  并行的英文是“parallel”,是指同时进行多个任务,每个任务都在不同的处理器或计算核心上独立运行,以达到更高的处理效率和更快的响应速度。

  在计算机系统中,通常使用多核处理器或者分布式系统来实现并行计算,在并行计算中,每个任务可以独立地分配到不同的处理器或计算核心上进行计算,以实现任务的同时执行。

  并行可以大大提高计算机系统的处理能力和吞吐量,特别是在大规模数据处理、机器学习、深度学习等需要大量计算的场票中,可以有效地缩短处理时间,提高数据处理的效率。

  并行和并发是不同的概念。并发是指多个任务之间的交替执行,虽然它们看起来同时执行,但实际上是通过操作系统的调度器轮流执行的。而并行是指多个任务同时执行,每个任务都在不同的处理器或计算核心上独立运行,相互之间没有干扰。

  并行就是:每个人都有自己的厕所,可以同时解决问题!

  现在有 3个厕所,A、B、C 三人同时上:

   A → 厕所1

   B → 厕所2

   C → 厕所3

  特点

   真正的“同时进行”

   不用抢、不用等

   速度快,效率高

2.4 总结

概念 场景举例 只有1个厕所? 同时解决? 说明
串行 3人排队上1个厕所 ✅ 是 ❌ 否 一人用完才轮下一个
并发 3人轮流抢1个厕所 ✅ 是 ❌ 看起来像 快速切换使用,看起来同时
并行 3人各上各的厕所 ❌ 否,3个厕所有 ✅ 是 真正同时进行,无等待

串行:一个一个来

并发:轮流蹭厕所

并行:各上各的,不耽误

3 Golang协程

3.1 创建协程

Go语言中可以通过关键字 “go” 来创建协程。可使用以下方式创建一个协程:

  1.直接使用 "go" 关键字启动一个匿名函数作为协程

go func() {
    // do something
}()


// 主协程:main    子协程:g1
func main() {
	fmt.Printf("\"start\": %v\n", "start")
	go func() {
		fmt.Printf("\"g1\": %v\n", "g1")
		time.Sleep(time.Second * 3)
	}()
	time.Sleep(time.Second * 4)
	fmt.Printf("\"end\": %v\n", "end")
}

  2.定义一个函数,然后使用 "go" 关键字启动该函数作为协程

func foo() {
    // do something
}

go foo()

// 主协程:main    子协程:g1
func g1() {
	fmt.Printf("\"g1\": %v\n", "g1")
	time.Sleep(time.Second * 3)
	
}

func main() {
	fmt.Printf("\"start\": %v\n", "start")
	go g1()
	time.Sleep(time.Second * 4)
	fmt.Printf("\"end\": %v\n", "end")
}

  关键字 “go” 表示启动一个新的协程来执行匿名函数或函数 foo()。

  在运行时,Go语言的协程调度器将负责分配资源和处理器来执行这个协程。

  使用 “go” 关键字启动协程时,不能直接返回协程的结果。如果需要获取协程的结果,可以使用 channel 或者其他同步机制来实现。在使用协程时,需要注意对共享资源的访问,避免竞态条件和数据冲突等问题。

  下面代码演示了主协程和子协程之间交替执行的过程

func child() {
	for i := 1; i <= 5; i++ {
		fmt.Println("child:", i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go child()

	for i := 1; i <= 5; i++ {
		fmt.Println("main:", i)
		time.Sleep(100 * time.Millisecond)
	}
}
// 输出结果:main: 1  child: 1  child: 2   main: 2  main: 3   child: 3   child: 4  main: 4   main: 5   child: 5

  在上述代码中,创建了一个子协程 child() 和一个主协程 main(),它们会交替执行。在子协程 child() 中,打印输出一系列数字,并且使用 time.Sleep() 函数暂停 100 毫秒。在主协程 main() 中,也是同样的操作。

  通过在 main() 函数中调用 go child(),启动了子协程,它会和主协程一起交替执行。

  从输出结果可以看出,主协程和子协程之间交替执行,它们的执行顺序并不是固定的,而是由操作系统的调度决定的。由于子协程是并发执行的,所以在主协程中调用 go child() 后,主协程会继续执行,不会等待子协程执行完。

3.2 停止协程

在 Golang 中,协程的停止可以通过两种方式来实现:

1、使用 context.Context 进行协程的取消

可以使用 context.Context 来控制协程的生命周期,从而达到停止协程的目的。具体实现方式如下:

package main

import (
    "context"
    "fmt"
    "time"
)

func printNum(ctx context.Context, n int) {
    for i := 1; i <= n; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("协程被取消")
            return
        default:
            fmt.Printf("子协程中的数字:%d\\n", i)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go printNum(ctx, 10)

    // 3 秒后取消协程
    time.Sleep(3 * time.Second)
    cancel()

    // 等待协程结束
    time.Sleep(1 * time.Second)
    fmt.Println("主协程退出")
}

在上述代码中,我们通过 context.Context 来实现了协程的取消。在 printNum 函数中,我们使用 select 语句来监听 ctx.Done() 是否被关闭,如果关闭了,则协程被取消。在 main 函数中,我们通过 context.WithCancel 函数创建了一个带有取消功能的上下文对象 ctx,并将其传递给 printNum 函数。在主协程中,我们等待 3 秒钟后调用 cancel 函数来取消协程的执行。最后,我们等待协程结束,并输出 主协程退出

golang context 详解

https://www.duoke360.com/tutorial/topic/golang-context

2、使用 channel 进行协程的关闭

可以通过关闭 channel 来实现协程的停止。具体实现方式如下:

package main

import (
    "fmt"
    "time"
)

func printNum(stopCh <-chan struct{}, n int) {
    for i := 1; i <= n; i++ {
        select {
        case <-stopCh:
            fmt.Println("协程被关闭")
            return
        default:
            fmt.Printf("子协程中的数字:%d\\n", i)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    stopCh := make(chan struct{})
    go printNum(stopCh, 10)

    // 3 秒后关闭协程
    time.Sleep(3 * time.Second)
    close(stopCh)

    // 等待协程结束
    time.Sleep(1 * time.Second)
    fmt.Println("主协程退出")
}

在上述代码中,我们使用 channel 来实现了协程的停止。在 printNum 函数中,我们通过监听 stopCh 的关闭来实现协程的停止。在 main 函数中,我们创建了一个 stopCh 的无缓冲通道,并将其传递给 printNum 函数。在主协程中,我们等待 3 秒钟后关闭 stopCh 通道,从而停止协程的执行。

3、使用布尔变量

使用一个 boolean 变量来控制协程的执行。在协程中,检查这个变量的值,如果为 true,则退出协程的执行。例如:

package main

import (
	"fmt"
	"time"
)

func worker(stopCh chan bool) {
	for {
		select {
		default:
			fmt.Println("working...")
			time.Sleep(500 * time.Millisecond)
		case <-stopCh:
			fmt.Println("stopped.")
			return
		}
	}
}

func main() {
	stopCh := make(chan bool)
	go worker(stopCh)
	time.Sleep(2 * time.Second)
	stopCh <- true
	time.Sleep(1 * time.Second)
}

在这个例子中,我们创建了一个名为 worker 的协程,它会不断地打印 “working…”,直到收到一个 bool 值为 true 的信号,然后打印 “stopped.” 并退出协程的执行。

main 函数中,我们创建了一个 stopCh 的 channel,然后启动了 worker 协程。接着,程序会睡眠 2 秒钟,然后向 stopCh channel 发送一个 bool 值为 true 的信号,用于停止 worker 协程的执行。最后,程序再睡眠 1 秒钟,确保 worker 协程已经停止执行。


文章作者: 罗宇
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 罗宇 !
  目录