从源码角度看Linux线程是怎么创建出来的

news2024/11/23 1:07:49

这篇文章来学习一下线程的创建过程。

线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。

用户态创建线程

pthread_create 不是一个系统调用,是 glibc 库的一个函数,位于 nptl/pthread_create.c 中:

int __pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg)
{
......
}
versioned_symbol (libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);

那这个函数到底做了什么呢?

  • 首先是线程的属性参数,如果没有传入线程属性,就取默认值:
const struct pthread_attr *iattr = (struct pthread_attr *) attr;
struct pthread_attr default_attr;
if (iattr == NULL)
{
  ......
  iattr = &default_attr;
}
  • 接下来,就像在内核里一样,每一个进程或者线程都有一个 task_struct 结构,在用户态也有一个用于维护线程的结构,就是这个 pthread 结构:
struct pthread *pd = NULL;
  • 凡是涉及到函数的调用,都要使用自己的栈。每个线程都有自己的栈。因此需要创建线程栈:
int err = ALLOCATE_STACK (iattr, &pd);
  • ALLOCATE_STACK 是一个宏,我们找到它的定义之后,发现它其实是一个函数:
    • 如果你在线程属性里面设置过栈的大小,需要你把设置的值拿出来
    • 为了防止栈的访问越界,在栈的末尾会有一块空间 guardsize,一旦访问到这里就错误了
    • 其实线程栈是在进程的堆里面创建的。如果一个进程不断的创建和删除线程,我们不可能不断地去申请和清除线程栈使用的内存块,这样就需要有一个缓存。get_cached_stack 就是根据计算出来的 size 的大小,看看已经有的缓存中,有没有已经能够满足条件的
    • 如果缓存里面没有,就需要调用 __mmap 创建一块新的(如果要在堆里面 malloc 一块内存,比较大的话,就用 __mmap)
    • 线程栈也是自顶而下生长的,在栈底的位置,其实是地址的最高位
    • 每个线程都要有一个 pthread 结构,这个结构也是放在栈的空间里面的
    • 计算出 guard 内存的位置,调用 setup_stack_prot 设置这块内存的是受保护的
    • 接下来,开始填充 pthread 这个结构里面的成员变量 tackblock、stackblock_size、guardsize、specific。这里的 specific 是用于存放 Thread Specific Data 的,也即属于线程的全局变量
    • 将这个线程栈放到 stack_used 链表中。
    • 其实管理线程栈总共有两个链表,一个是 stack_used,也就是这个栈正在被使用;另一个是 stack_cache,一旦线程结束,先缓存起来,不释放,等有其他的线程创建的时候,给其他的线程用
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)

static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
                ALLOCATE_STACK_PARMS)
{
  struct pthread *pd;
  size_t size;
  size_t pagesize_m1 = __getpagesize () - 1;
......
  size = attr->stacksize;
......
  /* Allocate some anonymous memory.  If possible use the cache.  */
  size_t guardsize;
  void *mem;
  const int prot = (PROT_READ | PROT_WRITE
                   | ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
  /* Adjust the stack size for alignment.  */
  size &= ~__static_tls_align_m1;
  /* Make sure the size of the stack is enough for the guard and
  eventually the thread descriptor.  */
  guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;
  size += guardsize;
  pd = get_cached_stack (&size, &mem);
  if (pd == NULL)
  {
    /* If a guard page is required, avoid committing memory by first
    allocate with PROT_NONE and then reserve with required permission
    excluding the guard page.  */
 mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,
   MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
    /* Place the thread descriptor at the end of the stack.  */
#if TLS_TCB_AT_TP
    pd = (struct pthread *) ((char *) mem + size) - 1;
#elif TLS_DTV_AT_TP
    pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);
#endif
    /* Now mprotect the required region excluding the guard area. */
    char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);
    setup_stack_prot (mem, size, guard, guardsize, prot);
    pd->stackblock = mem;
    pd->stackblock_size = size;
    pd->guardsize = guardsize;
    pd->specific[0] = pd->specific_1stblock;
    /* And add to the list of stacks in use.  */
    stack_list_add (&pd->list, &stack_used);
  }
  
  *pdp = pd;
  void *stacktop;
# if TLS_TCB_AT_TP
  /* The stack begins before the TCB and the static TLS block.  */
  stacktop = ((char *) (pd + 1) - __static_tls_size);
