当前位置: 首页 > news >正文

解决PyTorch在Docker中共享内存不足导致DataLoader崩溃的实战指南

1. 项目概述:当PyTorch在Docker里“喊饿”——Shared Memory不足的典型困境

如果你正在用Docker容器来部署或训练PyTorch模型,特别是那些涉及多进程数据加载(DataLoadernum_workers > 0)或者使用torch.multiprocessing的场景,那么你大概率遇到过这个令人头疼的错误:RuntimeError: DataLoader worker (pid(s) xxx) exited unexpectedly,或者更直接的OSError: [Errno 28] No space left on device,而当你进入容器查看磁盘空间时,却发现/dev/shm(共享内存)已经爆满。这不是你的模型参数太大,也不是数据集没地方放,而是Docker容器默认分配给共享内存(Shared Memory,简称shm)的空间太小了。这个看似不起眼的配置,恰恰是许多深度学习工作流在容器化过程中翻车的“暗礁”。

简单来说,共享内存是Linux系统中一种高效的进程间通信(IPC)机制。PyTorch的DataLoader在启用多进程时,主进程会创建多个子进程(worker)来预加载和预处理数据。为了高效地将数据从子进程传递回主进程,PyTorch默认会使用共享内存。如果容器内的/dev/shm空间不足,worker进程在尝试分配共享内存缓冲区时就会失败,导致进程崩溃,整个训练或推理过程也就中断了。Docker Desktop默认的shm大小通常是64MB或128MB,这对于处理稍大一些的批次(batch)或复杂的数据预处理流水线来说,是远远不够的。

这篇文章,就是为你彻底解决这个问题而写的。无论你是刚接触Docker的算法工程师,还是负责模型服务化部署的运维开发,都能在这里找到清晰、可操作的解决方案。我们将从问题根因讲起,覆盖从开发到生产、从单机到编排(如Kubernetes)的各种场景,并提供我踩过坑后总结的实战经验和排查技巧。让我们把这个“内存饥饿”的问题,一次性喂饱。

2. 核心原理:为什么PyTorch和Docker的shm会“打架”?

要解决问题,必须先理解问题背后的机制。这不仅仅是改一个参数那么简单,而是涉及到Linux内核、Docker隔离机制和PyTorch框架设计的三方互动。

2.1 共享内存(shm)在PyTorch工作流中的角色

PyTorch的torch.utils.data.DataLoader是数据供给的核心。当你设置num_workers=N(N>0)时,它会创建N个子进程来并行加载数据。其默认的multiprocessing_context(多进程上下文)使用的是'spawn''fork'(取决于平台)。这些子进程与主进程之间需要交换数据(如图像张量、标签等)。为了追求极致的速度,避免通过队列(queue)进行序列化和反序列化带来的开销,PyTorch选择使用共享内存作为数据传输的“高速公路”。

具体流程是:主进程在共享内存区域开辟一块空间,子进程将处理好的数据直接写入这块空间,主进程则直接从同一块内存地址读取。这个过程对用户是透明的,但它的高效性建立在/dev/shm有足够空间的基础上。每一批(batch)数据都会在shm中占据一块临时区域。如果你的批次数据很大(例如高分辨率图像、长序列文本),或者num_workers设置得很多,那么对shm空间的需求就会急剧上升。

2.2 Docker容器的隔离性与shm的默认限制

Docker的核心价值在于资源隔离与控制。每个容器都有自己的挂载命名空间(mount namespace),这意味着它看到的文件系统视图是独立的。/dev/shm在容器内,是一个使用tmpfs(一种基于内存的临时文件系统)挂载的特殊目录。

关键在于,这个tmpfs的大小是受限制的。Docker引擎(或容器运行时)在创建容器时,会为这个挂载点设置一个默认的大小上限。在Docker的默认配置中,这个值通常为64MB。这个设计初衷是合理的,为了防止单个容器无限制地占用主机内存,影响系统稳定性。然而,这个“合理”的默认值,对于现代深度学习任务来说,就显得过于局促了。

2.3 冲突的根源:需求与供给的失衡

