golang学习【12】:进程和线程

进程和线程

今天我们使用的计算机早已进入多CPU或多核时代,而我们使用的操作系统都是支持“多任务”的操作系统,这使得我们可以同时运行多个程序,也可以将一个程序分解为若干个相对独立的子任务,让多个子任务并发的执行,从而缩短程序的执行时间,同时也让用户获得更好的体验。因此在当下不管是用什么编程语言进行开发,实现让程序同时执行多个任务也就是常说的“并发编程”,应该是程序员必备技能之一。为此,我们需要先讨论两个概念,一个叫进程,一个叫线程。

概念

进程就是操作系统中执行的一个程序,操作系统以进程为单位分配存储空间,每个进程都有自己的地址空间、数据栈以及其他用于跟踪进程执行的辅助数据,操作系统管理所有进程的执行,为它们合理的分配资源。进程可以通过fork或spawn的方式来创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此必须通过进程间通信机制(IPC,Inter-Process Communication)来实现数据共享,具体的方式包括管道、信号、套接字、共享内存区等。

一个进程还可以拥有多个并发的执行线索,简单的说就是拥有多个可以获得CPU调度的执行单元,这就是所谓的线程。由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。当然在单核CPU系统中,真正的并发是不可能的,因为在某个时刻能够获得CPU的只有唯一的一个线程,多个线程共享了CPU的执行时间。使用多线程实现并发编程为程序带来的好处是不言而喻的,最主要的体现在提升程序的性能和改善用户体验,今天我们使用的软件几乎都用到了多线程技术,这一点可以利用系统自带的进程监控工具(如macOS中的“活动监视器”、Windows中的“任务管理器”)来证实。

当然多线程也并不是没有坏处,站在其他进程的角度,多线程的程序对其他程序并不友好,因为它占用了更多的CPU执行时间,导致其他程序无法获得足够的CPU执行时间;另一方面,站在开发者的角度,编写和调试多线程的程序都对开发者有较高的要求,对于初学者来说更加困难。

Go语言本身并没有直接提供多线程或多进程的抽象。Go的并发模型基于goroutinechannel,这是一种比传统的线程和进程更为轻量级和高效的并发机制。

Goroutine

goroutine是Go语言特有的并发执行单元,它比线程轻量得多。Go运行时会管理goroutine的调度,它们可以在多个OS线程甚至在同一个OS线程上运行。Goroutine的创建和上下文切换开销很小,这使得创建数以万计的goroutine成为可能。

补充说明:进程可以使用os/exec包或者os.StartProcess类来创建子进程,一般使用os/exec,不过进程并行的实现依旧需要通过goroutine

创建说明

Go 程(goroutine)是由 Go 运行时管理的轻量级线程。

go func(x, y, z)

会启动一个新的 Go 协程并执行

func(x, y, z)

func, x, yz 的求值发生在当前的 Go 协程中,而 func 的执行发生在新的 Go 协程中。

Go 程在相同的地址空间中运行,因此在访问共享的内存时必须进行同步

应用示例

下面用下载文件的例子,来说明使用多进程/线程和不使用多进程/线程到底有什么差别,先看看下面的代码。

package main

import (
	"fmt"
	"os/exec"
	"time"
)

func downloadFile(url string) {
	fmt.Printf("Downloading %s...\n", url)
	cmd := exec.Command("wget", url)
	err := cmd.Run()
	if err != nil {
		fmt.Printf("Error downloading %s: %v\n", url, err)
		return
	}
	fmt.Printf("Download of %s completed.\n", url)
}

func main() {
	startTime := time.Now()

	// 下载第一个文件
	downloadFile("https://example.com/file1.jpg")

	// 下载第二个文件
	downloadFile("https://example.com/file2.jpg")

	elapsedTime := time.Since(startTime)
	fmt.Printf("Total elapsed time: %s\n", elapsedTime)
}

下面是运行程序得到的一次运行结果。

