关于linux内存管理
Linux的内存管理主要分为两部分:物理地址到虚拟地址的映射,内核内存分配管理(主要基于slab)。
物理地址到虚拟地址之间的映射
1、概念
物理地址(physical address)
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相相应。——这个概念应该是这几个概念中最好理解的一个,可是值得一提的是,尽管能够直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,可是其实,这仅仅是一个硬件提供给软件的抽像,内存的寻址方式并非这样。所以,说它是“与地址总线相相应”,是更贴切一些,只是抛开对物理内存寻址方式的考虑,直接 把物理地址与物理的内存一一相应,也是能够接受的。或许错误的理解更利于形而上的抽像。
虚拟内存(virtual memory)
这是对整个内存(不要与机器上插那条对上号)的抽像描写叙述。它是相对于物理内存来讲的,能够直接理解成“不直实的”,“假的”内存,比如,一个0x08000000内存地址,它并不正确就物理地址上那个大数组中0x08000000 - 1那个地址元素;
之所以是这样,是由于现代操作系统都提供了一种内存管理的抽像,即虚拟内存(virtual memory)。进程使用虚拟内存中的地址,由操作系统协助相关硬件,把它“转换”成真正的物理地址。这个“转换”,是全部问题讨论的关键。有了这种抽像,一个程序,就能够使用比真实物理地址大得多的地址空间。(拆东墙,补西墙,银行也是这样子做的),甚至多个进程能够使用同样的地址。不奇怪,由于转换 后的物理地址并不是同样的。能够把连接后的程序反编译看一下,发现连接器已经为程序分配了一个地址,比如,要调用某个函数A,代码不是call
A,而是call0x0811111111 ,也就是说,函数A的地址已经被定下来了。没有这种“转换”,没有虚拟地址的概念,这样做是根本行不通的。
打住了,这个问题再说下去,就收不住了。
逻辑地址(logical address)
Intel为了兼容,将远古时代的段式内存管理方式保留了下来。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。以上例,我们说的连接器为A分配的0x08111111这个地址就是逻辑地址。——只是不好意思,这样说,好像又违背了Intel中段式管理中,对逻辑地址要求,“一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为[段标识符:段内偏移量],也就是说,上例中那个0x08111111,应该表示为[A的代码段标识符: 0x08111111],这样,才完整一些”
线性地址(linear address)或也叫虚拟地址(virtual address)
跟逻辑地址类似,它也是一个不真实的地址,假设逻辑地址是相应的硬件平台段式管理转换前地址的话,那么线性地址则相应了硬件页式内存的转换前地址。
CPU将一个虚拟内存空间中的地址转换为物理地址,须要进行两步:首先将给定一个逻辑地址(事实上是段内偏移量,这个一定要理解!!!),CPU 要利用其段式内存管理单元,先将为个逻辑地址转换成一个线程地址,再利用其页式内存管理单元,转换为终于物理地址。这样做两次转换,的确是很麻烦并且没有必要的,由于直接能够把线性地址抽像给进程。之所以这样冗余,Intel全然是为了兼容而已。
2、CPU段式内存管理,逻辑地址怎样转换为线性地址
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。当中前13位是一个索引號。后面3位包括一些硬件细节,如图:
最后两位涉及权限检查,本贴中不包括。
索引號,或者直接理解成数组下标——那它总要相应一个数组吧,它又是什么东东的索引呢?这个东东就是“段描写叙述符(segment descriptor)”,呵呵,段描写叙述符详细地址描写叙述了一个段(对于“段”这个字眼的理解,我是把它想像成,拿了一把刀,把虚拟内存,砍成若干的截—— 段)。这样,非常多个段描写叙述符,就组了一个数组,叫“段描写叙述符表”,这样,能够通过段标识符的前13位,直接在段描写叙述符表中找到一个详细的段描写叙述符,这个描 述符就描写叙述了一个段,我刚才对段的抽像不太准确,由于看看描写叙述符里面到底有什么东东——也就是它到底是怎样描写叙述的,就理解段到底有什么东东了,每个段描
述符由8个字节组成,例如以下图:
这些东东非常复杂,尽管能够利用一个数据结构来定义它,只是,我这里仅仅关心一样,就是Base字段,它描写叙述了一个段的開始位置的线性地址。
Intel设计的本意是,一些全局的段描写叙述符,就放在“全局段描写叙述符表(GDT)”中,一些局部的,比如每一个进程自己的,就放在所谓的“局部段 描写叙述符表(LDT)”中。那到底什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。
GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。好多概念,像绕口令一样。这张图看起来要直观些:
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再依据对应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,能够在这个数组中,查找到相应的段描写叙述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
还是挺简单的,对于软件来讲,原则上就须要把硬件转换所需的信息准备好,就能够让硬件来完毕这个转换了。OK,来看看Linux怎么做的。
3、Linux的段式管理
Intel要求两次转换,这样虽说是兼容了,可是却是非常冗余,呵呵,没办法,硬件要求这样做了,软件就仅仅能照办,怎么着也得形式主义一样。
还有一方面,其他某些硬件平台,没有二次转换的概念,Linux也须要提供一个高层抽像,来提供一个统一的界面。所以,Linux的段式管理,其实仅仅是“哄骗”了一下硬件而已。依照Intel的本意,全局的用GDT,每一个进程自己的用LDT——只是Linux则对全部的进程都使用了同样的段来对 指令和数据寻址。即用户数据段,用户代码段,相应的,内核中的是内核数据段和内核代码段。这样做没有什么奇怪的,本来就是走形式嘛,像我们写年终总结一样。
include/asm-i386/segment.h
#define GDT_ENTRY_DEFAULT_USER_CS 14
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS * 8 + 3)
#define GDT_ENTRY_DEFAULT_USER_DS 15
#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS * 8 + 3)
#define GDT_ENTRY_KERNEL_BASE 12
#define GDT_ENTRY_KERNEL_CS (GDT_ENTRY_KERNEL_BASE + 0)
#define __KERNEL_CS (GDT_ENTRY_KERNEL_CS * 8)
#define GDT_ENTRY_KERNEL_DS (GDT_ENTRY_KERNEL_BASE + 1)
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS * 8)
把当中的宏替换成数值,则为:
#define __USER_CS 115 [00000000 1110 0 11]
#define __USER_DS 123 [00000000 1111 0 11]
#define __KERNEL_CS 96 [00000000 1100 0 00]
#define __KERNEL_DS 104 [00000000 1101 0 00]
方括号后是这四个段选择符的16位二制表示,它们的索引號和T1字段值也能够算出来了
__USER_CS index= 14 T1=0
__USER_DS index= 15 T1=0
__KERNEL_CS index= 12 T1=0
__KERNEL_DS index= 13 T1=0
T1均为0,则表示都使用了GDT,再来看初始化GDT的内容中对应的12-15项(arch/i386/head.S):
.quad0x00cf9a000000ffff /* 0x60 kernel 4GBcode at 0x00000000 */
.quad0x00cf92000000ffff /* 0x68 kernel 4GBdata at 0x00000000 */
.quad0x00cffa000000ffff /* 0x73 user 4GBcode at 0x00000000 */
.quad0x00cff2000000ffff /* 0x7b user 4GBdata at 0x00000000 */
依照前面段描写叙述符表中的描写叙述,能够把它们展开,发现其16-31位全为0,即四个段的基地址全为0。
这样,给定一个段内偏移地址,依照前面转换公式,0 +段内偏移,转换为线性地址,能够得出重要的结论,“在Linux下,逻辑地址与线性地址总是一致(是一致,不是有些人说的同样)的,即逻辑地址的偏移量字段的值与线性地址的值总是同样的。!!!”
忽略了太多的细节,比如段的权限检查。呵呵。Linux中,绝大部份进程并不例用LDT,除非使用Wine ,仿真Windows程序的时候。
4.CPU的页式内存管理
CPU的页式内存管理单元,负责把一个线性地址,终于翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),比如一个32位的机器,线性地址最大可为4G,能够用4KB为一个页来划分,这页,整个线性地址就被划分为一个 tatol_page[2^20]的大数组,共同拥有2的20个次方个页。这个大数组我们称之为页文件夹。文件夹中的每个文件夹项,就是一个地址——相应的页的地址。
还有一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把全部的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一相应的。这里注意到,这个total_page数组有2^20个成员,每一个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数 组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。文字描写叙述太累,看图直观一些:
如上图,
1、分页单元中,页文件夹是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的開始点。万里长征就从此长始了。
2、每个活动的进程,由于都有其独立的相应的虚似内存(页文件夹也是唯一的),那么它也相应了一个独立的页文件夹地址。——执行一个进程,须要将它的页文件夹地址放到cr3寄存器中,将别个的保存下来。
3、每个32位的线性地址被划分为三部份,面文件夹索引(10位):页表索引(10位):偏移(12位)
根据下面步骤进行转换:
1、从cr3中取出进程的页文件夹地址(操作系统负责在调度进程的时候,把这个地址装入相应寄存器);
2、依据线性地址前十位,在数组中,找到相应的索引项,由于引入了二级管理模式,页文件夹中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
3、依据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
4、将页的起始地址与线性地址中最后12位相加,得到终于我们想要的葫芦;
这个转换过程,应该说还是很easy地。所有由硬件完毕,尽管多了一道手续,可是节约了大量的内存,还是值得的。那么再简单地验证一下:
1、这种二级模式是否仍可以表示4G的地址;页文件夹共同拥有:2^10项,也就是说有这么多个页表.每一个目表相应了:2^10页;
每一个页中可寻址:2^12个字节。还是2^32 = 4GB
2、这种二级模式是否真的节约了空间;也就是算一下页文件夹项和页表项共占空间(2^10 *4 + 2 ^10 *4) = 8KB。哎,……怎么说呢!!!
红色错误,标注一下,后文贴中有此讨论。。。。。。按<深入理解计算机系统>中的解释,二级模式空间的节约是从两个方面实现的:
A、假设一级页表中的一个页表条目为空,那么那所指的二级页表就根本不会存在。这表现出一种巨大的潜在节约,由于对于一个典型的程序,4GB虚拟地址空间的大部份都会是未分配的;
B、仅仅有一级页表才须要总是在主存中。虚拟存储器系统能够在须要时创建,并页面调入或调出二级页表,这就降低了主存的压力。仅仅有最常常使用的二级页表才须要缓存在主存中。——只是Linux并没有全然享受这样的福利,它的页表文件夹和已分配页面相关的页表都是常驻内存的。值得一提的是,尽管页文件夹和页表中的项,都是4个字节,32位,可是它们都仅仅用高20位,低12位屏蔽为0——把页表的低12屏蔽为0,是非常好理解的,由于这样,它刚好和一个页面大 小相应起来,大家都成整数添加�。计算起来就方便多了。可是,为什么同一时候也要把页文件夹低12位屏蔽掉呢?由于按相同的道理,仅仅要屏蔽其低10位就能够了,不
过我想,由于12>10,这样,能够让页文件夹和页表使用同样的数据结构,方便。
本贴仅仅介绍一般性转换的原理,扩展分页、页的保护机制、PAE模式的分页这些麻烦点的东东就不啰嗦了……能够參考其他专业书籍。
5.Linux的页式内存管理
原理上来讲,Linux仅仅须要为每一个进程分配好所需数据结构,放到内存中,然后在调度进程的时候,切换寄存器cr3,剩下的就交给硬件来完毕了 (呵呵,其实要复杂得多,只是偶仅仅分析最主要的流程)。前面说了i386的二级页管理架构,只是有些CPU,还有三级,甚至四级架构,Linux为了在 更高层次提供抽像,为每一个CPU提供统一的界面。提供了一个四层页管理架构,来兼容这些二级、三级、四级管理架构的CPU。这四级分别为:
页全局文件夹PGD(相应刚才的页文件夹)
页上级文件夹PUD(新引进的)
页中间文件夹PMD(也就新引进的)
页表PT(相应刚才的页表)。
整个转换根据硬件转换原理,仅仅是多了二次数组的索引罢了,例如以下图:
那么,对于使用二级管理架构32位的硬件,如今又是四级转换了,它们怎么可以协调地工作起来呢?嗯,来看这样的情况下,怎么来划分线性地址吧!从硬件的角度,32位地址被分成了三部份——也就是说,无论理软件怎么做,终于落实到硬件,也仅仅认识这三位老大。
从软件的角度,因为多引入了两部份,,也就是说,共同拥有五部份。——要让二层架构的硬件认识五部份也非常easy,在地址划分的时候,将页上级文件夹和页 中间文件夹的长度设置为0就能够了。这样,操作系统见到的是五部份,硬件还是按它死板的三部份划分,也不会出错,也就是说大家共建了和谐计算机系统。
这样,虽说是多此一举,可是考虑到64位地址,使用四层转换架构的CPU,我们就不再把中间两个设为0了,这样,软件与硬件再次和谐——抽像就是强大呀!!!
比如,一个逻辑地址已经被转换成了线性地址,0x08147258,换成二制进,也就是:
0000100000 0101000111 001001011000
内核对这个地址进行划分,
PGD = 0000100000
PUD = 0
PMD = 0
PT = 0101000111
offset = 001001011000
如今来理解Linux针对硬件的花招,由于硬件根本看不到所谓PUD,PMD,所以,本质上要求PGD索引,直接就相应了PT的地址。而不是再 到PUD和PMD中去查数组(尽管它们两个在线性地址中,长度为0,2^0 =1,也就是说,它们都是有一个数组元素的数组),那么,内核怎样合理安排地址呢?
从软件的角度上来讲,由于它的项仅仅有一个,32位,刚好能够存放与PGD中长度一样的地址指针。那么所谓先到PUD,到到PMD中做映射转换, 就变成了保持原值不变,一一转手就能够了。这样,就实现了“逻辑上指向一个PUD,再指向一个PDM,但在物理上是直接指向对应的PT的这个抽像,由于硬 件根本不知道有PUD、PMD这个东西”。然后交给硬件,硬件对这个地址进行划分,看到的是:
页文件夹 = 0000100000
PT = 0101000111
offset = 001001011000
嗯,先依据0000100000(32),在页文件夹数组中索引,找到其元素中的地址,取其高20位,找到页表的地址,页表的地址是由内核动态分配的,接着,再加一个offset,就是终于的物理地址了。
内核内存分配管理
内存管理方法应该实现下面两个功能:
- 最小化管理内存所需的时间
- 最大化用于一般应用的可用内存(最小化管理开销)
1.直接堆分配
每一个内存管理器都使用了一种基于堆的分配策略。在这样的方法中,大块内存(称为 堆)用来为用户定义的目的提供内存。当用户须要一块内存时,就请求给自己分配一定大小的内存。堆管理器会查看可用内存的情况(使用特定算法)并返回一块内存。搜索过程中使用的一些算法有first-fit(在堆中搜索到的第一个满足请求的内存块)和best-fit(使用堆中满足请求的最合适的内存块)。当用户使用完内存后,就将内存返回给堆。
这样的基于堆的分配策略的根本问题是碎片(fragmentation)。当内存块被分配后,它们会以不同的顺序在不同的时间返回。这样会在堆中留下一些洞,须要花一些时间才干有效地管理空暇内存。这样的算法通常具有较高的内存使用效率(分配须要的内存),可是却须要花费很多其它时间来对堆进行管理。
2.伙伴分配算法
第二种方法称为 buddy memory allocation,是一种更快的内存分配技术,它将内 存划分为 2 的幂次方个分区,并使用 best-fit 方法来分配内存请求。当用户释放内存时,就会检查 buddy 块,查看其相邻的内存块是否也已经被释放。假设是的话,将合并内存块以最小化内存碎片。这个算法的时间效率更高,可是因为使用 best-fit 方法的缘故,会产生内存浪费。
3.slab
关于slab 分配器有非常多文档介绍。简单的说就是内核常常申请固定大小的
一些内存空间,这些空间一般都是结构体。而这些结构体往往都会有一个共同的初始化行为比方:初始化里面的信号量、链表指针、成员。通过Sun 的大牛JeffBonwick 的研究发现,内核对这些结构体的初始化所消耗的时间比分配它们的时间还要长。所以他设计了一种算法,当这些结构体的空间被释放的时候,仅仅是让他回到刚刚分配好的状态而不真正释放,下次再申请的时候就能够节约初始化的时间。整个过程能够理解为借用白板的过程。申请空间就是从别人那里借多块白板。因为每块白板的用处不同,每次用的时候都要先在不同的白板上画上不同的表格,然后往里面填内容。假设一般的算法则是用完白板后,直接还给人家,下次要用的时候再借回来然后画好表格。优化一点的算法就是用完后临时不还人家,人家要用的时候再还,第二次再要用白板的时候随便取一块白板又一次画表格。而使用slab
算法就是不用白板的时候擦除表格的内容留下表格,白板也临时不还人家。下次要用的时候依据用途取出正确的白板,因为表格是现成的直接往里面填内容就能够了。省去了借白板和画表格这两个操作。
一、slab分配器的基本观点
* slab分配器把内存区看作对象(object),把包括快速缓存的主内存区划分为多个slab;
* slab分配器把对象依照类型分组放进快速缓存,每一个快速缓存都是同种类型对象的一种“储备”;
* 每一个slab由一个或多个连续的页框组成,这些页框中包括已分配的对象,也包括空暇的对象;
* slab分配器通过伙伴系统分配页框。
二、slab 缓存分配器的长处
1)、内核通常依赖于对小对象的分配,它们会在系统生命周期内进行无数次分配。slab缓存分配器通过对类似大小的对象进行缓存而提供这样的功能,从而避免了常见的碎片问题。2)、slab 分配器还支持通用对象的初始化,从而避免了为同一目的而对一个对象反复进行初始化。3)、slab 分配器还能够支持硬件缓存对齐和着色,这同意不同缓存中的对象占用同样的缓存行,从而提高缓存的利用率并获得更好的性能。
uClibc-0.9.28中的malloc能够调用mmap,从而与物理地址联系起来,也能够通过sbrk,从而与内核之间的内存管理联系起来,推測可能也会经过slab。
关于linux内存管理的更多相关文章
- 浅谈Linux内存管理机制
经常遇到一些刚接触Linux的新手会问内存占用怎么那么多?在Linux中经常发现空闲内存很少,似乎所有的内存都被系统占用了,表面感觉是内存不够用了,其实不然.这是Linux内存管理的一个优秀特性,在这 ...
- linux内存管理
一.Linux 进程在内存中的数据结构 一个可执行程序在存储(没有调入内存)时分为代码段,数据段,未初始化数据段三部分: 1) 代码段:存放CPU执行的机器指令.通常代码区是共享的,即其它执行程 ...
- Linux内存管理原理
本文以32位机器为准,串讲一些内存管理的知识点. 1. 虚拟地址.物理地址.逻辑地址.线性地址 虚拟地址又叫线性地址.linux没有采用分段机制,所以逻辑地址和虚拟地址(线性地址)(在用户态,内核态逻 ...
- 了解linux内存管理机制(转)
今天了解了下linux内存管理机制,在这里记录下,原文在这里http://ixdba.blog.51cto.com/2895551/541355 根据自己的理解画了张图: 下面是转载的内容: 一 物理 ...
- Linux内存管理原理【转】
转自:http://www.cnblogs.com/zhaoyl/p/3695517.html 本文以32位机器为准,串讲一些内存管理的知识点. 1. 虚拟地址.物理地址.逻辑地址.线性地址 虚拟地址 ...
- Windows内存管理和linux内存管理
windows内存管理 windows 内存管理方式主要分为:页式管理,段式管理,段页式管理. 页式管理的基本原理是将各进程的虚拟空间划分为若干个长度相等的页:页式管理把内存空间按照页的大小划分成片或 ...
- linux 内存管理——内核的shmall 和shmmax 参数
内核的 shmall 和 shmmax 参数 SHMMAX= 配置了最大的内存segment的大小 ------>这个设置的比SGA_MAX_SIZE大比较好. SHMMIN= 最小的内存seg ...
- linux内存管理子系统
一.Linux内存管理模型 1.虚拟地址与物理地址的映射 2.物理地址的分配二.虚拟地址与物理地址的映射 1.虚拟地址空间分布 32位处理器有32根地址总线,可访问4G的物理空间.其中有0-3G为用户 ...
- Linux内核分析(三)----初识linux内存管理子系统
原文:Linux内核分析(三)----初识linux内存管理子系统 Linux内核分析(三) 昨天我们对内核模块进行了简单的分析,今天为了让我们今后的分析没有太多障碍,我们今天先简单的分析一下linu ...
- Linux内存管理之slab分配器
slab分配器是什么? 参考:http://blog.csdn.net/vanbreaker/article/details/7664296 slab分配器是Linux内存管理中非常重要和复杂的一部分 ...
随机推荐
- 安装PHP过程中,make步骤报错:(集合网络上各种解决方法)
安装PHP过程中,make步骤报错:(集合网络上各种解决方法) (1)-liconv -o sapi/fpm/php-fpm /usr/bin/ld: cannot find -liconv coll ...
- Objective-C学习篇02—封装
面向对象的三大特性:封装.继承和多态 封装目的就是将数据隐藏起来,外界只能通过这个类的方法(接口)才能访问或者设置里面的数据,不可以在外部直接修改或者访问里面的数据,通常使用方法来达到封装一个类的目的 ...
- MySQL 删除数据库
MySQL 删除数据库 使用 mysqladmin 删除数据库 使用普通用户登陆mysql服务器,你可能需要特定的权限来创建或者删除 MySQL 数据库. 所以我们这边使用root用户登录,root用 ...
- socket.io实现
后台代码 index_server.js var app = require('http').createServer(handler)//创建服务器app , io = require('socke ...
- 你好,C++(38)从问题描述中发现对象的属性和行为 6.4 工资程序成长记:类与对象(上)
6.4 工资程序成长记:类与对象 “夜半三更哟,盼天明:寒冬腊月哟,盼春风.若要盼得哟,涨工资,岭上……”自从上次老板许诺给小陈涨工资以后,一转眼又过去几个月了,可是涨工资的事一点动静都没有.小陈只 ...
- Js屏蔽键盘事件
<script> function KeyDown(){ //屏蔽鼠标右键.Ctrl+n.shift+F10.F5刷新.退格键 //alert(& ...
- window.location.href/replace/reload()--页面跳转+替换+刷新
一.最外层top跳转页面,适合用于iframe框架集 top.window.location.href("${pageContext.request.contextPath}/Login_g ...
- 10个Java面试题及答案
1. 什么是JVM? 为什么称Java为跨平台的编程语言? Java虚拟机(Java Virtual Machine)是可以执行Java字节码的虚拟机,每个Java源文件将被编译成字节码文件,然后在J ...
- android.os.NetworkOnMainThreadException 在4.0之后谷歌强制要求连接网络不能在主线程进行访问
谷歌在4.0系统以后就禁止在主线程中进行网络访问了,原因是: 主线程是负责UI的响应,如果在主线程进行网络访问,超过5秒的话就会引发强制关闭, 所以这种耗时的操作不能放在主线程里.放在子线程里,而子线 ...
- 使用C#的自定义事件
public class CarDealer { public Action<string> NewCarInfo; //使用系统定义的泛型委托 public void NewCarCom ...