于是,矛盾产生了:

  • PyTorch的需求:假设你有一个数据加载流程,每个worker需要为每个batch准备约50MB的共享内存缓冲区。如果你有4个workers,并且采用预取策略,可能同时有2个batch在准备中,那么瞬时需要的shm空间就可能达到50MB * 4 workers * 2 batches = 400MB
  • Docker的默认供给:只有64MB。 结果就是,worker进程在尝试分配内存时,会立刻触发ENOSPC(设备上没有空间)错误,然后崩溃退出,主进程随之报告worker意外退出的错误。

注意:这个问题在使用torch.distributed进行分布式训练时同样常见,因为进程间通信(如NCCL后端)也可能使用共享内存。此外,一些依赖/dev/shm的第三方库(如某些版本的OpenCV用于缓存)也会因此受影响。

3. 解决方案全景:四种方法为shm“扩容”

解决思路很直接:增加容器内/dev/shm的大小。根据你的使用场景和平台,有以下几种主流方法,我将从最简单到最灵活逐一详解。

3.1 方法一:启动容器时通过--shm-size参数指定(最常用)

这是最直接、最推荐在单机Docker运行场景下使用的方法。在运行docker run命令时,通过--shm-size参数来覆盖默认值。

基本语法:

docker run --shm-size <size> <other-options> <image-name>

其中<size>是一个数字加单位,例如1g表示1GB,512m表示512MB。

实战示例:假设我们要运行一个PyTorch训练,并为共享内存分配2GB的空间。

docker run -it --rm \ --gpus all \ # 如果使用GPU --shm-size 2g \ # 关键参数:设置shm大小为2GB -v $(pwd)/data:/data \ -v $(pwd)/code:/workspace \ pytorch/pytorch:latest \ python /workspace/train.py

参数详解与选择依据:

  • --shm-size:这个参数直接告诉Docker守护进程,为这个容器实例的/dev/shm挂载点设置多大的tmpfs。其值会写入容器的cgroup配置中。
  • 大小估算:应该设置多大?一个实用的经验公式是:所需shm大小 ≈ batch_size * (单个样本序列化后的大致尺寸) * num_workers * 2其中的“*2”是考虑到预取(prefetch)机制。例如,batch_size=32,每个样本约0.5MB,num_workers=4,则估算为32 * 0.5MB * 4 * 2 = 128MB。为了留足余量,可以直接设置为1g2g。对于大多数CV或NLP任务,2g8g是一个安全范围。你可以通过监控容器内的df -h /dev/shm来观察实际使用峰值,从而精确调整。
  • --memory的关系--shm-size分配的内存包含在容器总内存限制(--memory)之内。例如,你设置--memory=8g --shm-size=2g,那么容器进程可用的普通内存(RAM)最大约为6GB,另外2GB专用于/dev/shm。如果不设置--memory,则shm大小独立于主机内存,但受主机物理内存总量限制。

实操心得:

  • 在编写docker run脚本或Makefile时,养成习惯加上--shm-size,尤其是运行深度学习相关镜像时。
  • 在持续集成(CI)流水线中运行测试时,如果测试涉及多进程数据加载,也必须配置此参数,否则测试会在某些机器上神秘失败。

3.2 方法二:在Docker Compose配置文件中定义

如果你使用Docker Compose来管理多容器应用,可以在docker-compose.yml文件中为服务配置shm_size

配置示例:

version: '3.8' services: model-trainer: image: pytorch/pytorch:latest runtime: nvidia # 如需GPU shm_size: '2gb' # 关键配置,支持‘b’, ‘k’, ‘m’, ‘g’等单位,字符串格式 volumes: - ./data:/data - ./code:/workspace command: python /workspace/train.py deploy: # 如果使用Compose部署模式,资源限制在这里 resources: limits: memory: 8G

注意事项:

  • shm_size的值是一个字符串,必须包含单位。
  • 在Compose v3格式中,shm_size是服务级别的配置项。它同样计入服务的总内存限制(如果在deploy.resources.limits中设置了memory)。

3.3 方法三:使用--mount-v挂载一个自定义的tmpfs(灵活控制)

这是一种更底层、更灵活的方式。你可以手动使用tmpfs类型挂载到容器内的/dev/shm目录,从而完全控制其挂载参数。

命令示例:

docker run -it --rm \ --mount type=tmpfs,destination=/dev/shm,tmpfs-size=2147483648 \ # 大小以字节为单位 pytorch/pytorch:latest \ python -c "import torch; print('PyTorch ready')"