Downloading https://example.com/file1.jpg...
Error downloading https://example.com/file1.jpg: exit status 8
Downloading https://example.com/file2.jpg...
Error downloading https://example.com/file2.jpg: exit status 8
Total elapsed time: 2.816983407s

从上面的例子可以看出,如果程序中的代码只能按顺序一点点的往下执行,那么即使执行两个毫不相关的下载任务,也需要先等待一个文件下载完成后才能开始下一个下载任务,很显然这并不合理也没有效率。接下来我们使用goroutine的方式将两个下载任务放到不同的进程中。

package main

import (
	"fmt"
	"os/exec"
	"sync"
	"time"
)

func downloadFile(url string, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Downloading %s...\n", url)
	cmd := exec.Command("wget", url)
	err := cmd.Run()
	if err != nil {
		fmt.Printf("Error downloading %s: %v\n", url, err)
		return
	}
	fmt.Printf("Download of %s completed.\n", url)
}

func main() {
	startTime := time.Now()
	var wg sync.WaitGroup

	// 为每个下载任务增加一个计数
	wg.Add(2)

	// 并发下载两个文件
	go downloadFile("https://example.com/file1.jpg", &wg)
	go downloadFile("https://example.com/file2.jpg", &wg)

	// 等待所有下载任务完成
	wg.Wait()

	elapsedTime := time.Since(startTime)
	fmt.Printf("Total elapsed time: %s\n", elapsedTime)
}

在上面的代码中,我们使用了 go 关键字来并发地执行 downloadFile 函数,这样两个文件将同时下载,每个文件下载都在一个独立的子进程中运行;使用了 sync.WaitGroup 来跟踪两个下载任务的完成情况。每次启动一个新的下载任务时,我们调用 wg.Add(2) 来增加计数器。每个下载任务完成后,我们调用 wg.Done() 来减少计数器。主进程中的 wg.Wait() 会阻塞,直到计数器归零,即所有任务完成后,主进程才会继续执行并退出。

Downloading https://example.com/file2.jpg...
Downloading https://example.com/file1.jpg...
Error downloading https://example.com/file1.jpg: exit status 8
Error downloading https://example.com/file2.jpg: exit status 8
Total elapsed time: 1.233333079s

接下来我们将重点放在如何实现两个goroutine间的通信。我们启动两个goroutine,一个输出Ping,一个输出Pong,两个进程输出的Ping和Pong加起来一共5个。听起来很简单吧,但是如果这样写可是错的哦。

package main

import (
	"fmt"
	"sync"
)

func echoString(text string, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		fmt.Printf("output: %s\n", text)
	}

}

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go echoString("ping", &wg)

	wg.Add(1)
	go echoString("pong", &wg)

	wg.Wait()
}

这段代码会输出5个"ping"和5个"pong",因为每个goroutine都在自己的循环中打印5次消息。每个goroutine是独立运行的,它们都有自己的循环迭代,所以每个goroutine都会打印5次。要实现总共打印4次,需要通过信道channel机制实现共享,下面会详细介绍channel

信道 channel

在Go语言中,信道(channel)是一种特殊的类型,用于在不同的goroutine之间进行通信。信道可以传输特定类型的值,这些值只能通过信道发送和接收。

信道的作用
  • goroutine间的通信:信道允许不同goroutine之间安全地传递数据。通过信道发送和接收数据,可以实现goroutine之间的协作和同步
  • 同步机制:信道可以用来同步goroutine的执行。例如,一个goroutine可以等待另一个goroutine通过信道发送信号,然后再继续执行
  • 数据流控制:通过缓冲信道,可以控制数据流的大小,避免goroutine之间的数据积压或饥饿
  • 并行执行:信道可以用来控制goroutine的并行执行。例如,一个goroutine可以等待多个goroutine通过信道发送数据,然后再处理这些数据。
  • 资源管理:信道可以用来管理共享资源。通过信道控制对共享资源的访问,可以避免资源竞争和数据不一致的问题
  • 错误传递:信道可以用来传递错误信息。通过在信道中发送错误值,可以实现goroutine之间的错误传递和处理
  • 构建复杂的并发程序:信道是构建复杂并发程序的基础。通过组合多个goroutine和信道,可以实现更高级的并发编程模式,如生产者-消费者模型、工作窃取算法等