# elif TLS_DTV_AT_TP
  stacktop = (char *) (pd - 1);
# endif
  *stack = stacktop;
...... 
}

搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。

 资料直通车:Linux内核源码技术学习路线+视频教程内核源码

学习直通车:Linux内核源码内存调优文件系统进程管理设备驱动/网络协议栈

内核态创建任务

接下来,我们接着 pthread_create 看。其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题

pd->start_routine = start_routine;
pd->arg = arg;
pd->schedpolicy = self->schedpolicy;
pd->schedparam = self->schedparam;
/* Pass the descriptor to the caller.  */
*newthread = (pthread_t) pd;
atomic_increment (&__nptl_nthreads);
retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);

start_routine 就是我们给线程的函数,start_routine 、start_routine 的参数 arg,以及调度策略都要赋值给 pthread。

接下来 __nptl_nthreads 加 1,说明又多了一个线程。

真正创建线程的是调用 create_thread 函数,这个函数定义如下:

static int
create_thread (struct pthread *pd, const struct pthread_attr *attr,
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
{
  const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);
  ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid);
  /* It's started now, so if we fail below, we'll have to cancel it
and let it clean itself up.  */
  *thread_ran = true;
}

这里面有很长的 clone_flags 需要特别关注一下。

然后就是 ARCH_CLONE ,其实就是调用 __clone(如果对于汇编不太熟悉也没关系,重点看注释)

 define ARCH_CLONE __clone 
 
/* The userland implementation is:
   int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
   the kernel entry is:
   int clone (long flags, void *child_stack). 

   The parameters are passed in register and on the stack from userland:
   rdi: fn
   rsi: child_stack
   rdx: flags
   rcx: arg
   r8d: TID field in parent
   r9d: thread pointer
%esp+8: TID field in child 

   The kernel expects:
   rax: system call number
   rdi: flags
   rsi: child_stack
   rdx: TID field in parent
   r10: TID field in child
   r8:  thread pointer  */
 
        .text
ENTRY (__clone)
        movq    $-EINVAL,%rax
......
        /* Insert the argument onto the new stack.  */
        subq    $16,%rsi
        movq    %rcx,8(%rsi) 
 
        /* Save the function pointer.  It will be popped off in the
           child in the ebx frobbing below.  */
        movq    %rdi,0(%rsi)
 
 
        /* Do the system call.  */
        movq    %rdx, %rdi
        movq    %r8, %rdx
        movq    %r9, %r8
        mov     8(%rsp), %R10_LP
        movl    $SYS_ify(clone),%eax
......
        syscall
......
PSEUDO_END (__clone)

我们能看到最后调用了 syscall,这一点 clone 和其他系统调用几乎一模一样,但是也有一些不一样的地方:

  • 如果在进程的主线程里面调用了其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用 clone 的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
  • 但是对于线程来说,这些都要变。因为我们希望当 clone 这个系统调用成功的时候,除了内核里面有这个线程对应的task_struct,当系统调用1返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该执行线程的栈,指令指针应该指向线程将要执行的那个函数
  • 所以这些都要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里面弹出来的时候,就从这个函数开始,带着这些参数执行下去。

接下来我们就要进入内核了。内核里面对于 clone 系统调用的定义是这样的:

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
   int __user *, parent_tidptr,
   int __user *, child_tidptr,
   unsigned long, tls)
{
 return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}

可以看到,调用了 _do_fork,先前我们已经看过了一遍它的主要逻辑,现在我们重点关注几个区别。

第一个是上面复杂的标识位设定,我们来看都影响了什么。

  • 对于 copy_files,原来是调用 dup_fd 复制一个 files_struct 的,现在因为 CLONE_FILES 标识位变成将原来的files_struct 引用计数加 1
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
 struct files_struct *oldf, *newf;
 oldf = current->files;
 if (clone_flags & CLONE_FILES) {
  atomic_inc(&oldf->count);
  goto out;
 }
 newf = dup_fd(oldf, &error);
 tsk->files = newf;