或者使用-v的旧语法(不推荐,但可能在某些旧脚本中见到):

docker run -it --rm \ -v /dev/shm --tmpfs /dev/shm:rw,size=2g,nr_inodes=1M,mode=1777 \ pytorch/pytorch:latest \ ...

为什么选择这种方法?

  1. 精细控制:除了大小(size),你还可以控制inode数量(nr_inodes)、挂载模式(mode)等tmpfs专有参数。
  2. 覆盖现有挂载:如果基础镜像内已经挂载了/dev/shm(通常如此),Docker的--mount指令会覆盖它。
  3. 兼容性:在某些非常古老的Docker版本(早于1.10)或某些容器运行时中,--shm-size可能不被支持,此时这种方法就是备选方案。

不过,对于绝大多数情况,--shm-size因其简洁直观而更受青睐。

3.4 方法四:在Kubernetes Pod Spec中配置(生产环境方案)

在Kubernetes集群中部署PyTorch训练任务或模型服务,需要在Pod的规格中定义emptyDir卷并将其挂载到/dev/shm,同时指定其介质为Memory并设置大小限制。

YAML配置示例:

apiVersion: v1 kind: Pod metadata: name: pytorch-training-pod spec: containers: - name: trainer image: pytorch/pytorch:latest command: ["python", "train.py"] resources: limits: memory: "8Gi" nvidia.com/gpu: 1 # 申请GPU volumeMounts: - name: dshm # 挂载名为dshm的卷到容器的/dev/shm mountPath: /dev/shm volumes: - name: dshm # 定义一个emptyDir卷,使用内存作为存储介质 emptyDir: medium: Memory sizeLimit: 2Gi # 设置大小限制

关键解析:

  • emptyDir.medium: Memory:这是核心。它指示Kubernetes使用节点的tmpfs(内存)来提供这个卷的存储,其行为与Docker的--shm-size创建的tmpfs一致。
  • sizeLimit:必须设置。它定义了该内存卷的上限。Kubernetes的驱逐管理器(kubelet)会监控此用量,如果容器使用的内存超过此限制,Pod可能会被驱逐(Evicted)。
  • 资源关联sizeLimit消耗的内存同样计入Pod的总内存使用量。在上例中,容器进程的“普通”内存使用量(约6Gi)加上/dev/shm的内存使用量(最多2Gi),总和不应超过Pod的memorylimit(8Gi),否则可能触发OOMKill。

生产环境建议:

  • 在Kubernetes的LimitRange中为命名空间设置默认的emptyDir大小限制,可以防止因忘记设置sizeLimit而导致的节点内存耗尽风险。
  • 对于使用KubeflowPyTorch Operator等高级框架提交的训练任务,通常可以在JobPyTorchJob的配置中找到相应的字段来设置volumeMounts

4. 诊断与排查:如何确认和定位shm问题?

不是所有的DataLoaderworker崩溃都是shm不足引起的。在动手调整配置前,准确的诊断能节省大量时间。下面是一套完整的排查流程。

4.1 典型错误信息识别

首先,看日志。shm不足引发的错误通常有这些特征:

  1. PyTorch报错RuntimeError: DataLoader worker (pid(s) [xxx]) exited unexpectedly。这是最外层的错误,原因需要进一步看worker的stderr。
  2. Worker进程报错:在PyTorch的主错误信息之前或同时,你可能会在日志中看到worker进程打印的跟踪信息,核心可能是:
    • OSError: [Errno 28] No space left on device
    • torch.multiprocessing.Process启动失败。
  3. 直接测试:在容器内运行一个快速测试脚本可以立即验证。
    # test_shm.py import torch import torch.multiprocessing as mp def worker(): # 尝试分配一块较大的共享内存 tensor = torch.zeros(1000, 1000, 100) # 约400MB的float32张量 shm_tensor = tensor.share_memory_() # 转换为共享内存张量 print(f"Worker allocated shared memory successfully.") if __name__ == '__main__': try: p = mp.Process(target=worker) p.start() p.join() if p.exitcode != 0: print(f"Worker failed with exitcode {p.exitcode}") except Exception as e: print(f"Error: {e}")
    在容器内运行python test_shm.py,如果失败并提示设备空间不足,即可确诊。

4.2 容器内实时监控shm使用情况

