zoukankan      html  css  js  c++  java
  • 进程、轻量级进程(LWP)、线程

    进程、轻量级进程(LWP)、线程

    • 进程:程序执行体,有生命期,用来分配资源的实体
    • 线程:分配CPU的实体。
      •   用户空间实现,一个线程阻塞,所有都阻塞。
      •   内核实现,不会所用相关线程都阻塞。用LWP实现,用线程组表示这些线程逻辑上所属的进程。

    进程描述符

    • 进程描述符(简称pd, process descriptors),结构体是:task_struct
      •   数据较多,存放在kenerl的动态内存空间。
      •   pd的引用放在thread_info中,
        •    thread_info与内核栈,放在一个8K空间(它的地址8K对齐)。内核程序使用的栈空间很小。
        •    thread_info在底部,内核栈在顶部向下增长。
          •     好处:多CPU时方便,每个CPU根据自己的栈指针就可以找到当前的pd (以后用current表示当前CPU运行的进程描述符)。
            •      esp(内核栈指针)低8位置零,就是thread_info地址。
        •    每进程有自己的thread_info, (分配释放函数: alloc_thread_info, free_thread_info)
    • 描述符的内容
      •   相关的ID (一个4元素数组)
        •    进程ID (PID)
          •     PID按创建顺序连续增长,到最大值后从最小值开始。
          •     0号进程:交换进程(swapper)
          •     有PID可用位图,表示那一个PID可用,至少占一个页。
        •    线程组ID(tgid),用LWP实现多线程支持
          •     多进程时,进程id,就是线程组id, 也就是组长的pid(LWP)。 getpid() 取的是线程组的id(tgid), 也是组长的pid.
          •     单线程时,pid = gid。所以getpid,也是真正的pid.
        •    进程组ID(pgrp)。
        •    回话的ID(session).
          •     组ID,都是组长的PID。FIXME: 但pb也有各组长的PID
            •      线程组长:tgid
            •      进程组长:signal->pgrp ,
            •      会话长:signal->session
        •    管理ID数据结构——哈希表管理 (利用id找到所用相关的pd,方便)。
          •     一个哈希表数组(pid_hash),存放四个哈希表, 每一个表代表一类id (pid, tgid, pgrp, session)
          •     每个哈希表的由数组(索引为哈希值)和二维链表(嵌入到进程描述符内的pids中)实现
            •      第一维链表:哈希冲突链表。
            •      第二维链表:要查找的值相同的链表, 叫per-PID list(同一组的所有线程,同一组的所有进程,同一会话的所有进程);
        •    进程组ID(pgrp), 回话ID(session)在共享信号的数据结构里。因为同一进程内的所有LWP,这两个ID都是一样的
    •  
      •   家族关系:由pd里的链表(下级)和pd指针(上级)实现
        •    关系:
          •     亲生父亲:创建自己的进程,或是托孤进程(创建自己的进程死了)。
          •     父亲:自己死时要发信号告知的。一般是亲生父亲,有时是监控自己的进程 (调用ptrace)
          •     孩子:
          •     兄弟:
        •    监控(自己起的名字,类似于监护。由于管理方式相同,也归为家族关系)
          •     监控的进程列表:ptrace_children
          •     被监控的其他进程:ptrace_list (类似于被监控的兄弟)
        •    在链表里为了管理方便:
          •     最大儿子的兄弟是父亲
          •     最小儿子的弟弟也是父亲
          •     父亲保管最大儿子,和最小儿子
    •  
      •   进程资源及资源限制:
        •    CPU相关:
          •     占用CPU总时间
          •     用户的最大进程数
        •    内存相关:
          •     进程地址空间
          •     锁住内存大小
          •     进程页数 (只有记录,没有限制)
          •     堆大小,栈大小
        •    资源相关:
          •     文件:
            •      core dump大小
            •      最大文件大小
            •      打开文件个数
          •     进程同步与通信
            •      锁数目,
            •      悬挂信号数据
            •      在消息列队中占的大小
        •    相关数据结构 和 处理流程
          •     pd->sigal->rlim 是一个表示进程资源使用情况以及限制的结构 的数组。
          •     表示进程资源使用情况以及限制的结构:包含当前值,最大值两个数值。
        •    只有超级用户才能增大资源限制。
        •    一般用户登陆时:
          •     kernel创建root进程,减少limit,
          •     建一个 shell子进程,继承limit.
          •     把shell进程的用户,改成登陆的那个用户
    •  
      •   进程状态(state)
        •    运行,TASK_RUNNING
          •     组织pd的结构:就绪进程链:
            •      一个CPU一组链表,每个链表表示一种优先级。
        •    阻塞
          •     可中断阻塞,TASK_INTERRUPTIBLE
            •      可被硬件中断,“释放资源”事件,信号唤醒。
          •     不可中断阻塞,TASK_UNINTERRUPTIBLE
            •      可被硬件中断,“释放资源”事件,唤醒。
            •      但不能被信号唤醒。可用于驱动程序中。
          •     组织pb的结构:等待列队: 每一类事件一个列队,用内嵌链表实现(虽然没列出内嵌链表节点)
            •      列队头:
              •       自旋锁:防止有一个主函数和中断函数同时操作列队。
            •      列队节点:
              •       独占标志:表示该进程是否要独占资源 (不再唤醒别的进程)
              •       指向pd的指针
              •       用于唤醒进程的回调函数。(提供进程的执行机会,是否操作等待列队由用户决定)
        •    停止
          •     停止TASK_STOPPED
            •      被信号停止
          •     追踪TASK_TRACED
            •      该进程被一个调试进程监控以后,收到任何一个信号就进入该状态
          •     组织pb的结构:FIXME: 信号的等待列队?
        •    退出
          •     退出_僵尸EXIT_ZOMBIE
            •      进程终止,资源没有被回收(父进程要用,没有调wait系列函数)
          •     退出_死亡EXIT_DEAD
            •      进程终止,资源正在被回收(父进程要用,没有调wait系列函数)。
            •      一旦资源回收完成,进程描述符也就被回收了。
            •      它防止该进程再次被wait.
          •     组织pb的结构:不挂到队列上,只在家族关系中,等待父进程收回资源

    进 程控制 :

    • 阻塞(current阻塞到某个列队上):
      •   基本流程
        •    临时生成一个列队节点,初始化。
        •    改变current的状态,放入节点,挂到列队上。
        •    调度 (=====》至此,阻塞完成。 一旦被别的进程唤醒====》从调度函数中返回)
        •    从等待列队上摘除节点。
      •   变化:
        •    将挂列队、调度、从列队删除三步拆开,便于灵活处理。
        •    可中断的、限时、独占的函数类似。只不过进程状态、调度函数、独占标志不同。
        •    非独占的从列队开始添加,独占的从末尾添加。(但一个列队内既有独占的,又有非独占的等待进程,很少见)
    • 唤醒:
      •   基本流程
        •    唤醒一个进程:调用节点里的回调函数
        •    唤醒的时候从列队开头依次唤醒,直到唤醒一个独占的后停止。
      •   变化
        •    是否只唤醒可中断的进程. (_interruptible后缀)
        •    唤醒的独占进程的数目(1个,多个(_nr后缀),所有(_all后缀))
        •    唤醒后是否不检查优先级,马上给予CPU (有_sync的不检查优先级)。
    • 进程切换
      •   切换pgd (全局页目录),此章不讨论。
      •   切换内核栈,硬件上下文
        •    硬件上下文,就是CPU的寄存器。
          •     一部分(大多数CPU寄存器(除了通用寄存器))在pd中保存(task_struct->thread, 类型是thread_struct),
          •     一部分(通用寄存器)保存在内核栈中.
        •    原来用硬件指令()保存CPU信息。后来改成软件(一个个MOV指令)
          •     容易控制,可以挑选信息保存,便于优化。不保存的做其他用(如:进程间传递)
            •           far jmp:跳至目标进程的TSSD。而linux是每个CPU一个TSS,不是每进程一个
          •     对于一些寄存器(ds、es)可以检查值。
          •     与用硬件指令保存时间差不多。
      •   switch_to 宏
        •    三个参数:
          •     prev: 要换走的进程,一般是当前进程
          •     next: 要换到的进程。
          •     last: 传出参数。当前进程再次被换到时,最后一个占用CPU的进程。(prev指向的进程 就是 next指向的进程 的last)
        •    步骤:
          •     栈切换, 完成后就是在新进程的上执行了:
            •      保存prev(放在eax)
            •      eflags,ebp入内核栈;
            •      保存并装载新的esp (旧的esp放到prev->thread.esp,新的esp是next->thread.esp)
              •       此时current就是新的esp所指的thread_info内的task指针
          •     设置返回地址:
            •      prev进程以后得到执行时的__switch_to的返回地址: __switch_to后的第一条指令, 放入prev->thread.eip,
            •      准备next进程的从__switch_to返回的地址: next->thread.eip入栈.
          •     调用__switch_to ()函数,该函数动作如下:
            •      更新CPU的相关信息(tss和gdt):
              •       存next->thread.esp0(内核栈低)到本地TSS.esp0中。
              •       所在CPU的全局段表里的TLS段, 设成next进程的.
              •       更新tss的I/O位图.
            •      更新CPU的寄存器(pd->thread (tss) 与 CPU寄存器交换数据):
              •       保存FPU, MMX, XMM寄存器, 先不装载以后需要时通过中断装载(TODO: )
              •       保存prev的fs, gs寄存器. 装载next的
              •       装载next的debug寄存器(debug寄存器一个8个, 进程切换时只需6个)
            •      返回
              •       prev放入eax (prev就是新进程的last)
              •       ret
          •     ret返回的地址: (__switch_to之前被存入栈中, __switch_to ret时进入eip)
            •      如果是next新进程, next->thread.eip是iret_from_fork.
            •      如果next不是新进程:
              •       弹出ebp, elfags
              •       把eax放入last变量 (prev就是next进程的last)

    任务状态段(一个存CPU状态的数组,tss_struct init_tss[])

    •     每个CPU用段上的一个元素。(FIXME: 用于:用户模式要进入内核模式时,设置相应寄存器)
      •       TSS上存内核栈地址。CPU上的程序从用户模式转到内核模式,设置esp。
      •       TSS存I/O端口许可位图。用户模式程序用到I/O时,检查有无权限
      •       所以,进程切换时,要保存的寄存器在pd->thread中。
        •         thread_struct不是thread_info。thread_info中只有少量的数据或指针, 用于通过esp快速定位数据
    •     进程切换时,更新TSS上的信息。
      •       CPU控制单元再从TSS上取需要的信息。
      •       即反应了CPU的当前进程情况,又不需要维护所有进程的状态数据。
    •     TSS的描述符在GDT里。
      •       TSSD:任务状态段描述符 (其实应该叫任务状态描述符,每个TSSD,表示一个CPU的状态, FIXME: :具体以源码为准)
      •       CPU原始设计,每个进程一个TSS元素。
      •       linux设计,每个CPU一个TSS元素。
      •       cpu里的tr寄存器,保存着自己的TSSD(即init_ttss[cpu_id]),不用总上gdt里去取。
      • 进程创建: clone, fork, vfork系统调用
      •   clone系统调用
        •    参数:
          •     执行函数(fn), 参数(arg)
          •     flags|死亡时给父进程发的信号 (clone_flags): 以下介绍clone_flags
            •      资源共享
              •       段,页,打开文件共享:
                •        页表(不是页, CLONE_VM),
                •        打开文件(clone_files),
                •        建一个新tls段(clone_settls)
              •       路径和权限设置:
                •        clone_fs: 共享根目录, 当前目录, 创建文件初始权限.
                •        clone_newns: 新的根路径, 自己的视野看文件系统
              •       线程通信
                •        clone_sighand: 信号处理action, 阻塞和悬挂的信号
                •        clone_sysvsem: 共享undoable信号量操作
            •      进程关系
              •       同父: clone_parent 创建进程与新进程是兄弟 (同父), 新进程不是创建进程的子进程
                •        为了方便期间, 以下讨论暂时不考虑这一因素(它很容易实现), 认为创建进程就是父进程
              •       同一个线程组: clone_thread. 属于同一个进程(线程组)
              •       都被trace: clone_ptrace
              •       子进程不被trace: clone_untrace (内核设置, 覆盖clone_ptrace)
            •      返回tid
              •       向父进程返回tid: clone_parent_settid
              •       向子进程返回tid: clone_child_settid
            •      子进程的状态:
              •       子进程开始就stop: clone_stopped
            •      进程死亡或exec通知:
              •       启动内核机制: 如果子进程死亡或exec, 它自己空间内的tid(*ctid)清零, 并唤醒等待子进程死亡的进程.
          •     赋给子进程的资源
            •      子进程的栈(父进程alloc的内存地址)
            •      线程局部仓库段(tls)
          •     返回子进程tid的地址
            •      父进程用户空间内的地址
            •      子进程用户空间的地址
    •   clone, fork, vfork实现方式
      •   大致相同:
        •     系统调用服务例程sys_clone, sys_fork, sys_vfork三者最终都是调用do_fork函数完成.
          •     do_fork的参数与clone系统调用的参数类似, 不过多了一个regs(内核栈保存的用户模式寄存器). 实际上其他的参数也都是用regs取的
      •    区别在于:
        •     clone:
          •      clone的API外衣, 把fn, arg压入用户栈中, 然后引发系统调用. 返回用户模式后下一条指令就是fn.
          •      sysclone: parent_tidptr, child_tidptr都传到了 do_fork的参数中
          •      sysclone: 检查是否有新的栈, 如果没有就用父进程的栈 (开始地址就是regs.esp)
        •     fork, vfork:
          •      服务例程就是直接调用do_fork, 不过参数稍加修改
          •      clone_flags:
            •       sys_fork: SIGCHLD|0;
            •       sys_vfork: SIGCHLD| (clone_vfork | clone_vm)
          •      用户栈: 都是父进程的栈.
          •      parent_tidptr, child_ctidptr都是NULL.
    • 具体实现函数do_fork() (内核函数)的工作流程:
      •   分配PID, 确定子进程到底是否traced.
        •    分配空闲的PID
        •    确定clone_ptrace位. (确定子进程到底要不要被trace, 而不是参数所说的希望被trace)
          •     设置该位: 参数已设该位, 且创建线程被trace中
          •     清除该位: 父进程没有被trace, 或 clone_untrace已经设置.
      •   复制进程描述符(copy_process)
        •    检查clone_flags是否兼容, 是否安全
          •     clone_newns 与 clone_fs 互斥
          •     clone_sighand 是 clone_thread 的必要条件: 线程必须共享信号处理
          •     clone_vm 是 clone_sighand 的必要条件 : 共享信号处理, 首先要共享信号处理的代码(在进程页面里)
          •     附加的安全检查: security_task_create(clone_flags)
        •    复制进程描述符
          •     在父进程的thread_info里保存浮点寄存器: __unlazy_fpu()
          •     分配新的进程pd(alloc_task_struct), 并拷贝父进程pd
          •     分配新的thread_info(alloc_thread_info), 并拷贝父进程的thread_info.
          •     新的thread_info和新分配的pd 相互引, 新pd的引用计数设为2 (表示:新pd有用, 且不是僵尸进程)
        •    相关计数加1: (此处先相关计数检查, 都通过后再都加1)
          •     检查并增加: 用户拥有进程数, 系统总共进程数.
            •      一般来说, 所有进程的thread_info总和, 不超过物理内存的1/8
          •     新进程的可执行格式的引用计数(FIXME: pd里标有可执行个数吗)
          •     系统执行fork总数.
        •    进程pd的关键域的设置(顺序与源码可能不一致):
          •     进程关系
            •      设置父子关系 (parent, real_parent, 考虑被trace的情况)
            •      设置新pd的PID
            •      设置tgid, 线程组长的pd(pd->group_leader). (根据是不是线程组长, 即clone_thread位是否为0)
            •      加入PID哈希表(pid, tgid, 如果是进程组长加入pgid和sid表),(调attach_pid())
            •      拷贝tid到父进程的用户空间(parent_tidptr)
          •     拷贝资源(如果clone_flags没标明共享):
            •      文件,目录,内存:copy_files, copy_mm, copy_namespace,
            •      进程通信: copy_signal, copy_sighand, copy_semundo
          •     设置子进程的内核栈(thread_info), 内核态相关寄存器(thread_struct, 不知道这个结构的具体用处): copy_thread()
            •      子进程的thread_struct:
              •       esp, esp0 - 内核栈顶, 内核栈底
              •       eip - ret_from_fork()的地址 (用户态切到内核态的第一条指令)
              •       I/O许可位图 - 如果父进程有, 就拷贝一份过来
              •       TLS - 如果用户空间提供了TLS段, 拷贝过来
            •      设置子进程的内核栈:
              •       child_regs.esp = 传入的栈地址参数;
              •       child_regs.eax = 0, 给用户态的返回值是0
              •       清除thread_info中的, TIF_SYSCALL_TRACE位, 防止运行ret_from_fork时, 系统通知调试进程
              •       设置子进程的thread_info的cpuid
          •     设置调度信息(sched_fork())
            •      设置task_running状态,
            •      初始化调度参数(时间片),
            •      子进程禁止内核抢占(thread_info.preempt_cout = 1)
          •     其他:
            •      如果没有被trace,pd->ptrace = 0;
            •      设置pd->exit_signal:
              •       有clone_thread位: 设为参数clone_flags中的退出信号
              •       没有clone_thread位: 设为-1 (表示进程终止时, 该LWP不给父进程发信号)
            •      pd->flags: 清除PF_SUPERPRIV , 设置PF_FORKNOEXEC
            •      大内核锁 pd->lock_depth = -1
            •      exec次数: pd->did_exec = 0
            •      拷贝child_tidptr到pd->set_child_tid. 以备子进程开始执行时, 把tid放到自己内存空间的child_tidptr
        •    返回pd
      •   设置父子进程的运行状态, 调度信息
        •    设置子进程的状态.
          •     挂信号: 如果创建出来的是停止(clone_stopped)或被trace(pd->ptrace里有PT_PTRACE位)的进程, 悬挂一个SIGSTOP信号.
            •      只有debugger发出SIGCONT信号后, 才能进入运行状态
          •     设状态,入列队:如果有clone_stopped位, 子进程设为stopped状态; 否则调用wake_up_new_task(), 把子进程加入就绪列队:
            •      调整父进程和子进程的调度参数 (主要是时间片)
            •      如果父子在同一CPU上运行, 且页表不同享, 子进程在插在父进程前
              •       子进程很可能exec, 不与父进程共享页. 这样防止父进程无用的copy on write.
            •      如果不同CPU上运行, 或者共享页表, 子进程放在列队最后
        •    如果父进程处于被调试状态, 程通知调试器
          •     当前进程给debugger进程发信号, 告知自己创建了子进程; 并停止自己(进入traced状态), 使debugger运行.
            •      子进程的pid保存在current->ptrace_message中, 供debugger用
            •      调试器发信号, 使父进程继续后, 再进行下一步; 否则父进程一直处于traced状态
        •    设置父进程状态
          •     如果有clone_vfork, 把自己放到一个等待列队.
            •      内核处理完系统调用后, 会执行调度, 这样就阻塞父进程了.
            •      直到子进程释放了它的内存地址空间, 即子进程终止或exec新程序, 用信号唤醒父进程.
      •   返回子进程的pid.
      •   子进程被调度后,执行pd.thread.eip(ret_from_fork). 调用关系(=>): ret_from_fork=>schedule_tail=>finish_task_switch.
        •    schedule_tail的另一件事就是: 把pid保存到地址pd->set_child_tid (创建进程使的parent_tidptr)
        •    finish_task_switch的动作是: 装载内核栈保存的寄存器(regs->eax为0),返回到用户态。系统调用返回值就是eax(0)
    • 内核线程:
      •   只运行于kernel模式,只能访问大于3G的空间。而普通进程在内核模式时,能访问整个4G空间
      •   创建方法, 类似于clone
        •    准备返回地址fn: 构造一个regs. 里面有fn, args, __KERNEL_CS等. regs->eip是汇编函数kernel_thread_helper
        •    do_fork (flags|CLONE_VM|clone_untraced, 0, &regs, 0, NULL, NULL)
          •     创建线程, 与父进程共享页. 用上步构造的regs初始化新程的内核栈
        •    新线程被调度后. 由ret_from_fork, 用regs恢复寄存器, 开始执行kernel_thread_helper
        •    kernel_thread_helper: 把args压入栈, call fn(args, fn都寄存器中)
      •   典型的内核线程:
        •    进程0: 所有进程的祖先
          •     编译时存在.
            •      pd, 内核栈: init_task, init_thread_union
            •      资源: init_mm, init_files, init_fs.  信号: init_signals, init_sighand
            •      页表: swapper_gd_dir
          •     功能
            •      初始化系统数据,
              •       多CPU系统中, 开始时BIOS禁用其他CPU.
              •       初始化系统数据后, 进程0拷贝自己到其他CPU的调度列队上, 启动其他CPU, 所有的PID都是0.
            •      使能中断
            •      创建内核线程1, (函数是init)
            •      进入idle
        •    进程1:
          •     init函数 exec可执行文件init, 使内核线程变成了普通进程.
          •     管理其他进程, 称为托孤进程
        •    其他内核线程:
          •     执行工作列队:
            •      ksoftirqd: 执行 softlets
            •      kblockd: 执行工作列队 kblockd_workqueue, 定期激活块设备驱动
            •      keventd (又叫events): 处理工作列队 keventd_wq
          •     管理资源:
            •      kapmd: 电源管理
            •      kswapd: 交换进程, 用于回收内存资源
            •      pdflush: flush脏的磁盘缓存

    进程销毁

    • 进程终止
      •   系统调用
        •    整个进程终止: exit_group(), 由do_group_exit处理系统调用. c函数 exit()也是用的这系统调用
        •    某个线程终止: _exit(), 由do_exit处理. C函数中用到此系统调用的API: pthread_exit
      •   do_group_exit流程: (整个组内至少有一个线程调用它, 用于整组协调)
        •    检查线程组的退出过程是否启动: 检查signal_group_exit(线程组内的公共数据)是否非零. 如果没有启动, 执行一下操作来启动退出过程:
          •     设置启动标志signal_group_exit.
          •     存储终止码(exit_group的参数), 在current->signal->group_exit_cold
          •     向其他线程发SIG_KILL信号, (它们收到信号后, 调do_exit())
        •    调用do_exit, 使本线程退出
      •   do_exit流程:
        •    设置线程的终止标志, 退出码
          •     设置PF_EXITING, 标明要被终止
          •     设置pd->exit_code
            •      系统调用参数
            •      或是内核提供的错误码, 表示异常终止
        •    释放资源:
          •     删除该进程的定时器
          •     去除对资源的引用:
            •      exit_mm, __exit_files;
            •      __exit_fs(root路径,工作路径, 创建文件权限), exit_namespace(挂载的文件系统的视野);
            •      exit_thread(thread_struct), exit_sem,
        •    如果这个线程的函数实现了一种可执行格式, 可执行格式数的引用计数--; FIXME: 还没看到这块儿, 凑合翻译的不一定对
        •    改变父子关系, 并向父进程发信号, 改变自己的状态(exit_notify)
          •     托付终止线程创建的子进程:
            •      如果终止线程还有同组线程: 终止线程创建的子进程, 作为与同组线程的子进程.
            •      否则: 终止线程创建的子进程, 作为孤儿进程, 由init进程托管
          •     向父进程发信号
            •      exit_signal有意义 && 最后线程 :  发exit_signal
            •      否则:
              •       被trace : 发SIGCHLD
              •       没被trace : 不发信号
          •     僵尸自己或直接死亡,  并设置PF_DEAD位
            •      exit_signal没意义 && 没被trace : 直接死亡 (这种情况没有发信号)
              •       变成EXIT_DEAD状态,
              •       release_task() (后面介绍). pd引用计数变为1, 不会马上释放
            •      否则: 僵尸
              •       exit_signal有意义 || 被trace : 僵尸
            •      整理"僵尸"与"发临僵尸信号"的关系:
              •       将发信号的条件中"最后线程"去掉, 可简化为(exit_signal有意义)||(被trace) == (发信号)
              •       可得出后: (发信号) == (僵尸)
              •       又可推出: (没有trace && exit_signal有意义 && 不是最后进程) == (僵尸了,但没法信号) , 这种情况在移除死进程时, 会给其父进程发信号 (FIXME: 待验证)
        •    调度. 调度函数会忽略僵尸进程, 但会减少僵尸进程的pd的使用计数; 会检查PF_DEAD位, 把它变成exit_dead状态

    进程移除 TODO:

  • 相关阅读:
    设计模式(17) 访问者模式(VISITOR) C++实现
    Effective C++(20) 继承与面向对象设计
    Google论文(1) GFS:Google文件系统
    设计模式(16) 观察者模式(OBSERVER)C++实现
    海量数据处理面试题(2) 将用户的query按出现频度排序
    海量数据处理面试题(1) 找出两文件种包含的相同的url
    深入探索C++对象模型(1) 关于对象(思维导图)
    服务器编程入门(13) Linux套接字设置超时的三种方法
    技术笔记:Indy的TIdSMTP改造,解决发送Html和主题截断问题
    技术笔记:Delphi多线程应用读写锁
  • 原文地址:https://www.cnblogs.com/xumaojun/p/8532275.html
Copyright © 2011-2022 走看看