1. 操作系统发展历程
1.1 进程概念
进程是程序的一次运行过程,进程这个概念是比较抽象的,从来就没有标准统一的定义,进程主要包含三部分要素:程序、数据、进程控制块
- 程序:用于描述进程要完成的功能,是控制进程执行的指令集
- 数据:指程序在运行过程中所需的数据和工作区
- 进程控制块(Program Control Block,简称PCB):包含进程的描述信息和控制信息,是进程存在的唯一标识
但是最早的计算机操作系统只是一个 单进程操作系统 ,当时科学家们使用计算机都需要 “约钟” 来预定时间,这就意味着同一段时间内只有一个进程才能工作;并且操作系统全部资源都交给一个进程,这样的设定存在两个主要问题:
- 在时间上效率比较低下,CPU无法同时调度多个进程
- 在空间上资源利用效率低下,内存空间完全交由一个进程
为了解决上述问题,于是引入了 “多道技术” ,简单理解就是在时间层面和空间层面划分多道给多个不同的进程,并设定一种操作系统调度算法(时间片+I/O阻塞)让多个进程并发执行
此时的进程具有的特征:
- 动态性:进程是程序的一次运行过程,是存在生命周期的
- 并发性:进程可以与其他进程并发运行
- 独立性:进程是操作系统分配资源的基本单位
- 结构性:进程包含程序、数据、进程控制块
1.2 线程概念
实际上基于上述结构我们已经能够实现 “并发编程” 的需求,但是这种多进程编程模式的时空开销是比较大的,因为调度进程需要完成 PCB 数据结构的切换,还要发起系统调用操作硬件资源、还需要重新分配资源给不同的进程
💡 提示:试想一下你的电脑同时运行100个应用程序是不是非常卡顿!
因此诞生了 “多线程编程”,线程也被称为 “轻量级进程”,其实就是将一个进程的资源拆分多份分配给不同的线程,引入线程技术之后,操作系统调度的最基本单位就变成了线程!其调度模式简化如下图所示:
首先操作系统分配资源的基本单位仍然是进程,但是执行的基本单位是线程,线程向进程获取所需的运行资源,带来的好处就是节省了频繁创建销毁进程的开销
1.3 常见面试题——进程线程的区别
面试题:请你解释一下操作系统中进程和线程的区别是什么?
参考答案:
- 进程是操作系统分配资源的基本单位,线程是操作系统调度的基本单位
- 一个进程由一个或更多线程组成
- 进程之间相互独立,有不同的进程地址空间;同一进程内的多个线程共享进程的内存空间
- 进程调度切换比较大;线程调度切换开销比较小
1.4 协程概念
随着时代的发展,这种 “多线程” 编程模式切换的开销也逐渐变大,因此又诞生了 “协程” 机制,也被称为纤程、用户级线程、轻量级线程,但是协程不是简单地继续划分线程空间,其体系图简化如下:
此时用户态线程对于操作系统是不可见的,操作系统调度的基本单位仍然是线程,但是在应用程序中程序员(在Go语言中是编译器完成的工作)可以自定义调度算法,分配时间片轮转策略和I/O切换模拟并发执行多个协程任务单元,其调度的开销就大大降低了
- 避免了用户态和内核态的切换
- 在语言层面提供调度并发逻辑
2. Goroutine的基本使用
2.1 Goroutine基本语法
在Go语言之中,开发者无需关注创建的是内核级线程还是用户级线程,Go编译器底层有一套GPM调度模型会自适应,使用层面只需要一个关键字go
即可创建一个线程并发执行
现在我们就来体会Go的并发编程的魅力:
func printOdd() {var num = 1for num <= 5 {fmt.Println(num)time.Sleep(time.Second * 1)num += 2}
}func printEven() {var num = 0for num <= 5 {fmt.Println(num)time.Sleep(time.Second * 1)num += 2}
}func main() {// 线程1打印奇数go printOdd()// 线程2打印偶数go printEven()// 阻塞5s等待执行完毕time.Sleep(time.Second * 5)
}
程序运行结果:
程序运行分析:我们使用go
关键字启动了两个分线程,当前一共有三个线程在运行:main主线程、printOdd分线程、printEven分线程。其中主线程执行time.Sleep
的原因是:主线程使用go启动分线程后就会继续向下执行,当主线程结束运行则分线程也会强制终止,因此为了能看到效果必须在主线程中阻塞。两个分线程并发执行,调度逻辑下运行结果是不确定的
❗ 注意:主线程结束运行后分线程也会强制终止运行!
2.2 sync.WaitGroup
在上一小节中,我们使用time.Sleep
阻塞一定时间等待分线程运行完毕,这种实现方式并不优雅,因为我们不知道具体要阻塞多长时间,Go语言也提供了相关的并发组件可供使用,比如sync包中的WaitGroup(类似于Java当中的CountDownLatch),相关方法如下:
- Add(delta int):计数器+delta
- Done():计数器-1
- Wait():阻塞知道计数器变为0
package mainimport ("fmt""sync""time"
)// 声明全局计数器锁
var wg sync.WaitGroupfunc printOdd() {defer wg.Done() // 结束执行后计数器-1var num = 1for num <= 5 {fmt.Println(num)time.Sleep(time.Second * 1)num += 2}
}func printEven() {defer wg.Done() // 结束执行后计数器-1var num = 0for num <= 5 {fmt.Println(num)time.Sleep(time.Second * 1)num += 2}
}func main() {// 计数器+2wg.Add(2)// 启动分线程1go printOdd()// 启动分线程2go printEven()// 阻塞等待计数器变为0wg.Wait()
}
程序运行结果:
3. 线程安全问题
Go语言当中一样会存在线程安全问题,比如观察如下代码判断运行结果:
package mainimport ("fmt""sync"
)// 全局变量
var num = 0// 计数器锁
var wg sync.WaitGroupfunc foo() {defer wg.Done()for i := 0; i < 5000; i++ {num += 1}
}func bar() {defer wg.Done()for i := 0; i < 5000; i++ {num += 1}
}func main() {wg.Add(2)go foo()go bar()wg.Wait()fmt.Println("num: ", num)
}
程序运行结果:
实际上每次运行的结果都是不同的!有些没学过相关知识的小伙伴可能就比较好奇,难道不是10000吗?两个线程就算并发运行不影响最终结果呀!这里原因不过多赘述,详见我另一篇博客:【Java多线程】线程安全问题_java多线程 线程安全问题-CSDN博客
这里直接提供解决方案:需要借助到Go当中的互斥锁sync.Mutex
- Lock():加锁
- Unlock():释放锁
解决代码:
package mainimport ("fmt""sync"
)// 全局变量
var num = 0// 计数器锁
var wg sync.WaitGroup// 互斥锁
var lock sync.Mutexfunc foo() {defer wg.Done()lock.Lock()for i := 0; i < 5000; i++ {num += 1}lock.Unlock()
}func bar() {defer wg.Done()lock.Lock()for i := 0; i < 5000; i++ {num += 1}lock.Unlock()
}func main() {wg.Add(2)go foo()go bar()wg.Wait()fmt.Println(num)
}
程序运行结果:
4. channel管道
Go语言的并发设计哲学就是 不要依赖共享内存进行通信 ,因此提供了chan
这种管道数据类型,用于在多个goroutine之间进行数据通信
channel是一种类似于阻塞队列的数据结构,遵循先进先出(FIFO)的规则,有了channel就可以实现 “生产者消费者模型”
4.1 声明创建管道
channel用于存放数据,因此定义管道时不仅需要chan关键字声明管道,还需要声明内部数据的类型
声明语法格式:var 管道实例 chan 数据类型
管道是一个引用类型,因此需要使用make
函数初始化
创建语法格式:var 管道实例 = make(chan 数据类型, 数量)
4.2 channel基本操作
- 给管道插入值:
chan变量 <- value
- 从管道中取值:
<-chan变量
4.2.1 案例一
func main() {var ch1 = make(chan int, 10)// 插入值ch1 <- 1ch1 <- 2ch1 <- 3// 取值fmt.Println(<-ch1)fmt.Println(<-ch1)fmt.Println(<-ch1)
}
程序运行结果:
4.2.2 案例二
type Student struct {Name stringAge int
}func main() {// 案例2var ch2 = make(chan interface{}, 10)// 插入值ch2 <- 100ch2 <- truech2 <- Student{Name: "rice", Age: 21}// 取值fmt.Println(<-ch2)fmt.Println(<-ch2)fmt.Println(<-ch2)
}
程序运行结果:
4.2.3 案例三
func main() {// 案例3var ch3 = make(chan int, 3)x := 10ch3 <- xx = 20fmt.Println(<-ch3)var ch4 = make(chan *int, 3)y := 10ch4 <- &yy = 20fmt.Println(*(<-ch4))
}
程序运行结果:
4.3 channel底层结构
chan是一种引用数据类型,其源码在src/runtime/chan.go
当中:
其对应内存结构图如下:
- qcount:当前队列剩余元素个数
- dataqsize:环形队列长度
- buf:数据缓冲区指针
- sendx:指向下一个插入位置的索引
- recvx:指向下一个取出位置的索引
- sendq:生产者队列
- recvq:消费者队列
易错点:
func foo(ch chan int) {ch <- 100
}func main() {// 引用类型易错var ch = make(chan int, 3)ch <- 10ch <- 20var ch2 = chfmt.Println(<-ch2)fmt.Println(<-ch)var ch3 = make(chan int, 3)foo(ch3)fmt.Println(<-ch3)
}
程序运行结果:
💡 易错点:channel类型在拷贝过程中虽然结构当中有基本类型比如qcount、dataqsize等,但是可以认为操作的都是同一个channel类型
4.4 channel的关闭与循环
当向管道中发送完数据之后,需要使用close
内置函数进行关闭,关闭后的管道只可以读不可写,如果不进行关闭,管道会一直等待写入最后造成deadlock死锁!
func main() {var ch = make(chan int, 3)ch <- 1ch <- 2ch <- 3for value := range ch {fmt.Println(value)}
}
程序运行结果:
因为循环channel的数据时,没有关闭导致最后一直阻塞等待输入!
解决方案:在写入完毕后执行close(ch)
即可
func main() {var ch = make(chan int, 3)ch <- 1ch <- 2ch <- 3close(ch)for value := range ch {fmt.Println(value)}
}
程序运行结果:
4.5 生产者消费者模型
生产者消费者模型代码:
package mainimport ("fmt""sync"
)// producer 生产者
func producer(ch chan int) {defer wg.Done()defer close(ch) // 写入完毕后关闭for i := 0; i < 100; i++ {fmt.Println("producer ", i)ch <- i}
}// consumer 消费者
func consumer(ch chan int) {defer wg.Done()for true {val, ok := <-chif ok {// 没有关闭fmt.Println("consumer ", val)} else {break}}
}// 计数器锁
var wg sync.WaitGroupfunc main() {wg.Add(2)var ch = make(chan int, 100)go consumer(ch)go producer(ch)wg.Wait() // 阻塞等待fmt.Println("process end...")
}
💡 注意:
- 生产者写入数据完毕后要关闭管道:clsoe(ch)
- 消费者可以通过多重返回值val, ok := <-ch判断管道是否关闭,ok为false则已经关闭