chaoz的杂货铺

生命有息、学无止境、折腾不止

0%

2022-Golang-GMP模型

golang并发模型

GMP模型

G:G是Goroutine的缩写,在这里就是Goroutine的控制结构,是对Goroutine的抽象。其中包括执行的函数指令及参数;G保存的任务对象;线程上下文切换,现场保护和现场恢复需要的寄存器(SP、IP)等信息。
M:表示操作系统线程也可以成为内核线程,由操作系统调度以及管理,调度器最多可以创建 10000 个线程,在 Go 语言中使用 runtime.m 结构表示。
P:逻辑处理器,但不代表真正的CPU的数量,真正决定并发程度的是P,初始化的时候一般会去读取GOMAXPROCS对应的值,如果没有显示设置,则会读取默认值,在Go1.5之后GOMAXPROCS被默认设置可用的核数,而之前则默认为1,在 Go 语言中使用 runtime.p 结构表示。它指的是一种可以承载若干个 G、且能够使这些 G 适时地与 M 进行对接,并得到真正运行的中介。正因为有了 P 的存在,G 和 M 才能够进行灵活、高效的配对,从而实现强大的并发编程模型。

在本地池列表中的每个本地池都包含了三个字段(或者说组件),它们是:存储私有临时对象的字段private、代表了共享临时对象列表的字段shared,以及一个sync.Mutex类型的嵌入字段。

单进程时代不需要调度器

我们知道,一切的软件都是跑在操作系统上,真正用来干活 (计算) 的是 CPU。早期的操作系统每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程,就是 “单进程时代”

一切的程序只能串行发生。

20220222165750

早期的单进程操作系统,面临 2 个问题:

  1. 单一的执行流程,计算机只能一个任务一个任务处理。
  2. 进程阻塞所带来的 CPU 时间浪费。

那么能不能有多个进程来宏观一起来执行多个任务呢?

后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把 CPU 利用起来,CPU 就不浪费了。

多进程 / 线程时代有了调度器需求

20220222165846

在多进程 / 多线程的操作系统中,就解决了阻塞的问题,因为一个进程阻塞 cpu 可以立刻切换到其他进程中去执行,而且调度 cpu 的算法可以保证在运行的进程都可以被分配到 cpu 的运行时间片。这样从宏观来看,似乎多个进程是在同时被运行。

但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU 虽然利用起来了,但如果进程过多,CPU 有很大的一部分都被用来进行进程调度了。

怎么才能提高 CPU 的利用率呢?

对于 Linux 操作系统来讲,cpu 对进程的态度和线程的态度是一样的。

20220222165911

很明显,CPU 调度切换的是进程和线程。尽管线程看起来很美好,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等。

协程来提高 CPU 利用率

补充知识:用户态和内核态

20220224153742

从图上我们可以看出来通过系统调用将Linux整个体系分为用户态和内核态(或者说内核空间和用户空间)。那内核态到底是什么呢?其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。

用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。

系统调用

系统调用时操作系统的最小功能单位。根据不同的应用场景,不同的Linux发行版本提供的系统调用数量也不尽相同,大致在240-350之间。这些系统调用组成了用户态跟内核态交互的基本接口,例如:用户态想要申请一块20K大小的动态内存,就需要brk系统调用,将数据段指针向下偏移,如果用户态多处申请20K动态内存,同时又释放呢?这个内存的管理就变得非常的复杂。

库函数

库函数就是屏蔽这些复杂的底层实现细节,减轻程序员的负担,从而更加关注上层的逻辑实现。它对系统调用进行封装,提供简单的基本接口给用户,这样增强了程序的灵活性,当然对于简单的接口,也可以直接使用系统调用访问资源,例如:open(),write(),read()等等。库函数根据不同的标准也有不同的版本,例如:glibc库,posix库等。

shell

shell顾名思义,就是外壳的意思。就好像把内核包裹起来的外壳。它是一种特殊的应用程序,俗称命令行。为了方便用户和系统交互,一般一个shell对应一个终端,呈现给用户交互窗口。当然shell也是编程的,它有标准的shell语法,符合其语法的文本叫shell脚本。很多人都会用shell脚本实现一些常用的功能,可以提高工作效率。