进入容器,使用Linux命令监控。

  1. 查看当前shm大小和使用量
    df -h /dev/shm
    输出示例:
    Filesystem Size Used Avail Use% Mounted on tmpfs 64M 58M 6.0M 91% /dev/shm
    如果Use%接近100%,问题就很明显了。
  2. 动态监控变化:在训练脚本运行的同时,在另一个终端进入容器,使用watch命令:
    watch -n 1 'df -h /dev/shm'
    可以每秒刷新一次,观察使用量在数据加载时的峰值。

4.3 深入分析:使用strace追踪系统调用(高级)

如果问题比较隐蔽,可以使用strace来追踪worker进程,看它在崩溃前执行了哪些系统调用。

  1. 首先,在训练脚本中获取worker的PID可能比较麻烦。一个变通的方法是,在容器内启动一个简单的、会触发问题的Python脚本。
  2. 在宿主机上,找到容器对应的进程,然后用strace附加。
    # 在宿主机上执行 # 1. 找到容器的主进程PID docker inspect --format '{{.State.Pid}}' <container_name_or_id> # 假设输出是 12345 # 2. 进入该进程的命名空间,并跟踪任何子进程的shm相关调用 sudo nsenter -t 12345 -m strace -f -e trace=shmget,shmat,shmdt,ftruncate,openat 2>&1 | grep -i "shm\|/dev/shm"
    如果看到大量的shmget调用后跟着ENOSPC错误,那就是铁证。

4.4 常见问题排查速查表

现象可能原因排查步骤解决方案
DataLoader worker随机退出,无明确错误shm不足,但错误被捕获或日志未输出1. 检查容器日志是否完整。
2. 在训练脚本开头添加torch.multiprocessing.set_start_method('spawn', force=True)并重定向worker错误流。
3. 监控/dev/shm使用率。
增加--shm-size
仅在某些大数据集或大batch下出错shm需求与数据规模正相关1. 计算单batch数据在内存中的大致体积。
2. 根据公式估算所需shm大小。
按需增加shm,或考虑优化数据预处理,减少内存拷贝。
增加--shm-size后仍报内存错误可能触及容器总内存限制(--memory)1. 检查docker statskubectl top pod
2. 确认--shm-size+ 应用内存 <--memory
增加容器总内存限制,或优化模型/数据内存占用。
Kubernetes Pod处于Evicted状态emptyDirsizeLimit被突破查看Pod描述:kubectl describe pod <pod_name>,在事件中寻找Evicted原因。调整Pod的memorylimit和emptyDirsizeLimit

5. 进阶优化与替代方案

解决了基本的空间问题后,我们还可以从其他角度优化,让整个系统更稳健。

5.1 调整PyTorch DataLoader的行为

有时,我们无法无限增加shm大小(例如在共享的K8s集群中配额有限),那么可以调整PyTorch端的行为来降低对shm的依赖。

  1. 减少num_workers:这是最直接的方法。虽然会降低数据加载的并行度,但能线性减少shm压力。可以将其设置为1或2进行测试。
  2. 使用pin_memory=Falsepin_memory是将数据锁页内存,便于更快地从CPU传输到GPU。但这个过程有时会涉及额外的内存分配。如果shm非常紧张,可以尝试关闭它,但可能会轻微影响GPU训练速度。
  3. 更换multiprocessing_context:在Linux上,可以尝试使用'fork'而不是默认的'spawn''fork'创建子进程的方式不同,有时对共享内存的管理也更高效。但要注意,'fork'在多线程环境中可能不安全。
    from torch.utils.data import DataLoader loader = DataLoader(dataset, batch_size=32, num_workers=4, multiprocessing_context='fork') # 仅限Linux
  4. 使用persistent_workers=False:默认情况下,每个epoch结束后worker进程会销毁。将其设为True可以避免进程反复创建销毁的开销,但worker进程会持续占用shm。在shm紧张时,设为False可能有助于清理。

5.2 使用共享内存替代方案