out:
 return error;
}
  • 对于 copy_fs,原来是调用 copy_fs_struct 复制一个 fs_struct,现在因为 CLONE_FS 标识位,变成将原来的 fs_struct 的用户数加 1
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
 struct fs_struct *fs = current->fs;
 if (clone_flags & CLONE_FS) {
  fs->users++;
  return 0;
 }
 tsk->fs = copy_fs_struct(fs);
 return 0;
}
  • 对于 copy_sighand,原来是创建一个新的 sighand_struct,现在因为 CLONE_SIGHAND 标识位变成将原来的sighand_struct 引用计数加 1
static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
{
 struct sighand_struct *sig;
 
 
 if (clone_flags & CLONE_SIGHAND) {
  atomic_inc(&current->sighand->count);
  return 0;
 }
 sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
 atomic_set(&sig->count, 1);
 memcpy(sig->action, current->sighand->action, sizeof(sig->action));
 return 0;
}
  • 对于 copy_signal,原来是创建一个新的 signal_struct,现在因为 CLONE_THREAD 直接返回了
static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
{
 struct signal_struct *sig;
 if (clone_flags & CLONE_THREAD)
  return 0;
 sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
 tsk->signal = sig;
    init_sigpending(&sig->shared_pending);
......
}
  • 对于 copy_mm,原来是调用 dup_mm 复制一个 mm_struct,现在因为 CLONE_VM 标识位而直接指向了原来的mm_struct
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
 struct mm_struct *mm, *oldmm;
 oldmm = current->mm;
 if (clone_flags & CLONE_VM) {
  mmget(oldmm);
  mm = oldmm;
  goto good_mm;
 }
 mm = dup_mm(tsk);
good_mm:
 tsk->mm = mm;
 tsk->active_mm = mm;
 return 0;
}

第二个是对于亲缘关系的影响,毕竟我们要识别多个线程是不是属于一个进程。

p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
 p->exit_signal = -1;
 p->group_leader = current->group_leader;
 p->tgid = current->tgid;
} else {
 if (clone_flags & CLONE_PARENT)
  p->exit_signal = current->group_leader->exit_signal;
 else
  p->exit_signal = (clone_flags & CSIGNAL);
 p->group_leader = p;
 p->tgid = p->pid;
}
 /* CLONE_PARENT re-uses the old parent */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
 p->real_parent = current->real_parent;
 p->parent_exec_id = current->parent_exec_id;
} else {
 p->real_parent = current;
 p->parent_exec_id = current->self_exec_id;
}

从上面可以看出,使用了 CLONE_THREAD 标识位之后,使得亲缘关系有了一定的变化。

  • 如果是新进程,那这个进程的 group_leader 就是它自己,tgid 就是它自己的 pid,也就是说,这就完全重打锣鼓另开张了,自己是线程组的头;如果是新线程,group_leader 是当前进程的 group_leader,tgid 是当前进程的 tgid,也就是当前进程的 pid,这个时候还是拜原来进程为老大。
  • 如果是新进程,新进程的 real_parent 就是当前的进程,也就是新进程是子辈;如果是新线程,线程的 real_parent 是当前的进程的 real_parent,其实是平辈的。

第三,对信号的处理,如何保证发给进程的信号虽然可以被一个线程处理,但是影响范围应该是整个进程的。比如,kill一个进程,则所有线程都要被干掉。如果 pthread_kill 一个线程,那只有那个线程才能够收到。

  • 在 copy_process 的主流程里面,无论是创建进程还是线程,都会初始化 struct sigpending pending,也就是每个task_struct,都会有这样一个成员变量。这就是一个信号列表。如果这个 task_struct 是一个线程,这里面的线程就是发给这个线程的;如果 task_struct 是一个进程,那这里面的信号是发给主线程的。
init_sigpending(&p->pending);
  • 另外,上面 copy_signal 的时候,我们可以看到,在创建进程的过程中,会初始化 signal_struct 里面的 struct sigpending shared_pending。但是,在创建线程的过程中,连 signal_struct 都共享了。也就是说,整个进程里的所有线程共享一个 shard_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样
init_sigpending(&sig->shared_pending);

至此,clone 在内核的调用完毕,要返回系统调用,回到用户态。

用户态执行线程

根据 clone 的第一个参数,回到用户态也不是直接运行我们指定的那个函数,而是一个通用的 start_thread,这是所有线程在用户态的统一入口

#define START_THREAD_DEFN \
  static int __attribute__ ((noreturn)) start_thread (void *arg)

 