用户态到内核态怎样切换?

Linux的设计的初衷:给不同的操作给与不同的“权限”。Linux操作系统就将权限等级分为了2个等级,分别就是内核态和用户态。
用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态也可以运行在内核态,那它们之间肯定存在用户态和内核态切换的过程。打一个比方:C库接口malloc申请动态内存,malloc的实现内部最终还是会调用brk()或者mmap()系统调用来分配内存。

从用户态到内核态切换可以通过三种方式:
系统调用,其实系统调用本身就是中断,但是软件中断,跟硬中断不同。
异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。例如:缺页异常。
外设中断:当外设完成用户的请求时,会向CPU发送中断信号。

正文

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存 (进程虚拟内存会占用 4GB [32 位操作系统], 而线程也要大约 4MB)。

大量的进程 / 线程出现了新的问题

  • 高内存占用
  • 调度的高消耗 CPU

好了,然后工程师们就发现,其实一个线程分为 “内核态 “线程和” 用户态 “线程。

一个 “用户态线程” 必须要绑定一个 “内核态线程”,但是 CPU 并不知道有 “用户态线程” 的存在,它只知道它运行的是一个 “内核态线程”(Linux 的 PCB 进程控制块)。

20220222165927

我们再去细化去分类一下,内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”.

20220222165936

既然一个协程 (co-routine) 可以绑定一个线程 (thread),那么能不能多个协程 (co-routine) 绑定一个或者多个线程 (thread) 上呢。

有 三种协程和线程的映射关系:

  1. N:1 关系

    N 个协程绑定 1 个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上

    缺点:

    • 某个程序用不了硬件的多核加速能力
    • 一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。

20220222165945

  1. 1:1 关系

    1 个协程绑定 1 个线程,这种最容易实现。协程的调度都由 CPU 完成了,不存在 N:1 缺点,

    缺点:

    • 协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。

20220222165955

  1. M:N 关系

    M 个协程绑定 N 个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。

20220222170020

协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

Go 语言的协程 goroutine

Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go 中,协程被称为 goroutine,它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 goroutine 分配。

Goroutine 特点:

  • 占用内存更小(几 kb)
  • 调度更灵活 (runtime 调度)

https://golang.design/go-questions/sched/what-is/

被废弃的 goroutine 调度器

Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题,所以使用 4 年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的?

20220222170038

来看看被废弃的 golang 调度器是如何实现的?

20220222170048

M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。

老调度器有几个缺点:

  • 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
  • M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
  • 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

Goroutine 调度器的 GMP 模型的设计思想

新调度器中,除了 M (thread) 和 G (goroutine),又引进了 P (Processor)。

20220222170105

Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

GMP 模型

在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。

20220222170121

  • 全局队列(Global Queue):存放等待运行的 G。
  • P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,执行负载均衡,则会把本地队列中的前一半的 G 以及新创建的 G 一起打乱后 移动到全局队列(实际上还有优先级策略)。 ps:(负载有本地队列p负载,又有一个全局P的负载)
  • P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
  • M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他尾部的一半 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。

有关 P 和 M 的个数问题

  1. P 的数量:
  • 由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
  1. M 的数量:
  • go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
  • runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
  • 一个 M 阻塞了,会创建新的 M。

M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

P 和 M 何时会被创建

  1. P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
  2. M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

调度器的设计策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

  • work stealing 机制

    当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。

  • hand off 机制

    当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。

利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。

抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。

go func () 调度流程

20220222170134

从上图我们可以分析出几个结论:

  1. 我们通过 go func () 来创建一个 goroutine;

  2. 有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;

  3. G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;

  4. 一个 M 调度 G 执行的过程是一个循环机制;

  5. 当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;

  6. 当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。

调度器的生命周期

20220222170143

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了

G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0

调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。

思考题

gmp当一个g堵塞时,g、m、p会发生什么

