导读:阿里云容器服务团队结合多年 Kubernetes 产品与客户支持经验,对 Kube-scheduler 进行了大量优化和扩展,逐步使其在不同场景下依然能稳定、高效地调度各种类型的复杂工作负载。《进击的 Kubernetes 调度系统》系列文章将把我们的经验、技术思考和实现细节全面地展现给 Kubernetes 用户和开发者,期望帮助大家更好地了解 Kubernetes 调度系统的强大能力和未来发展方向。
Kubernetes 已经成为目前事实标准上的容器集群管理平台。它为容器化应用提供了自动化部署、运维、资源调度等全生命周期管理功能。经过 3 年多的快速发展,Kubernetes 在稳定性、扩展性和规模化方面都有了长足进步。尤其是 Kubernetes 控制平面的核心组件日臻成熟。而作为决定容器能否在集群中运行的调度器 Kube-scheduler,更是由于长久以来表现稳定,且已能满足大部分 Pod 调度场景,逐渐不被开发人员特别关注。
伴随着 Kubernetes 在公有云以及企业内部 IT 系统中广泛应用,越来越多的开发人员尝试使用 Kubernetes 运行和管理 Web 应用和微服务以外的工作负载。典型场景包括机器学习和深度学习训练任务,高性能计算作业,基因计算工作流,甚至是传统的大数据处理任务。此外,Kubernetes 集群所管理的资源类型也愈加丰富,不仅有 GPU,TPU 和 FPGA,RDMA 高性能网络,还有针对领域任务的各种定制加速器,比如各种 AI 芯片,NPU,视频编解码器等。开发人员希望在 Kubernetes 集群中能像使用 CPU 内存那样简单地声明和使用各种异构设备。
总的来说,围绕 Kubernetes 构建一个容器服务平台,统一管理各种新算力资源,弹性运行多种类型应用,最终把服务按需交付到各种运行环境(包括公共云、数据中心、边缘节点,甚至是终端设备),已然成为云原生技术的发展趋势。
首先,让我们来了解一下 Kubernetes 社区都有过哪些提升调度器扩展能力的方案。
要统一管理和调度异构资源与更多复杂工作负载类型,首先面对挑战的就是 Kube-scheduler。在 Kubernetes 社区里关于提升调度器扩展能力的讨论一直不断。sig-scheduling 给出的判断是,越多功能加入,使得调度器代码量庞大,逻辑复杂,导致维护的难度越来越大,很多 bug 难以发现、处理。而对于使用了自定义调度的用户来说,跟上每一次调度器功能更新,都充满挑战。
在阿里云,我们的用户遇到了同样的挑战。Kubernetes 原生调度器循环处理单个 Pod 容器的固定调度逻辑,无法及时的支持不同用户在不同场景的需求。所以针对特定的场景,我们会基于原生的 Kube-scheduler 扩展自己场景的调度策略。
最初对于 Kube-scheduler 进行扩展的方式主要有两种,一种是调度器扩展(Scheduler Extender), 另外一种是多调度器(Multiple schedulers)。接下来我们对这两种方式分别进行介绍和对比。
社区最初提供的方案是通过 Extender 的形式来扩展 scheduler。Extender 是外部服务,支持 Filter、Preempt、Prioritize 和 Bind 的扩展,scheduler 运行到相应阶段时,通过调用 Extender 注册的 webhook 来运行扩展的逻辑,影响调度流程中各阶段的决策结果。
以 Filter 阶段举例,执行过程会经过 2 个阶段:
我们可以发现 Extender 存在以下问题:
基于以上介绍,Extender 的方式在集群规模较小,调度效率要求不高的情况下,是一个灵活可用的扩展方案,但是在正常生产环境的大型集群中,Extender 无法支持高吞吐量,性能较差。
Scheduler 在 Kubernetes 集群中其实类似于一个特殊的 Controller,通过监听 Pod 和 Node 的信息,给 Pod 挑选最佳的节点,更新 Pod 的 spec.NodeName 的信息来将调度结果同步到节点。所以对于部分有特殊的调度需求的用户,有些开发者通过自研 Custom Scheduler 来完成以上的流程,然后通过和 default scheduler 同时部署的方式,来支持自己特殊的调度需求。
Custom Scheduler 会存在一下问题:
Scheduler Extender 的性能较差可是维护成本较小,Custom Scheduler 的研发和维护的成本特别高但是性能较好,这种情况是开发者面临这种两难处境。这时候 Kubernetes Scheduling Framework V2 横空出世,给我们带来鱼和熊掌可以兼得的方案。
社区也逐渐的发现开发者所面临的困境,为了解决如上问题,使 Kube-scheduler 扩展性更好、代码更简洁,社区从 Kubernetes 1.16 版本开始, 构建了一种新的调度框架 Kubernetes Scheduling Framework 的机制。
Scheduling Framework 在原有的调度流程中, 定义了丰富扩展点接口,开发者可以通过实现扩展点所定义的接口来实现插件,将插件注册到扩展点。Scheduling Framework 在执行调度流程时,运行到相应的扩展点时,会调用用户注册的插件,影响调度决策的结果。通过这种方式来将用户的调度逻辑集成到 Scheduling Framework 中。
Framework 的调度流程是分为两个阶段 scheduling cycle 和 binding cycle。
scheduling cycle 是调度的核心流程,主要的工作是进行调度决策,挑选出唯一的节点。
// QueueSortPlugin is an interface that must be implemented by "QueueSort" plugins.
// These plugins are used to sort pods in the scheduling queue. Only one queue sort
// plugin may be enabled at a time.
type QueueSortPlugin interface {
Plugin
// Less are used to sort pods in the scheduling queue.
Less(*PodInfo, *PodInfo) bool
}
Scheduler 中的优先级队列是通过 heap 实现的,我们可以在 QueueSortPlugin 中定义 heap 的比较函数来决定的排序结构。但是需要注意的是 heap 的比较函数在同一时刻只有一个,所以 QueueSort 插件只能 Enable 一个,如果用户 Enable 了 2 个则调度器启动时会报错退出。下面是默认的比较函数,可供参考。
// Less is the function used by the activeQ heap algorithm to sort pods.
// It sorts pods based on their priority. When priorities are equal, it uses
// PodQueueInfo.timestamp.
func (pl *PrioritySort) Less(pInfo1, pInfo2 *framework.QueuedPodInfo) bool {
p1 := pod.GetPodPriority(pInfo1.Pod)
p2 := pod.GetPodPriority(pInfo2.Pod)
return (p1 > p2) || (p1 == p2 && pInfo1.Timestamp.Before(pInfo2.Timestamp))
}
PreFilter 在 scheduling cycle 开始时就被调用,只有当所有的 PreFilter 插件都返回 success 时,才能进入下一个阶段,否则 Pod 将会被拒绝掉,标识此次调度流程失败。PreFilter 类似于调度流程启动之前的预处理,可以对 Pod 的信息进行加工。同时 PreFilter 也可以进行一些预置条件的检查,去检查一些集群维度的条件,判断否满足 pod 的要求。
Filter 插件是 scheduler v1 版本中的 Predicate 的逻辑,用来过滤掉不满足 Pod 调度要求的节点。为了提升效率,Filter 的执行顺序可以被配置,这样用户就可以将可以过滤掉大量节点的 Filter 策略放到前边执行,从而减少后边 Filter 策略执行的次数,例如我们可以把 NodeSelector 的 Filter 放到第一个,从而过滤掉大量的节点。Node 节点执行 Filter 策略是并发执行的,所以在同一调度周期中多次调用过滤器。
新的 PostFilter 的接口定义在 1.19 的版本会发布,主要是用于处理当 Pod 在 Filter 阶段失败后的操作,例如抢占,Autoscale 触发等行为。
PreScore 在之前版本称为 PostFilter,现在修改为 PreScore,主要用于在 Score 之前进行一些信息生成。此处会获取到通过 Filter 阶段的节点列表,我们也可以在此处进行一些信息预处理或者生成一些日志或者监控信息。
Scoring 扩展点是 scheduler v1 版本中 Priority 的逻辑,目的是为了基于 Filter 过滤后的剩余节点,根据 Scoring 扩展点定义的策略挑选出最优的节点。Scoring 扩展点分为两个阶段:
Reserve 扩展点是 scheduler v1 版本的 assume 的操作,此处会对调度结果进行缓存,如果在后边的阶段发生了错误或者失败的情况,会直接进入 Unreserve 阶段,进行数据回滚。
Permit 扩展点是 framework v2 版本引入的新功能,当 Pod 在 Reserve 阶段完成资源预留之后,Bind 操作之前,开发者可以定义自己的策略在 Permit 节点进行拦截,根据条件对经过此阶段的 Pod 进行 allow、reject 和 wait 的 3 种操作。allow 表示 pod 允许通过 Permit 阶段。reject 表示 pod 被 Permit 阶段拒绝,则 Pod 调度失败。wait 表示将 Pod 处于等待状态,开发者可以设置超时时间。
binding cycle 需要调用 apiserver 的接口,耗时较长,为了提高调度的效率,需要异步执行,所以此阶段线程不安全。
Bind 扩展点是 scheduler v1 版本中的 Bind 操作,会调用 apiserver 提供的接口,将 pod 绑定到对应的节点上。
开发者可以在 PreBind 和 PostBind 分别在 Bind 操作前后执行,这两个阶段可以进行一些数据信息的获取和更新。
UnReserve 扩展点的功能是用于清理到 Reserve 阶段的的缓存,回滚到初始的状态。当前版本 UnReserve 与 Reserve 是分开定义的,未来会将 UnReserve 与 Reserve 统一到一起,即要求开发者在实现 Reserve 同时需要定义 UnReserve,保证数据能够有效的清理,避免留下脏数据。
Kubernetes 负责 Kube-scheduler 的小组 sig-scheduling 为了更好的管理调度相关的 Plugin,新建了项目 scheduler-plugins 来方便用户管理不同的插件,用户可以直接基于这个项目来定义自己的插件。接下来我们以其中的 Qos 的插件来为例,演示是如何开发自己的插件。
Qos 的插件主要基于 Pod 的 QoS(Quality of Service) class 来实现的,目的是为了实现调度过程中如果 Pod 的优先级相同时,根据 Pod 的 Qos 来决定调度顺序,调度顺序是: 1. Guaranteed (requests == limits) 2. Burstable (requests < limits) 3. BestEffort (requests and limits not set)
首先插件要定义插件的对象和构造函数:
// QoSSort is a plugin that implements QoS class based sorting.
type Sort struct{}
// New initializes a new plugin and returns it.
func New(_ *runtime.Unknown, _ framework.FrameworkHandle) (framework.Plugin, error) {
return &Sort{}, nil
}
然后,根据我们插件要对应的 extention point 来实现对应的接口,Qos 是作用于 QueueSort 的部分,所以我们要实现 QueueSort 接口的函数。如下所示,QueueSortPlugin 接口只定义了一个函数 Less,所以我们实现这个函数即可。
// QueueSortPlugin is an interface that must be implemented by "QueueSort" plugins.
// These plugins are used to sort pods in the scheduling queue. Only one queue sort
// plugin may be enabled at a time.
type QueueSortPlugin interface {
Plugin
// Less are used to sort pods in the scheduling queue.
Less(*PodInfo, *PodInfo) bool
}
实现的函数如下。默认的 default QueueSort 在比较的时候,首先比较优先级,然后再比较 pod 的 timestamp。我们重新定义了 Less 函数,在优先级相同的情况下,通过比较 Qos 来决定优先级。
// Less is the function used by the activeQ heap algorithm to sort pods.
// It sorts pods based on their priority. When priorities are equal, it uses
// PodInfo.timestamp.
func (*Sort) Less(pInfo1, pInfo2 *framework.PodInfo) bool {
p1 := pod.GetPodPriority(pInfo1.Pod)
p2 := pod.GetPodPriority(pInfo2.Pod)
return (p1 > p2) || (p1 == p2 && compQOS(pInfo1.Pod, pInfo2.Pod))
}
func compQOS(p1, p2 *v1.Pod) bool {
p1QOS, p2QOS := v1qos.GetPodQOS(p1), v1qos.GetPodQOS(p2)
if p1QOS == v1.PodQOSGuaranteed {
return true
} else if p1QOS == v1.PodQOSBurstable {
return p2QOS != v1.PodQOSGuaranteed
} else {
return p2QOS == v1.PodQOSBestEffort
}
}
我们在启动的 main 函数中注册自己定义的插件和相应的构造函数:
// cmd/main.go
func main() {
rand.Seed(time.Now().UnixNano())
command := app.NewSchedulerCommand(
app.WithPlugin(qos.Name, qos.New),
)
if err := command.Execute(); err != nil {
os.Exit(1)
}
}
$ make
kube-scheduler 启动时,配置 ./manifests/qos/scheduler-config.yaml 中 kubeconfig 的路径,启动时传入集群的 kubeconfig 文件以及插件的配置文件即可。
$ bin/kube-scheduler --kubeconfig=scheduler.conf --config=./manifests/qos/scheduler-config.yaml
至此,相信大家已经通过我们的介绍和示例了解了 Kubernetes Scheduling Framework 的架构和开发方法。
Kubernetes目前已经广泛的应用于在线服务编排,为了提升集群的的利用率和运行效率,我们希望将Kubernetes作为一个统一的管理平台来管理在线服务和离线作业。默认的调度器是以Pod为调度单元进行依次调度,不会考虑Pod之间的相互关系。但是很多数据计算类的离线作业具有组合调度的特点,即要求所有的子任务都能够成功创建后,整个作业才能正常运行。如果只有部分子任务启动的话,启动的子任务将持续等待剩余的子任务被调度。这正是Gang Scheduling的场景。
如下图所示,JobA需要4个Pod同时启动,才能正常运行。Kube-scheduler依次调度3个Pod并创建成功。到第4个Pod时,集群资源不足,则JobA的3个Pod处于空等的状态。但是它们已经占用了部分资源,如果第4个Pod不能及时启动的话,整个JobA无法成功运行,更糟糕的是导致集群资源浪费。
如果出现更坏的情况的话,如下图所示,集群其他的资源刚好被JobB的3个Pod所占用,同时在等待JobB的第4个Pod创建,此时整个集群就出现了死锁。
社区目前有Kube-batch以及基于Kube-batch衍生的Volcano 2个项目来解决上文中提到的痛点。实现的方式是通过开发新的调度器将Scheduler中的调度单元从Pod修改为PodGroup,以组的形式进行调度。使用方式是如果需要Coscheduling功能的Pod走新的调度器,其他的例如在线服务的Pod走Kube-scheduler进行调度。
这些方案虽然能够解决Coscheduling的问题,但是同样引入了新的问题。如大家所知,对于同一集群资源,调度器需要中心化。但如果同时存在两个调度器的话,有可能会出现决策冲突,例如分别将同一块资源分配给两个不同的Pod,导致某个Pod调度到节点后因为资源不足,导致无法创建的问题。解决的方式只能是通过标签的形式将节点强行的划分开来,或者部署多个集群。这种方式通过同一个Kubernetes集群来同时运行在线服务和离线作业,势必会导致整体集群资源的浪费以及运维成本的增加。再者,Volcano运行需要启动定制的MutatingAdmissionWebhook和ValidatingAdmissionWebhook。这些Webhooks本身存在单点风险,一旦出现故障,将影响集群内所有pod的创建。另外,多运行一套调度器,本身也会带来维护上的复杂性,以及与上游Kube-scheduler接口兼容上的不确定性。
本系列第一篇《进击的Kubernetes调度系统 (一):Scheduling Framework》介绍了Kubernetes Scheduling Framework的架构原理和开发方法。在此基础上,我们扩展实现了Coscheduling调度插件,帮助Kubernetes原生调度器支持批作业调度,同时避免上述方案存在的问题。Scheduling framework的内容在前一篇文章详细介绍,欢迎大家翻阅。
Kubernetes负责Kube-scheduler的小组sig-scheduler为了更好的管理调度相关的Plugin,新建了项目scheduler-plugins管理不同场景的Plugin。我们基于scheduling framework实现的Coscheduling Plugin成为该项目的第一个官方插件,下面我将详细的介绍Coscheduling Plugin的实现和使用方式。
我们通过label的形式来定义PodGroup的概念,拥有同样label的Pod同属于一个PodGroup。min-available是用来标识该PodGroup的作业能够正式运行时所需要的最小副本数。
labels:
pod-group.scheduling.sigs.k8s.io/name: tf-smoke-gpu
pod-group.scheduling.sigs.k8s.io/min-available: "2"
备注: 要求属于同一个PodGroup的Pod必须保持相同的优先级
Framework的Permit插件提供了延迟绑定的功能,即Pod进入到Permit阶段时,用户可以自定义条件来允许Pod通过、拒绝Pod通过以及让Pod等待状态(可设置超时时间)。Permit的延迟绑定的功能,刚好可以让属于同一个PodGruop的Pod调度到这个节点时,进行等待,等待积累的Pod数目满足足够的数目时,再统一运行同一个PodGruop的所有Pod进行绑定并创建。
举个实际的例子,当JobA调度时,需要4个Pod同时启动,才能正常运行。但此时集群仅能满足3个Pod创建,此时与Default Scheduler不同的是,并不是直接将3个Pod调度并创建。而是通过Framework的Permit机制进行等待。
此时当集群中有空闲资源被释放后,JobA的中Pod所需要的资源均可以满足。
则JobA的4个Pod被一起调度创建出来,正常运行任务。
由于Default
Scheduler的队列并不能感知PodGroup的信息,所以Pod在出队时处于无序性(针对PodGroup而言)。如下图所示,a和b表示两个不同的PodGroup,两个PodGroup的Pod在进入队列时,由于创建的时间交错导致在队列中以交错的顺序排列。
当一个新的Pod创建后,入队后,无法跟与其相同的PodGroup的Pod排列在一起,只能继续以混乱的形式交错排列。
这种无序性就会导致如果PodGroupA在Permit阶段处于等待状态,此时PodGroupB的Pod调度完成后也处于等待状态,相互占有资源使得PodGroupA和PodGroupB均无法正常调度。这种情况即是把死锁现象出现的位置从Node节点移动到Permit阶段,无法解决前文提到的问题。
针对如上所示的问题,我们通过实现QueueSort插件, 保证在队列中属于同一个PodGroup的Pod能够排列在一起。我们通过定义QueueSort所用的Less方法,作用于Pod在入队后排队的顺序:
func Less(podA *PodInfo, podB *PodInfo) bool
首先,继承了默认的基于优先级的比较方式,高优先级的Pod会排在低优先级的Pod之前。
然后,如果两个Pod的优先级相同,我们定义了新的排队逻辑来支持PodGroup的排序。
通过如上的排队策略,我们实现属于同一个PodGroup的Pod能够同一个PodGroup的Pod能够排列在一起。
当一个新的Pod创建后,入队后,会跟与其相同的PodGroup的Pod排列在一起。
为了减少无效的调度操作,提升调度的性能,我们在Prefilter阶段增加一个过滤条件,当一个Pod调度时,会计算该Pod所属PodGroup的Pod的Sum(包括Running状态的),如果Sum小于min-available时,则肯定无法满足min-available的要求,则直接在Prefilter阶段拒绝掉,不再进入调度的主流程。
如果某个Pod在Permit阶段等待超时了,则会进入到UnReserve阶段,我们会直接拒绝掉所有跟Pod属于同一个PodGroup的Pod,避免剩余的Pod进行长时间的无效等待。
用户既可以在自己搭建的Kubernetes集群中,也可以在任一个公有云提供的标准Kubernetes服务中来试用Coscheduling。需要注意的是集群版本1.16+, 以及拥有更新集群master的权限。
本文将使用 阿里云容器服务 ACK 提供的Kubernetes集群来进行测试。
我们已经将Coscheduling插件和原生调度器代码统一构建成新的容器镜像。并提供了一个helm chart包 ack-coscheduling来自动安装。它会启动一个任务,自动用Coscheduling scheduler替换集群默认安装的原生scheduler,并且会修改scheduler的相关Config文件,使scheduling framework正确地加载Coscheduling插件。完成试用后,用户可通过下文提示的卸载功能恢复集群默认scheduler及相关配置。
下载helm chart包,执行命令安装
$ wget http://kubeflow.oss-cn-beijing.aliyuncs.com/ack-coscheduling.tar.gz
$ tar zxvf ack-coscheduling.tar.gz
$ helm install ack-coscheduling -n kube-system ./ack-coscheduling
NAME: ack-coscheduling
LAST DEPLOYED: Mon Apr 13 16:03:57 2020
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
在Master节点上,使用helm命令验证是否安装成功。
$ helm get manifest ack-coscheduling -n kube-system | kubectl get -n kube-system -f -
NAME COMPLETIONS DURATION AGE
scheduler-update-clusterrole 1/1 8s 35s
scheduler-update 3/1 of 3 8s 35s
### 卸载Coscheduling
通过helm卸载,将kube-scheduler的版本及配置回滚到集群默认的状态。
$ helm uninstall ack-coscheduling -n kube-system
使用Coscheduling时,只需要在创建任务的yaml描述中配置http://pod-group.scheduling.sigs.k8s.io/name和http://pod-group.scheduling.sigs.k8s.io/min-available这两个label即可。
labels:
pod-group.scheduling.sigs.k8s.io/name: tf-smoke-gpu
pod-group.scheduling.sigs.k8s.io/min-available: "3"
http://pod-group.scheduling.sigs.k8s.io/name:用于表示PodGroup的Name
http://pod-group.scheduling.sigs.k8s.io/min-available: 用于表示当前集群资源至少满足min-available个pod启动时,才能整体调度该任务
备注: 属于同一个PodGroup的Pod必须保持相同的优先级
接下来我们通过运行Tensorflow的分布式训练作业来演示Coscheduling的效果。当前测试集群有4个GPU卡
Arena是基于Kubernetes的机器学习系统开源社区Kubeflow中的子项目之一。Arena用命令行和SDK的形式支持了机器学习任务的主要生命周期管理(包括环境安装,数据准备,到模型开发,模型训练,模型预测等),有效提升了数据科学家工作效率。
git clone https://github.com/kubeflow/arena.git
kubectl create ns arena-system
kubectl create -f arena/kubernetes-artifacts/jobmon/jobmon-role.yaml
kubectl create -f arena/kubernetes-artifacts/tf-operator/tf-crd.yaml
kubectl create -f arena/kubernetes-artifacts/tf-operator/tf-operator.yaml
检查是否部署成功
$ kubectl get pods -n arena-system
NAME READY STATUS RESTARTS AGE
tf-job-dashboard-56cf48874f-gwlhv 1/1 Running 0 54s
tf-job-operator-66494d88fd-snm9m 1/1 Running 0 54s
apiVersion: "kubeflow.org/v1"
kind: "TFJob"
metadata:
name: "tf-smoke-gpu"
spec:
tfReplicaSpecs:
PS:
replicas: 1
template:
metadata:
creationTimestamp: null
labels:
pod-group.scheduling.sigs.k8s.io/name: tf-smoke-gpu
pod-group.scheduling.sigs.k8s.io/min-available: "5"
spec:
containers:
- args:
- python
- tf_cnn_benchmarks.py
- --batch_size=32
- --model=resnet50
- --variable_update=parameter_server
- --flush_stdout=true
- --num_gpus=1
- --local_parameter_device=cpu
- --device=cpu
- --data_format=NHWC
image: registry.cn-hangzhou.aliyuncs.com/kubeflow-images-public/tf-benchmarks-cpu:v20171202-bdab599-dirty-284af3
name: tensorflow
ports:
- containerPort: 2222
name: tfjob-port
resources:
limits:
cpu: '1'
workingDir: /opt/tf-benchmarks/scripts/tf_cnn_benchmarks
restartPolicy: OnFailure
Worker:
replicas: 4
template:
metadata:
creationTimestamp: null
labels:
pod-group.scheduling.sigs.k8s.io/name: tf-smoke-gpu
pod-group.scheduling.sigs.k8s.io/min-available: "5"
spec:
containers:
- args:
- python
- tf_cnn_benchmarks.py
- --batch_size=32
- --model=resnet50
- --variable_update=parameter_server
- --flush_stdout=true
- --num_gpus=1
- --local_parameter_device=cpu
- --device=gpu
- --data_format=NHWC
image: registry.cn-hangzhou.aliyuncs.com/kubeflow-images-public/tf-benchmarks-gpu:v20171202-bdab599-dirty-284af3
name: tensorflow
ports:
- containerPort: 2222
name: tfjob-port
resources:
limits:
nvidia.com/gpu: 2
workingDir: /opt/tf-benchmarks/scripts/tf_cnn_benchmarks
restartPolicy: OnFailure
删除上述TFJob yaml中的http://pod-group.scheduling.sigs.k8s.io/name和http://pod-group.scheduling.sigs.k8s.io/min-available标签,表示该任务不使用Coscheduling。创建任务后,集群资源只能满足2个Worker启动,剩余两个处于Pending状态。
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。