信道示例
  • 创建信道

    //用make创建
    ch := make(chan int)    // 创建一个无缓冲的整数类型信道
    ch := make(chan int, 10) // 创建一个有缓冲的整数类型信道,缓冲大小为10
    

    说明:默认情况下,创建的信道是无缓冲的。这意味着在发送值之前,必须有一个接收者准备好接收该值。否则,发送操作会阻塞直到有接收者准备好。有缓冲的信道可以存储一定数量的值。即使没有接收者,发送操作也可以进行,直到信道的缓冲区已满。当缓冲区为空时,接收操作会阻塞,直到有新的值可以接收。

  • 通过信道发送和接收值

    ch <- value // 发送值到信道
    value := <-ch // 从信道接收值
    
  • 关闭信道

    close(ch)
    

    关闭信道后,任何尝试向已关闭的信道发送值的操作都会导致恐慌(panic)。从已关闭的信道接收值时,接收操作会立即返回该信道类型的零值,并且第二个返回值(通常是布尔类型)会指示信道是否已关闭

  • range和循环

    //可以使用 range 循环从信道接收值,直到信道关闭:
    for value := range ch {
    // 处理接收到的值
    }
    

    注意:信道与文件不同,通常情况下无需关闭它们。只有在必须告诉接收者不再有需要发送的值时才有必要关闭,例如终止一个 range 循环

  • select 语句

    //select 语句使一个 Go 程可以等待多个通信操作。
    
    //select 会阻塞到某个分支可以继续执行为止,这时就会执行该分支。当多个分支都准备好时会随机选择一个执行。
    select {
        case value := <-ch1:
            // 处理从ch1接收的值
        case value := <-ch2:
            // 处理从ch2接收的值
        case ch3 <- value:
            // 处理向ch3发送的值
        default:
            // 如果以上所有信道操作都没有准备好,执行默认情况
    }
    
  • 综合示例

    package main
    
    import (
        "fmt"
        "time"
    )
    
    func ping(ch chan<- string) {
        ch <- "ping"
    }
    
    func pong(ch <-chan string, done chan<- bool) {
        msg := <-ch
        fmt.Println(msg)
        done <- true
    }
    
    func main() {
        //创建信道
        ch := make(chan string)
        done := make(chan bool)
    
        //创建gorountine
        go ping(ch)
        go pong(ch, done)
    
        //等待接收信号
        <-done
    
        //关闭信道
        close(ch)
        close(done)
    }
    

    在这个示例中,ping 函数发送一个字符串 “ping” 到信道 ch,而 pong 函数从信道 ch 接收这个字符串并打印它。done 信道用于通知 main 函数 pong 函数已经完成。

我们回到之前的问题,启动两个goroutine,一个输出Ping,一个输出Pong,两个进程输出的Ping和Pong加起来一共5个,要如何实现

package main

import (
	"fmt"
	"sync"
)

func printer(printCh, nextCh chan bool, wg *sync.WaitGroup, toPrint string, totalPrints *int) {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		if *totalPrints >= 5 {
			break
		}
		<-printCh
		fmt.Println(toPrint)
		*totalPrints++
		nextCh <- true
	}
}

func main() {
	var wg sync.WaitGroup
	pingCh := make(chan bool, 5)
	pongCh := make(chan bool, 5)
	totalPrints := 0

	wg.Add(2)

	// 启动打印"ping"的goroutine
	go printer(pingCh, pongCh, &wg, "ping", &totalPrints)

	// 启动打印"pong"的goroutine
	go printer(pongCh, pingCh, &wg, "pong", &totalPrints)

	// 开始打印循环
	pingCh <- true

	wg.Wait()
}