当G因系统调用(syscall)阻塞时会阻塞M,如果当前M上的P有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),此时P会和M解绑即hand off,并寻找新的idle的M,若没有idle的M就会新建一个M。当系统调用完成后,G会重新尝试获取一个idle的P进入它的Local队列恢复执行,如果没有idle的P,G会被标记为runnable加入到Global队列。

当G因channel或者network I/O阻塞时,不会阻塞M,M会寻找其他runnable的G;当阻塞的G恢复后会重新进入runnable进入P队列等待执行

当G因系统调用(syscall)阻塞时会阻塞M时,runtime 会把这个线程 M 从 P 中摘除 (detach),阻塞的G运行结束,G会重新尝试获取一个idle的P进入它的Local队列恢复执行。在这个过程中 这个G用的是谁的栈空间?是同一个G0的栈空间吗?

go调度中阻塞都有那些方式?

GMP模型的阻塞可能发生在下面几种情况:

I/O,select
block on syscall
channel
等待锁
runtime.Gosched()

用户态阻塞

channel操作
network I/O

系统调用阻塞

block on syscall

Go调度可能的情况

20220226170011

go语言的GMP模型,全局队列中的G会不会饥饿,为什么?P的数量是多少?能修改吗?M的数量是多少?G的数量?P 和 M 何时会被创建?

正常情况下,不会。M最大默认时10000,内核很难支持那么多线程数,所以在这个限制忽略的前提下考虑问题的话,不会有问题。运行中的M与P是一一对应的关系,如果M阻塞,与这个M关联的P会从尝试直接调用一个空闲的M(休眠状态),如果获取不到局创建一个新的,然后M获取P队列,从中取一个G去运行。

G 的数量:理论上没有数量上限限制的。查看当前G的数量可以使用runtime. NumGoroutine()

P 的数量:由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。

M 的数量:go 程序启动时,会设置 M 的最大数量,默认 10000. M 的数量是不可控的 M 的数量>=p的数量 M的数量在运行时 动态变化。runtime/debug 中的 SetMaxThreads 函数可以设置最大数。

系统中最多有 GOMAXPROCS 个自旋的线程

阻塞和饥饿的区别?
死锁: 可以认为是两个线程或进程在请求对方占有的资源。
饿死:一个线程在无限地等待另外两个或多个线程相互传递使用并且不会释放的资源。

在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

某个P空了,一直从其他P偷G,这个过程持续了很长的时间,会不会导致全局队列中的G饥饿?

自旋状态,go中有电镀机制,偷不到会去全局中拿

如果大量的G发生了系统调度/阻塞,M达到了最大数量,此时会阻塞么?

P和M的数量一定是1:1吗?如果一个G阻塞了会怎么样?

运行中M和P是一对一的。P上一定对应一个M。M的数量不可控制,是动态变化的。

M长时间休眠会怎么样?不受影响还是被GC了?

G的状态

20220224171736

P的状态

20220224171803

如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行,偷的逻辑是什么,什么情况下偷不到?

全局队列已经没有 G,那 m 就要执行 work stealing (偷取):从其他有 G 的 P 哪里偷取一半 G 过来,放到自己的 P 本地队列。

什么情况下 M 会进入自旋的状态?

M 是系统线程。为了保证自己不被释放,所以自旋。这样一旦有 G 需要处理,M 可以直接使用,不需要再创建。M 自旋表示此时没有 G 需要处理
自旋场景:
规定:在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。

20220224195049

假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。

scheduler调度器的实现细节

分为OS调度器和Go调度器。
简单说:OS调度主要有三种。go选择了***模型。
20220224172419

调度是个啥:

既然有了用户态的代表 Goroutine,操作系统又看不到他。必然需要有某个东西去管理他,才能更好的运作起来。
20220224172448

Go scheduler 的主要功能是针对在处理器上运行的 OS 线程分发可运行的 Goroutine
20220224172457
说下 GMP

Go调度器的设计策略

work stealing 机制、hand off 机制、利用并行、抢占、全局 G 队列

调度流程:

推荐:https://www.topgoer.cn/docs/golang/chapter09-11

https://network.51cto.com/article/649707.html
https://juejin.cn/post/6886321367604527112

http://dockone.io/article/10491

P空了,只能从全局P中取数据,取多少个p,怎么计算的?

M2 尝试从全局队列 (简称 “GQ”) 取一批 G 放到 P2 的本地队列(函数:findrunnable())。M2 从全局队列取的 G 数量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

至少从全局队列取 1 个 g,但每次不要从全局队列移动太多的 g 到 p 本地队列,给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡。

当有多个运行中的M,多个自旋的M,多个休眠的M。此时刚好运行中的M中的G发生了系统调用,他后面的G是转移到自旋M上还是休眠M上?

自旋线程的最大限制不能超过GOMAXPROCS,多出的线程休眠(⻓时间休眠等待GC回收销毁)

M 与 P 绑定了,但是 P 没有 G 但为运行状态的线程,不断寻找 G

Go 语言抢占式调度

在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

同时启了一万个g,如何调度的?

一个本地队列最多多少个g

P关联了的本地可运行G的队列(也称为LRQ),最多可存放256个G。

描述 scheduler 的初始化过程

https://golang.design/go-questions/sched/init/
【阿波张 goroutine 调度器初始化】https://mp.weixin.qq.com/s/W9D4Sl-6jYfcpczzdPfByQ

主 goroutine 如何创建

https://golang.design/go-questions/sched/main-goroutine/
【欧神 关键字 go】https://github.com/changkun/go-under-the-hood/blob/master/book/zh-cn/part3compile/ch11keyword/go.md

【欧神 Go scheduler】https://github.com/changkun/go-under-the-hood/blob/master/book/zh-cn/part2runtime/ch06sched/init.md

g0 栈何用户栈如何切换

https://golang.design/go-questions/sched/g0-stack/
【阿波张 Go语言调度器之调度 main 】https://mp.weixin.qq.com/s/8eJm5hjwKXya85VnT4y8Cw

schedule 循环如何启动

https://golang.design/go-questions/sched/sched-loop-boot/
【欧神 调度循环】https://github.com/changkun/go-under-the-hood/blob/master/book/zh-cn/part2runtime/ch06sched/exec.md

【go 语言核心编程技术 调度器系列】https://mp.weixin.qq.com/s/8eJm5hjwKXya85VnT4y8Cw

【曹大 Go plan9 汇编】https://github.com/cch123/asmshare/blob/master/layout.md

【Go 语言高级编程】https://chai2010.cn/advanced-go-programming-book/ch3-asm/readme.html

goroutine 如何退出

https://golang.design/go-questions/sched/goroutine-exit/
【阿波张 非 main goroutine 的退出及调度循环】https://mp.weixin.qq.com/s/XttP9q7-PO7VXhskaBzGqA

schedule 循环如何运转

https://golang.design/go-questions/sched/sched-loop-exec/
【阿波张 非 main goroutine 的退出及调度循环】https://mp.weixin.qq.com/s/XttP9q7-PO7VXhskaBzGqA

M 如何找工作

https://golang.design/go-questions/sched/m-worker/
【阿波张 Goroutine 调度策略】https://mp.weixin.qq.com/s/2objs5JrlnKnwFbF4a2z2g

sysmon 后台监控线程做了什么

https://golang.design/go-questions/sched/sysmon/

文章参考

https://www.cnblogs.com/flippedxyy/p/15558743.html
https://austsxk.com/2020/08/18/Golang%E8%B0%83%E5%BA%A6%E5%99%A8GMP%E5%8E%9F%E7%90%86%E4%B8%8E%E8%B0%83%E5%BA%A6%E5%85%A8%E5%88%86%E6%9E%90/
https://blog.csdn.net/slphahaha/article/details/103515573

https://www.cnblogs.com/jiujuan/p/12735559.html

https://blog.csdn.net/GugeMichael/article/details/73381484

喜欢这篇文章?打赏一下作者吧!

欢迎关注我的其它发布渠道