START_THREAD_DEFN
{
    struct pthread *pd = START_THREAD_SELF;
    /* Run the code the user provided.  */
    THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
    /* Call destructors for the thread_local TLS variables.  */
    /* Run the destructor for the thread-local data.  */
    __nptl_deallocate_tsd ();
    if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
        /* This was the last thread.  */
        exit (0);
    __free_tcb (pd);
    __exit_thread ();
}

在 start_thread 入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释放这个线程相关的数据。比如,线程本地数据 thread_local ,线程数目也减少 1。如果这是最后一个线程了,就直接退出进程,另外 __free_tcb 用于释放 pthread

void
internal_function
__free_tcb (struct pthread *pd)
{
  ......
  __deallocate_stack (pd);
}
 
 
void
internal_function
__deallocate_stack (struct pthread *pd)
{
  /* Remove the thread from the list of threads with user defined
     stacks.  */
  stack_list_del (&pd->list);
  /* Not much to do.  Just free the mmap()ed memory.  Note that we do
     not reset the 'used' flag in the 'tid' field.  This is done by
     the kernel.  If no thread has been created yet this field is
     still zero.  */
  if (__glibc_likely (! pd->user_stack))
    (void) queue_stack (pd);
}

__free_tcb 会调用 __deallocate_stack 来释放整个线程栈,这个线程栈要从当前使用线程栈的列表 stack_used 中拿下来,放到缓存的线程栈列表 stack_cache 中。

至此,整个线程的生命周期结束。

总结

下表对比了创建进程和创建线程在用户态和内核态的不同。

  • 创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。
  • 创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面,五大结构仅仅是引用计数加一,也就是线程共享进程的数据结构

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/631070.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

redis商户查询缓存

1 什么是缓存? 前言:什么是缓存? 就像自行车,越野车的避震器。 举个例子:越野车,山地自行车,都拥有"避震器",防止车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样; 同样,实际开发中,系统也需要"避震器",防止过…

Unity Shader - 兰伯特漫反射

兰伯特漫反射公式: 漫反射(Diffuse) 光源颜色 * max(0,cos(光方向和法线的夹角)) 公式原理: 从上面图片可以看出光照方向 L 与物体法相 N形成的 余弦值越大,反…

力扣笔记(每日随机一题)—— 打折购买糖果的最小开销

问题(简单) 一家商店正在打折销售糖果。每购买 两个 糖果,商店会 免费 送一个糖果。 免费送的糖果唯一的限制是:它的价格需要小于等于购买的两个糖果价格的 较小值 。 比方说,总共有 4 4 4 个糖果,价格…

开源代码分享(3)—微电网鲁棒定价策略(附matlab代码)

1背景介绍 1.1摘要 本论文聚焦于微电网中的能量失衡管理问题,并从电力市场的角度进行研究。与传统电力网不同,微电网可从可再生能源(RES)如太阳能电池板或风力涡轮机等获得额外能源。然而,来自RES的随机输入给平衡供需…

简述Vue的生命周期以及每个阶段做的事情

03_简述Vue的生命周期以及每个阶段做的事情 思路 给出概念 列举出生命周期各个阶段 阐述整体流程 结合实际 扩展:vue3变化 回答范例 每个vue组件实例被创建后都会经过一系列步骤。比如它需要数据观测、模板编译、挂载实例到dom、以及数据变化的时候更新dom、…

Android系统的启动过程(三):Launcher启动过程

Android系统的启动过程(三):Launcher启动过程 摘要&概述 前两篇文章中我们已经将系统启动的过程推进到了系统服务启动完毕之后,本篇文章就来介绍Android系统启动的最后一步:启动Launcher。 这个Launcher我们可以通俗地理解为桌面&#…

深度相机介绍

一、什么是深度相机 (五)深度相机:结构光、TOF、双目相机 - 知乎 传统的RGB彩色普通相机称为2D相机,只能拍摄相机视角内的物体,没有物体到相机的距离信息,只能凭感觉感知物体的远近,没有明确的数…

V90 PN伺服驱动器转矩控制(750报文)

主要介绍通过标准报文加附加报文 750 实现发送驱动报文的控制字、速度给定、转矩限幅及附加转矩给定的功能,首先就是V90在博途环境下的组态,安装GSD文件,GSD文件下载地址如下: https://download.csdn.net/download/m0_46143730/86542047https://download.csdn.net/downloa…

