• Cgroup内核文档翻译(3)——Documentation/cgroup-v1/cgroups.txt


    CGROUPS
    -------

    由Paul Menage <menage@google.com>根据Documentation/cgroup-v1/cpusets.txt提供

    来自cpusets.txt的原始版权声明:
    部分版权所有(C)2004 BULL SA。
    部分版权所有(c)2004-2006 Silicon Graphics,Inc.
    由保罗·杰克逊<pj@sgi.com>修改
    由Christoph Lameter修改<cl@linux.com>

    内容:
    =========
    1. Control Groups
    1.1 什么是cgroup?
    1.2 为什么需要cgroup?
    1.3 cgroup如何实现?
    1.4 notify_on_release 有什么作用?
    1.5 clone_children 是做什么的?
    1.6 如何使用cgroups?
    2. 使用示例和语法
    2.1 基本用法
    2.2 附加流程
    2.3 按名称挂载层次结构
    3. 内核API
    3.1 概述
    3.2 同步
    3.3 子系统API
    4. 扩展属性用法
    5. 问题

    1. Control Groups
    =================

    1.1什么是cgroup?
    ----------------------

    控制组提供了一种机制,可以将任务集及其所有未来子级集合/划分为具有特殊行为的分层组。

    定义:一个 *cgroup*将一组任务与一个或多个子系统的一组参数相关联。

    *subsystem*是一个模块,该模块利用cgroups提供的任务分组工具以特定方式处理一组任务。 子系统通常是调度资源或应用每cgroup限制的“资源控制器”,但是它可能是想要对一组进程进行操作的任何事物,例如:虚拟化子系统。

    *层次结构*是一组排列在树中的cgroup,这样系统中的每个任务都恰好位于层次结构中的cgroup中,并且是子系统的集合。 每个子系统都有特定于系统的状态,该状态附加到层次结构中的每个cgroup。 每个层次结构都有一个与之关联的cgroup虚拟文件系统的实例。

    在任何时候,任务cgroup可能有多个活动层次结构。 每个层次结构都是系统中所有任务的分区。

    用户级代码可以按名称在cgroup虚拟文件系统的实例中创建和销毁cgroup,指定并查询任务分配给哪个cgroup,并列出分配给cgroup的任务PID。 这些创建和分配仅影响与cgroup文件系统实例关联的层次结构。

    cgroups的唯一用途是进行简单的作业跟踪。 目的是让其他子系统加入通用cgroup支持,以为cgroup提供新属性,例如记帐/限制cgroup中的进程可以访问的资源。 例如,cpusets(请参阅Documentation/cgroup-v1/cpusets.txt)允许您将一组CPU和一组内存节点与每个cgroup中的任务相关联。

    1.2 为什么需要cgroup?
    ----------------------------

    为了在Linux内核中提供进程聚合,已经进行了多种努力,主要是为了进行资源跟踪。 这些工作包括cpuset,CKRM / ResGroups,UserBeanCounters和虚拟服务器名称空间。 所有这些都需要对进程进行分组/分区的基本概念,新派生的进程与其父进程最终位于同一组(cgroup)中。

    内核cgroup补丁提供了有效实施此类组所需的最低限度基本内核机制。 它对系统快速路径的影响最小,并为特定子系统(例如cpusets)提供了挂钩,以根据需要提供其他行为。

    提供了多层次支持,以允许在不同子系统之间将任务划分为cgroup的情况明显不同-具有并行层次结构允许每个层次结构自然地划分任务,而不必处理如果存在的情况下复杂的任务组合 需要将几个不相关的子系统强加到同一cgroup树中。

    在一个极端情况下,每个资源控制器或子系统都可以位于单独的层次结构中。 在另一个极端,所有子系统都将附加到同一层次结构。

    作为可以从多个层次结构中受益的方案的一个示例(最初由vatsa@in.ibm.com提出),请考虑一个具有各种用户的大型大学服务器-学生,教授,系统任务等。该服务器的资源规划可以是遵循以下原则:

       CPU :          "Top cpuset"
                       /       
               CPUSet1         CPUSet2
                  |               |
               (Professors)    (Students)
    
            另外(系统任务)附加到top cpuset(以便它们可以在任何地方运行),限制为20%
    
            内存:教授(50%),学生(30%),系统(20%)
    
            磁盘:教授(50%),学生(30%),系统(20%)
    
            网络:WWW浏览(20%),网络文件系统(60%),其他(20%)
                                    /    
                            教授(15%)学生(5%)

    Firefox/Lynx等浏览器进入WWW网络类,而(k)nfsd进入NFS网络类。

    同时,Firefox/Lynx将根据启动它的人(教授/学生)共享适当的CPU/内存类。

    借助针对不同资源对任务进行不同分类的能力(通过将这些资源子系统置于不同的层次结构中),管理员可以轻松地设置一个脚本来接收执行通知,并取决于谁可以启动浏览器

    # echo browser_pid > /sys/fs/cgroup/<restype>/<userclass>/tasks

    仅凭一个层次结构,他现在可能必须为启动的每个浏览器创建一个单独的cgroup,并将其与适当的网络和其他资源类相关联。 这可能导致此类cgroup扩散。

    也可以说,管理员希望暂时向学生的浏览器提供增强的网络访问权限(因为现在是晚上,并且用户希望进行在线游戏:)),或者为学生的其中一个模拟应用程序提供增强的CPU能力。

    具备将PID直接写入资源类的能力,这仅仅是一个问题:

    # echo pid > /sys/fs/cgroup/network/<new_class>/tasks
    (after some time)
    # echo pid > /sys/fs/cgroup/network/<orig_class>/tasks

    没有此功能,管理员将不得不将cgroup拆分为多个单独的cgroup,然后将新的cgroup与新的资源类相关联。

    1.3 cgroup如何实现?
    ---------------------------------

    控制组对内核的扩展如下:

    - 系统中的每个任务都有一个指向 css_set 的引用计数的指针。

    - 一个 css_set 包含一组指向 cgroup_subsys_state 对象的引用计数的指针,每个指针用于系统中注册的每个cgroup子系统。在每个层次结构中,没有从任务到其所属cgroup的直接链接,但这可以通过在 cgroup_subsys_state 对象中跟随指针来确定。这是因为访问子系统状态是经常发生的事情,并且在对性能有严格要求的代码中会经常发生,而需要执行任务实际cgroup分配(特别是在cgroup之间移动)的操作并不常见。 链接列表使用 css_set 遍历每个 task_struct 的 cg_list 字段,并固定
    在 css_set->tasks上。

    - 为了浏览和操作,cgroup层次结构文件系统可以从用户空间进行挂载。

    - 您可以列出附加到任何cgroup的所有任务(通过PID)。cgroup的实现需要一些简单的钩子连接到内核的其余部分,而在性能关键的路径中则没有钩子:

    - 在 init/main.c 中,在系统引导时初始化根cgroup和初始css_set。

    - 在fork和exit中,将任务从其 css_set 附加和分离。

    另外,可以安装类型为“cgroup”的新文件系统,以使得能够浏览和修改内核当前已知的cgroup。 挂载cgroup层次结构时,可以指定以逗号分隔的要挂载的子系统列表作为文件系统挂载选项。 默认情况下,挂载cgroup文件系统会尝试挂载包含所有已注册子系统的层次结构。

    如果已经存在具有完全相同的子系统集的活动层次结构,则它将被重新用于新的挂载。 如果没有现有层次结构匹配,并且现有层次结构中正在使用任何请求的子系统,则挂载将失败,并显示-EBUSY。 否则,将激活与所请求的子系统关联的新层次结构。

    当前无法将新子系统绑定到活动的cgroup层次结构,或将子系统从活动的cgroup层次结构取消绑定。 将来可能会发生这种情况,但是充满了令人讨厌的错误恢复问题。

    卸载cgroup文件系统时,如果在顶层cgroup下方创建了任何子cgroup,则即使卸载,该层次结构仍将保持活动状态;如果没有子cgroup,则将停用层次结构。

    没有为cgroup添加新的系统调用-查询和修改cgroup的所有支持特性都是通过此cgroup文件系统进行的。

    /proc下的每个任务都添加一个名为“cgroup”的文件,该文件为每个活动层次结构显示子系统名称和cgroup名称作为相对于cgroup文件系统根目录的路径。

    每个cgroup由cgroup文件系统中的目录表示,该目录包含描述该cgroup的以下文件:

    - tasks:附加到该cgroup中的任务列表(通过PID)。 此列表不保证是排好序的。 将线程ID写入此文件会将线程移至该cgroup。
    - cgroup.procs:cgroup中的线程组ID的列表。 不保证此列表可以排序或没有重复的TGID,如果需要此属性,则用户空间应对列表进行排序/唯一化。 将线程组ID写入此文件会将该组中的所有线程移入该cgroup
    - notify_on_release 标志:在退出时运行释放代理?
    - release_agent:用于释放通知的路径(此文件仅在顶级cgroup中存在)

    其他子系统(例如cpusets)可能会在每个cgroup目录中添加其他文件。

    使用mkdir系统调用或shell命令创建新的cgroup。 如上所述,通过写入该cgroups目录中的相应文件来修改cgroup的属性(例如其标志)。

    嵌套cgroup的命名层次结构允许将大型系统划分为嵌套的,可动态更改的“软分区”。

    每个任务(由该任务的任何子级在派生时自动继承)都附加到cgroup,从而可以将系统上的工作负载组织到相关的任务集中。 如果必要的cgroup文件系统目录的权限允许,则任务可以重新附加到任何其他cgroup。

    当任务从一个cgroup移到另一个cgroup时,它将获得一个新的 css_set 指针-如果已经存在具有所需cgroup集合的css_set,则该组将被重用,否则将分配一个新的css_set。 通过查看哈希表可以找到适当的现有css_set。

    为了允许从cgroup访问组成它的css_sets(以及因此而来的任务),一组 cg_cgroup_link 对象形成一个网格; 每个 cg_cgroup_link 都链接到其 cgrp_link_list 字段中单个cgroup的cg_cgroup_links 列表,以及其 cg_link_list 上单个 cssset的cg_cgroup_links 列表。

    因此,可以通过迭代引用cgroup的每个 css_set 并在每个 css_set 的任务集上进行子迭代来列出cgroup中的任务集。

    使用Linux虚拟文件系统(vfs)表示cgroup层次结构,为cgroup提供了熟悉的权限和名称空间,并带有最少的附加内核代码。

    1.4 notify_on_release有什么作用?
    ------------------------------------
    如果在cgroup中启用了 notify_on_release 标志(1),则每当cgroup中的最后一个任务离开(退出或附加到其他cgroup)并且该cgroup的最后一个子cgroup被删除时,内核就会运行由该层次结构的根目录中“ release_agent”文件的内容,提供放弃的cgroup的路径名(相对于cgroup文件系统的安装点)。 这样可以自动删除废弃的cgroup。 系统引导时,根cgroup中的 notify_on_release 的默认值被禁用的(0)。 创建时其他cgroup的默认值是其父母的 notify_on_release 设置的当前值。 cgroup层次结构的 release_agent 路径的默认值为空。

    1.5 clone_children 是做什么的?
    ---------------------------------
    该标志仅影响cpuset控制器。 如果在cgroup中启用了 clone_children 标志(1),则新的cpuset cgroup将在初始化期间从父级复制其配置。

    1.6 如何使用cgroups?
    --------------------------

    要启动cgroup中的一个新作业,使用"cpuset" cgroup子系统,步骤如下:

     1) mount -t tmpfs cgroup_root /sys/fs/cgroup
     2) mkdir /sys/fs/cgroup/cpuset
     3) mount -t cgroup -ocpuset cpuset /sys/fs/cgroup/cpuset
     4) 通过在/sys/fs/cgroup/cpuset虚拟文件系统中执行mkdir和write(或echo)来创建新的cgroup。
     5) 开始一项将成为新工作的“奠基人”的任务。
     6) 通过将其PID写入该cgroup的/sys/fs/cgroup/cpuset任务文件中,将该任务附加到新cgroup。
     7) 从此创始父任务派生,执行或克隆作业任务。

    例如,以下命令序列将设置一个名为“ Charlie”的cgroup,仅包含CPU2和3,以及内存节点1,然后在该cgroup中启动子shell“ sh”:

      mount -t tmpfs cgroup_root /sys/fs/cgroup
      mkdir /sys/fs/cgroup/cpuset
      mount -t cgroup cpuset -ocpuset /sys/fs/cgroup/cpuset
      cd /sys/fs/cgroup/cpuset
      mkdir Charlie
      cd Charlie
      /bin/echo 2-3 > cpuset.cpus
      /bin/echo 1 > cpuset.mems
      /bin/echo $$ > tasks
      sh
      # The subshell 'sh' is now running in cgroup Charlie
      # The next line should display '/Charlie'
      cat /proc/self/cgroup

    2. 使用示例和语法
    ===========================

    2.1 基本用法
    ---------------

    可以通过cgroup虚拟文件系统来创建,修改和使用cgroup。

    要挂载一个带有所有可用子系统的cgroup层次结构,请键入:

    # mount -t cgroup xxx /sys/fs/cgroup

    “ xxx”不会被cgroup代码解释,但是会出现在/proc/mounts中,因此可能是您喜欢的任何有用的标识字符串。

    注意:如果没有先输入用户信息,某些子系统将无法正常工作。 例如,如果启用了cpuset,则用户必须在使用该组之前为每个创建的新cgroup填充cpus和mems文件。

    如“1.2 为什么需要cgroups”一节中所述。 您应该为每个要控制的资源或资源组创建不同的cgroup层次结构。 因此,您应该在/sys/fs/cgroup上挂载tmpfs并为每个cgroup资源或资源组创建目录。

    # mount -t tmpfs cgroup_root /sys/fs/cgroup
    # mkdir /sys/fs/cgroup/rg1

    如果要安装的cgroup仅使用cpuset和memory子系统,请键入:

    # mount -t cgroup -o cpuset,memory hier1 /sys/fs/cgroup/rg1

    当前支持重新安装cgroup,但不建议使用它。 重新安装允许更改绑定的子系统和release_agent。 重新绑定几乎没有用,因为它仅在层次结构为空并且release_agent本身应替换为常规fsnotify时才起作用。 将来将删除对重新安装的支持。

    要指定层次结构的 release_agent:

    # mount -t cgroup -o cpuset,release_agent="/sbin/cpuset_release_agent" xxx /sys/fs/cgroup/rg1

    请注意,多次指定“release_agent”将返回失败。

    请注意,当前仅在层次结构由单个(根)cgroup组成时才支持更改子系统集。 支持在现有cgroup层次结构中任意绑定/解除子系统的功能将在将来实现。

    然后在/sys/fs/cgroup/rg1下可以找到与系统中cgroup的树相对应的树。 例如,/sys/fs/cgroup/rg1是保存整个系统的cgroup。

    如果要更改 release_agent 的值:

    # echo "/sbin/new_release_agent" > /sys/fs/cgroup/rg1/release_agent

    也可以通过remount来更改。

    如果要在/sys/fs/cgroup/rg1下创建一个新的cgroup:

    # cd /sys/fs/cgroup/rg1
    # mkdir my_cgroup

    现在,您想对此cgroup进行操作。

    # cd my_cgroup

    在此目录中,您可以发现几个文件:

    # ls
    cgroup.procs notify_on_release tasks (以及附加子系统添加的任何文件)

    现在将您的shell附加到此cgroup:

    # /bin/echo $$ > tasks

    您也可以通过在此目录中使用mkdir在cgroup中创建cgroup。

    # mkdir my_sub_cs

    要删除cgroup,只需使用rmdir:

    # rmdir my_sub_cs

    如果正在使用cgroup(内部包含cgroup或已附加进程,或被其他子系统特定的引用保持活动状态),则此操作将失败。

    2.2 附加进程到cgroup中
    -----------------------

    # /bin/echo PID > tasks

    请注意,它是PID,而不是PIDs。 您一次只能附加一个任务。 如果要附加多个任务,则必须一个接一个地执行:

    # /bin/echo PID1 > tasks
    # /bin/echo PID2 > tasks
        ...
    # /bin/echo PIDn > tasks

    您可以通过echo 0来附加当前的shell任务:

    # echo 0 > tasks

    您可以使用 cgroup.procs 文件而不是task文件,来一次移动线程组中的所有线程。 将线程组中任何任务的PID echo到 cgroup.procs 会使该线程组中的所有任务都附加到cgroup 将0写入 cgroup.procs 将移动写入任务的线程组中的所有任务。

    注意:由于每个任务在每个已安装的层次结构中始终都是一个cgroup的成员,因此要从当前cgroup中删除任务,必须通过写入新cgroup的tasks文件将其移到新的cgroup(可能是根cgroup)中。

    注意:由于某些cgroup子系统实施的某些限制,将进程移动到另一个cgroup可能会失败。

    2.3 按名称挂载层次结构
    --------------------------------

    挂载cgroups层次结构时传递 name=<x> 选项会将给定名称与该层次结构相关联。挂载预先存在的层次结构时,可以使用此名称,以便通过名称而不是其活动子系统的集合来引用它。每个层次结构要么是无名的,要么具有唯一的名称。

    名称应匹配 [w.-]+

    为新的层次结构传递 name=<x> 选项时,需要手动指定子系统。 给子系统命名时,不支持在未明确指定时挂载所有子系统的传统行为。

    子系统的名称作为/proc/mounts和/proc/<pid>/cgroups中层次结构描述的一部分出现。

    3.内核API
    =============

    3.1概述
    ------------

    每个想要挂接到通用 cgroup 系统的内核子系统都需要创建一个 cgroup_subsys 对象。 它包含各种方法,这些方法是cgroup系统的回调,以及将由cgroup系统分配的子系统ID。

    cgroup_subsys 对象中的其他字段包括:

    - subsys_id:子系统的唯一数组索引,指示该子系统应管理 cgroup->subsys[]中的哪个条目。
    
    - name: 应初始化为唯一的子系统名称。 长度不得超过 MAX_CGROUP_TYPE_NAMELEN。
    
    - early_init:指示子系统在系统引导时是否需要早期初始化。

    系统创建的每个cgroup对象都有一个由子系统ID索引的指针数组。 该指针完全由子系统管理;通用的cgroup代码将永远不会碰到该指针。

    3.2 Synchronization
    -------------------

    cgroup系统使用一个全局互斥锁 cgroup_mutex。 任何想要修改cgroup的人都应该持有这个锁。 也可以持有此锁不释放以防止修改cgroup,但是在这种情况下,更具体的锁定可能更合适。

    有关更多详细信息,请参见kernel/cgroup.c。

    子系统可以通过函数 cgroup_lock()/cgroup_unlock() 获取/释放 cgroup_mutex。

    可以通过以下方式访问任务的cgroup指针:

    - 持有 cgroup_mutex 锁
    - 持有任务的 alloc_lock 时(通过task_lock())
    - 通过 rcu_dereference()在rcu_read_lock()部分中

    3.3 子系统API
    -----------------

    每个子系统应:

    -在 linux/cgroup_subsys.h 中添加一个条目
    -定义一个名为 <name>_cgrp_subsys 的 cgroup_subsys 对象

    每个子系统可以导出以下方法。 唯一的强制方法是 css_alloc / free(4.19上应该是release)。 任何其他为空的都被假定为成功无操作。

    struct cgroup_subsys_state *css_alloc(struct cgroup *cgrp) //(调用者持有cgroup_mutex)

    被调用为cgroup分配子系统状态对象。 子系统应为传递的cgroup分配其子系统状态对象,成功时返回指向新对象的指针或ERR_PTR()值。 成功后,子系统指针应指向 cgroup_subsys_state 类型的结构(通常嵌入更大的子系统特定对象中),该结构将由cgroup系统初始化。 注意,这将在初始化时被调用以为该子系统创建根子系统状态。 这种情况可以通过传递的具有NULL父级的cgroup对象(因为它是层次结构的根)来识别,并且可能是初始化代码的适当位置。

    int css_online(struct cgroup *cgrp) //(cgroup_mutex held by caller)

    在 @cgrp 成功完成所有分配并且对 cgroup_for_each_child / descendant_*() 迭代器可见之后调用。 子系统可以通过返回-errno来选择创建失败。 此回调可用于实现可靠的状态共享和沿层次结构的传播。 有关详细信息,请参见 cgroup_for_each_descendant_pre()上的注释。

    void css_offline(struct cgroup *cgrp); //(cgroup_mutex held by caller)

    这与 css_online() 对应,并且称为 iff css_online()在 @cgrp上成功。 这表示 @cgrp 结尾的开始。@cgrp被删除,子系统应该开始删除它在@cgrp上持有的所有引用。 删除所有引用后,cgroup删除将继续进行下一步 css_free(). 在此回调之后,@cgrp应该被认为对子系统无效。

    void css_free(struct cgroup *cgrp) //(cgroup_mutex held by caller)

    cgroup系统将释放@cgrp; 子系统应释放其子系统状态对象。 到调用此方法时,@cgrp完全未使用。 @cgrp->parent仍然有效。(注意-如果为新的cgroup调用了该子系统的create()方法后发生错误,也可以为新创建的cgroup调用)。

    int can_attach(struct cgroup *cgrp, struct cgroup_taskset *tset) //(cgroup_mutex held by caller)

    在将一个或多个任务移入cgroup之前调用; 如果子系统返回错误,则会中止附加操作。 @tset包含要附加的任务,并保证其中至少包含一个任务。

    如果任务集中有多个任务,则:
      - 确保所有线程都来自同一线程组
      - @tset包含线程组中的所有任务,无论它们是否切换cgroup
      - 第一项任务是领导者

    每个@tset条目还包含任务的旧cgroup,并且可以使用 cgroup_taskset_for_each() 迭代器轻松跳过未切换cgroup的任务。 请注意,这不是在fork上调用的。 如果此方法返回0(成功),则在调用方持有 cgroup_mutex 的同时应保持有效,并确保将来会调用attach() 或cancel_attach() 。

    void css_reset(struct cgroup_subsys_state *css) //(cgroup_mutex held by caller)

    可选操作,应将@css的配置恢复到初始状态。 当前仅当通过“ cgroup.subtree_control”在cgroup上禁用子系统时,才在统一层次结构上使用它,但应保持启用状态,因为其他子系统依赖于该子系统。 cgroup核心通过删除关联的接口文件使这种css不可见,并调用此回调,以便隐藏的子系统可以返回到初始中立状态。 这样可以防止来自隐藏的CSS的意外资源控制,并确保该配置在以后再次可见时处于初始状态。

    void cancel_attach(struct cgroup *cgrp, struct cgroup_taskset *tset) //(cgroup_mutex held by caller)

    can_attach()成功后,任务附加操作失败时调用。 其can_attach()具有一些副作用的子系统应提供此功能,以便该子系统可以实现回滚。 如果没有,则没有必要。 仅对can_attach()操作成功的子系统进行调用。 参数与can_attach()相同。

    void attach(struct cgroup *cgrp, struct cgroup_taskset *tset) //(cgroup_mutex held by caller)

    在将任务附加到cgroup之后调用,以允许任何需要内存分配或阻塞的附加后活动。 参数与can_attach()相同。

    void fork(struct task_struct *task) //将任务forked到cgroup中时调用。
    
    void exit(struct task_struct *task) //在任务退出期间调用。
    
    void free(struct task_struct *task) //释放task_struct时调用。
    void bind(struct cgroup *root) //(cgroup_mutex held by caller)

    当cgroup子系统重新绑定到其他层次结构和根cgroup时调用。 当前,这仅涉及默认层次结构(从不具有子cgroup)和正在创建/销毁的层次结构(因此不具有子cgroup)之间的移动。

    4.扩展属性用法
    ==========================

    cgroup文件系统在其目录和文件中支持某些类型的扩展属性。 当前支持的类型是:

    -Trusted(XATTR_TRUSTED)
    -Security(XATTR_SECURITY)

    两者都需要设置 CAP_SYS_ADMIN 功能。

    像在tmpfs中一样,cgroup文件系统中的扩展属性是使用内核内存存储的,建议将使用量保持在最低水平。 这就是为什么不支持用户定义的扩展属性的原因,因为任何用户都可以这样做,并且值大小没有限制。

    SELinux的当前已知用户是SELinux,用于限制容器中的cgroup使用情况和systemd来限制各种元数据,例如cgroup中的主PID(systemd为每个服务创建一个cgroup)。

    5.问题
    ============

    问:这个'/bin/echo'是怎么回事?
    答:bash的内置“echo”命令不会检查对write()的调用是否有错误。 如果在cgroup文件系统中使用它,
    您将无法确定命令是成功还是失败。

    问:当我附加进程时,只有第一行才真正被附加!
    答:我们每次调用write()只能返回一个错误代码。 因此,您还应该只放置一个PID。

  • 相关阅读:
    WinCE NAND flash
    正确选择报表工具的十大标准
    从技术岗位走向管理岗位:机会是留给有准备的人
    创业失败的七个原因及解决之道
    技术人员如何参与产品设计讨论:激活那一潭死水
    基于Android Studio搭建hello world工程
    基于Android Studio搭建Android应用开发环境
    JS数组去重的6种算法实现
    八款前端开发人员更轻松的实用在线工具
    海市蜃楼-嘉兴外蒲岛奇遇
  • 原文地址:https://www.cnblogs.com/hellokitty2/p/14227678.html
Copyright © 2020-2023  润新知