程序执行流程如下:

  • “ping"的goroutine首先启动并打印"ping”,然后发送信号到pongCh。
  • “pong"的goroutine被唤醒,打印"pong”,然后发送信号到pingCh。
  • 这两个步骤重复进行,直到每个字符串共打印了5次。
  • 当两个goroutine都完成了打印任务,它们各自调用wg.Done()。
  • main函数中的wg.Wait()解除阻塞,程序结束。

这个例子运用了多个知识结合,包含:错误处理(defer),指针(*totalPrints),线程(go printer),通道(pingCh,pongCh),同步(sync.WaitGroup)。

因为多个线程可以共享进程的内存空间,因此要实现多个线程间的通信相对简单,大家能想到的最直接的办法就是设置一个全局变量,多个线程共享这个全局变量即可。但是当多个线程共享同一个变量(我们通常称之为“资源”)的时候,很有可能产生不可控的结果从而导致程序失效甚至崩溃。如果一个资源被多个线程竞争使用,那么我们通常称之为“临界资源”,对“临界资源”的访问需要加上保护,否则资源会处于“混乱”的状态。下面的例子演示了100个线程向同一个银行账户转账(转入1元钱)的场景,在这个例子中,银行账户就是一个临界资源,在没有保护的情况下我们很有可能会得到错误的结果。

package main

import (
	"fmt"
	"sync"
	"time"
)

type Account struct {
	balance int
}

func (a *Account) Deposit(money int) {

	// 计算存款后的余额
	newBalance := a.balance + money
	// 模拟受理存款业务需要0.01秒的时间
	time.Sleep(10 * time.Millisecond)
	// 修改账户余额
	a.balance = newBalance
}

func (a *Account) Balance() int {
	return a.balance
}

type AddMoneyTask struct {
	account *Account
	money   int
}

func (t *AddMoneyTask) Run() {
	t.account.Deposit(t.money)
}