Qt线程的几种使用方法

目录 引言使用方法重写QThread::run()moveToThreadQRunnable使用QtConcurrent使用 完整代码 引言 多线程不应该是一个复杂而令人生畏的东西,它应该只是程序员的一个工具,不应该是调用者过多记忆相关概念,而应该是被调用方应该尽可能的简化调…

Java网络开发(Tomcat)——登陆和注册功能 的 迭代升级 从Jsp到JavaScript + axios + vue 同步到异步

目录 引出前置工作vueaxiosresp0.vue版本的jsp模板1.导包--Json:pom.xml文件:2.新建一个专门用来处理响应的实体类ResData3.在axios中,所有响应必须是 resp.getWriter().write() 的方式,核心代码如下4.在jsp前端代码中导包&#x…

浅谈一级机电管道设计中的压力与介质温度

管道设计是工程设计中的一个非常重要的部分,管道的设计需要考虑到许多因素,其中就包括管道设计压力分类和介质温度分类。这两个因素是在设计管道时必须非常严格考虑的, 首先是管道设计压力分类。在管道设计中,根据工作要求和要传输…

详解 Ansible 自动化运维,提升工作效率

概要 Ansible 是一个模型驱动的配置管理器,支持多节点发布、远程任务执行。默认使用 SSH 进行远程连接。无需在被管理节点上安装附加软件,可使用各种编程语言进行扩展。 一、Ansible基本架构 上图为ansible的基本架构,从上图可以了解到其由以…

算法刷题-关于链表,你该了解这些!

关于链表,你该了解这些! 什么是链表,链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域…

mybatis-plus分页查询(springboot中实现单表和多表查询)

一、mybatis-plus单表查询 使用mybatis-plus实现单表分页查询 非常方便,主要操作步骤如下: 配置分页查询拦截器进行分页查询 1.首先,打开mybatis-plus官网的插件(插件主体) 或者点击mybatis-plus插件 我是配置在s…

KameAI:探索AI驱动的未来,体验聊天GPT与AI绘画的奇妙世界

人工智能的崛起与发展随着科技的飞速发展,人工智能(AI)已经逐渐成为我们生活中不可或缺的一部分。它的出现不仅改变了我们与世界的互动方式,还为各行各业带来巨大的便利。今天,我们就来聊一聊一个类似ChatGPT的人工智能网站—KameAI&#xff…

Nautilus Chain全球行分享会,上海站圆满举办

在北京时间 6 月 9 日,由 Nautilus Chain 主办的“Layer3 模块化区块链的发展探讨”为主题的全球行活动,在上海顺利举办,本次分享会联合主办方还包 括 Stanford Blockchain Accelerator、Zebec Protocol、Tiger VC DAO、Crypto PHD、Rootz L…

Nginx【反向代理负载均衡动静分离】--上

Nginx【反向代理负载均衡动静分离】–上 先看2 个实际需求,引出Nginx 需求1: 访问不同微服务 示意图 需求2: 轮询访问服务 示意图 解决方案: Nginx 反向代理 负载均衡 动静分离 高可用集群 Nginx 在分布式微服务架构的位置 基本介绍 Nginx 是什么? 能干什…

solr快速上手:配置IK中文分词器(七)

0. 引言 solr作为搜索引擎,常用在我们对于搜索速度有较高要求且大数据量的业务场景,我们之前已经配置过英文分词器,但是针对中文分词不够灵活和实用,要实现真正意义上的中文分词,还需要单独安装中文分词器 solr快速上…

【shell 基础13】输入输出与重定向

文章目录 一. 标准输入和标准输出二、重定向1. 定义2. 输出的重定向3. 对标准错误输出重定向4. 输入的重定向 一. 标准输入和标准输出 linux中有三种标准输入输出,分别是STDIN,STDOUT,STDERR,文件描述符分别是 0、1、2。 当运行…

Android Paging3分页+ConcatAdapter+空数据视图+下拉刷新(SwipeRefreshLayout)+加载更多+错误重试 (示例)

文章目录 引入库数据模型定义分页 adapter加载更多 adapter空数据 adapter分页数据源ViewModel 提供加载数据源的方法结合以上实现的 Fragment数据重复问题 引入库 implementation androidx.paging:paging-runtime-ktx:3.1.1paging 库,目前还是有点小bug &#xff…