如果shm问题成为架构瓶颈,可以考虑更根本的解决方案。

  1. 使用文件系统作为IPC:对于非常大的数据,可以先将数据序列化到容器内的普通磁盘(如挂载的volume),然后通过文件路径通知其他进程。这种方法速度慢,但不受shm大小限制。可以使用Python的multiprocessing.Queue或第三方消息队列(如Redis),但这些方案会引入序列化开销和复杂度。
  2. 使用torch.distributed的TCP后端:在分布式训练中,如果NCCL因为共享内存问题无法初始化,可以回退到使用TCP后端进行进程间通信。但这会严重降低通信性能,仅作为调试手段。
    python -m torch.distributed.launch --nproc_per_node=2 --use_env \ --master_addr=127.0.0.1 --master_port=29500 \ YOUR_TRAIN_SCRIPT.py # 在脚本中设置 # os.environ['MASTER_ADDR'] = '127.0.0.1' # os.environ['MASTER_PORT'] = '29500' # dist.init_process_group(backend='gloo') # 或 'nccl',如果失败可尝试 'gloo'(基于TCP)

5.3 容器镜像构建最佳实践

在构建用于深度学习的Docker镜像时,可以预先做好一些配置,减少运行时的不确定性。

  1. 在Dockerfile中设置环境变量:虽然不能直接设置shm大小,但可以设置PyTorch相关的环境变量来优化默认行为。
    FROM pytorch/pytorch:latest # 建议设置,防止OpenMPI等库使用共享内存 ENV OMPI_MCA_btl_vader_single_copy_mechanism=none # 对于某些情况,可以尝试限制内存分配器 ENV PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
  2. 提供清晰的启动说明:在镜像的README或启动脚本中,明确提示用户可能需要使用--shm-size参数。
    # 示例的 run.sh #!/bin/bash echo "提示:运行深度学习任务建议增加共享内存,例如:" echo "docker run --shm-size=2g ..."

6. 生产环境部署的考量与监控

将解决方案应用到生产环境,需要考虑更多维度的稳定性和可观测性。

6.1 资源配额与成本平衡

在云环境或共享的Kubernetes集群中,内存是昂贵的资源。/dev/shm使用的tmpfs内存是会被计费的。你需要精确评估:

  • 需求评估:通过线下压力测试,确定你的模型服务或训练任务在峰值负载下所需的shm大小。不要盲目设置一个很大的值。
  • 配额申请:在向运维团队或云平台申请资源配额时,需要明确说明总内存需求是“应用内存 + shm内存”。例如,申请8G内存的Pod,可能实际需要配置memory: 10Gi,其中2Gi预留给emptyDirMemory卷。
  • 成本优化:如果多个服务可以共享节点,合理设置sizeLimit可以防止单个服务异常占用所有内存,提高节点利用率。

6.2 监控与告警

对于线上服务,需要对shm使用情况进行监控。

  1. 容器层监控:使用cAdvisornode-exporter等工具,可以采集容器级别的tmpfs使用量指标。
  2. 应用层埋点:在训练或服务代码中,可以定期检查/dev/shm的使用率并记录日志。
    import shutil usage = shutil.disk_usage('/dev/shm') usage_percent = usage.used / usage.total * 100 if usage_percent > 80: logger.warning(f"/dev/shm usage is high: {usage_percent:.1f}%")
  3. Prometheus + Grafana:将上述指标通过/metrics端点暴露,并由Prometheus抓取。在Grafana中制作仪表盘,设置当shm使用率超过85%时触发告警(Alert),通知运维人员。

6.3 在CI/CD流水线中的处理

在自动化测试和构建流水线中,同样需要配置足够的shm。

  • GitLab CI:在.gitlab-ci.yml中,可以在docker执行器或kubernetes执行器中配置。
    test_model: image: pytorch/pytorch:latest services: - docker:dind variables: DOCKER_DRIVER: overlay2 # 对于docker-in-docker场景,需要在执行器配置中传递参数,通常需在Runner配置中设置 script: - docker run --shm-size 2g your_image python test.py
  • Jenkins:如果使用Jenkins的Docker插件,可以在Agent模板中指定--shm-size
  • GitHub Actions:在jobs.<job_id>.container选项中配置。
    jobs: test: runs-on: ubuntu-latest container: image: pytorch/pytorch:latest options: --shm-size 2gb # 关键配置 steps: - run: python test.py

7. 一个完整的实战案例:从错误到解决

让我们用一个虚构但典型的场景,串联起所有知识点。

场景:算法工程师小张在本地用Docker运行一个图像分割模型训练。数据集是高清医学图像,每张约5MB,batch_size=8num_workers=4。他直接使用docker run启动,很快程序崩溃。