func main() {
	account := &Account{}
	var tasks []*AddMoneyTask
	// 创建100个存款的goroutine向同一个账户中存钱
	for i := 0; i < 100; i++ {
		task := &AddMoneyTask{
			account: account,
			money:   1,
		}
		tasks = append(tasks, task)
	}
	// 等所有存款的goroutine都执行完毕
	var wg sync.WaitGroup
	wg.Add(100)
	for _, task := range tasks {
		go func() {
			task.Run()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("账户余额为: ¥%d元\n", account.Balance())
}

运行上面的程序,100个线程分别向账户中转入1元钱,结果大概率是1。之所以出现这种情况是因为我们没有对银行账户这个“临界资源”加以保护,所有线程有很大概率,都是在balance为0的时候执行到了newBalance := a.balance + money一句,得到的账户余额都是初始状态下的0,所以都是0上面做了+1的操作,因此得到了错误的结果;大家也可以尝试把time.Sleep(10 * time.Millisecond)这一句注释掉,以go的运行速度,在for进入下一个循环前,是有较大概率在启动下一个线程时上一个已经运行完毕,但多执行几次依然会出现小于100的情况。在这种情况下,“锁”就可以派上用场了。我们可以通过“锁”来保护“临界资源”,只有获得“锁”的线程才能访问“临界资源”,而其他没有得到“锁”的线程只能被阻塞起来,直到获得“锁”的线程释放了“锁”,其他线程才有机会获得“锁”,进而访问被保护的“临界资源”。下面的代码演示了如何使用“锁”来保护对银行账户的操作,从而获得正确的结果。

package main

import (
	"fmt"
	"sync"
	"time"
)

type Account struct {
	mu      sync.Mutex
	balance int
}

func (a *Account) Deposit(money int) {
	a.mu.Lock()
	defer a.mu.Unlock()

	// 计算存款后的余额
	newBalance := a.balance + money
	// 模拟受理存款业务需要0.01秒的时间
	time.Sleep(10 * time.Millisecond)
	// 修改账户余额
	a.balance = newBalance
}

func (a *Account) Balance() int {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.balance
}

type AddMoneyTask struct {
	account *Account
	money   int
}

func (t *AddMoneyTask) Run() {
	t.account.Deposit(t.money)
}

func main() {
	account := &Account{}
	var tasks []*AddMoneyTask
	// 创建100个存款的goroutine向同一个账户中存钱
	for i := 0; i < 100; i++ {
		task := &AddMoneyTask{
			account: account,
			money:   1,
		}
		tasks = append(tasks, task)
	}
	// 等所有存款的goroutine都执行完毕
	var wg sync.WaitGroup
	wg.Add(100)
	for _, task := range tasks {
		go func() {
			task.Run()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("账户余额为: ¥%d元\n", account.Balance())
}

在这个代码中,我们做了以下改动:

  • 使用sync.Mutex来保护Account的balance字段,避免并发访问时的数据竞争。

应用案例

例子:使用多进程对复杂任务进行“分而治之”。

我们来完成1~100000000求和的计算密集型任务,这个问题本身非常简单,有点循环的知识就能解决,代码如下所示。

package main

import (
	"fmt"
	"time"
)

func main() {
	var total int64 = 0
	numberList := make([]int64, 100000000)
	for i := range numberList {
		numberList[i] = int64(i) + 1
	}

	start := time.Now()
	for _, number := range numberList {
		total += number
	}
	fmt.Println(total)

	end := time.Now()
	fmt.Printf("Execution time: %.3fs\n", end.Sub(start).Seconds())
}

在上面的代码中

  • 使用var total int64 = 0来声明一个int64类型的变量,以避免在大量数据累加时溢出。
  • 使用make([]int64, 100000000)创建一个int64类型的切片来存储数列,切片的长度为100000000。
  • 使用for i := range numberList来初始化数列,numberList[i] = int64(i) + 1将每个元素设置为i + 1
  • 使用time.Now()来获取当前时间
  • 使用end.Sub(start).Seconds()来计算执行时间,并以秒为单位打印。

可以使用多线程来分而治之

package main

import (
	"fmt"
	"sync"
	"time"
)

func taskHandler(currList []int64, resultChan chan int64, wg *sync.WaitGroup) {
	defer wg.Done()
	var total int64 = 0
	for _, number := range currList {
		total += int64(number)
	}
	fmt.Println("task finish, total: ", total)
	resultChan <- total
}

func main() {
	var wg sync.WaitGroup
	numberList := make([]int64, 100000000)
	for i := range numberList {
		numberList[i] = int64(i) + 1
	}
	resultChan := make(chan int64, 8)

    // 计算执行时间
	start := time.Now()
	// 启动8个goroutine来处理数据
	for i := 0; i < 8; i++ {
		wg.Add(1)
		var start int64 = int64(i) * 12500000
		end := start + 12500000
		fmt.Println("task ", i)
		go taskHandler(numberList[start:end], resultChan, &wg)
	}

	// 等待所有goroutine完成
	wg.Wait()

	// 合并结果
	var total int64 = 0
	for i := 0; i < 8; i++ {
		total += <-resultChan
	}
	fmt.Println(total)

	//for i := 0; i < 8; i++ {
	//	<-resultChan
	//}
	end := time.Now()
	fmt.Printf("Execution time: %.5fs\n", end.Sub(start).Seconds())
}

比较两段代码的执行结果(在我目前使用的MacBook上,上面的代码需要大概0.27秒左右的时间,而下面的代码只需要0.14秒的时间,我们只是比较了运算的时间,不考虑列表创建及切片操作花费的时间),使用多进程后由于获得了更多的CPU执行时间以及更好的利用了CPU的多核特性,明显的减少了程序的执行时间,而且计算量越大效果越明显。

  • 28
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值