第一步:观察错误日志显示:RuntimeError: DataLoader worker (pid 89, 90...) exited unexpectedly。没有更详细的错误。

第二步:初步诊断小张进入容器,在训练前运行df -h /dev/shm,发现只有64M。他立刻运行我们提供的test_shm.py脚本,脚本报错No space left on device。问题锁定。

第三步:实施解决他修改了启动命令,增加了--shm-size参数:

# 初始估算:8张 * 5MB * 4 workers * 2 (预取) ≈ 320MB。为保险起见,设置为1G。 docker run -it --rm \ --gpus all \ --shm-size 1g \ -v $(pwd)/data:/data \ -v $(pwd)/src:/workspace \ my_pytorch_image \ python train.py

训练启动成功。但他通过watch -n 1 'df -h /dev/shm'监控发现,使用峰值达到了900MB,接近上限。

第四步:优化调整为了避免在后续更复杂的增强操作中再次出问题,小张决定将--shm-size增加到2g。同时,他查阅代码,发现数据预处理中有一个步骤创建了不必要的内存副本。优化该步骤后,shm峰值使用量下降到了600MB。

第五步:沉淀经验小张将优化后的启动命令写入了项目的README.md和启动脚本run_train.sh中。并且,他在项目的Docker Compose文件(用于本地多服务联调)和Kubernetes部署清单中,都同步添加了shm_size: '2gb'的配置。

通过这个完整的流程,小张不仅解决了眼前的问题,还将解决方案固化到了项目的各个层面,避免了团队其他成员踩进同一个坑。这个从诊断到解决再到优化的过程,正是处理这类系统级问题的标准方法论。记住,关键不在于记住--shm-size 2g这个命令,而在于理解其背后的原理,并掌握一套属于自己的排查和解决流程。

http://www.cnnetsun.cn/news/4167590.html

相关文章:

  • Neural Holography复现:光学物理、ASM建模与CITL闭环实战指南
  • FTP工具深度横评:从FileZilla到lftp,高效文件传输与自动化部署实战
  • C++模板进阶:从函数模板到显式具体化与实例化
  • 嵌入式开发实战:DMA串口接收与调试优化全解析
  • MongoDB从安装到实战:CentOS 7部署与Python/Node.js开发指南
  • 光纤交换机巡检实战:从核心命令到自动化运维
  • STM32 GPIO驱动电路设计全解析:从LED到电机,避开硬件大坑
  • 程序设计方法学实战:从抽象建模到SOLID原则的工程化编码指南
  • CMake构建系统:从基础概念到大型C/C++项目实战指南
  • PyCharm从Git拉取项目并配置虚拟环境完整指南
  • 深入解析CPU高速缓存:原理、优化策略与实战避坑指南
  • Qt Designer入门指南:可视化GUI开发工具的核心原理与实践
  • AI代理如何学会选择性调用技能?双粒度偏好学习框架SelSkill详解
  • 基于LightGBM的移动通信基站流量预测实战:从特征工程到模型调优
  • C++模板进阶实战:从特化、分离编译到模板参数高级用法
  • 从零转型AI大模型工程师:4个月速成路线与求职策略
  • 数学建模预测方法全解析:从ARIMA到XGBoost的选型与实战
  • 数据结构实战:从面试真题到工程优化
  • Two Sigma OA面试全解析:算法优化与统计建模实战
  • KEIL-MDK编码转换实战:解决中文乱码与统一UTF-8规范
  • 分类模型评估指标全解析:从混淆矩阵到业务场景选择
  • 基于PPO强化学习的机器人轨迹规划与避障实战指南
  • Keil AC6编译后生成bin文件夹问题解析与解决方案
  • Java面试核心:三层漏斗筛选法与高频考点解析
  • CANdelaStudio入门指南:汽车诊断数据库(CDD)开发核心与实践
  • C# TCP/IP网络编程实战:从Socket基础到生产级数据传输系统构建
  • 蓝桥杯矩阵运算实战:从基础实现到快速幂优化
  • 深入解析方法重写:从动态绑定到多态实现的核心机制
  • 2026年Java面试核心要点与云原生技术解析
  • 视频世界模型如何学习物理规律?可微分物理模拟是关键