zoukankan      html  css  js  c++  java
  • Casual Note of OS

    20170104

    冯诺依曼计算机(遵循冯诺依曼结构设计的计算机:存储器、运算器、控制器、输入设备、输出设备)之前也有计算机,不过在那之前的计算机是专用的,不可编程,只能干特定的事情没法干其他事。与之前计算机的一个不同在于冯诺依曼计算机是通用计算机可以干很多事情,并且程序是可存储的(“存储程序”),多个程序可以存储在计算机中。

    20170328

    1、系统硬件组成

    2、存储设备层次结构

     

    为什么叫主存(main memory)?因为CPU地址总线控制的不止是通常所认为的内存,还有显存等,这里面最常用的是通常认为的内存,故叫主存?

    速度级别:

    硬盘:毫秒级

    内存:纳秒级

     3、OS提供的抽象表示

    1、进程、线程。并行化:线程级并行(多核或超线程)、指令级并行、单指令多数据(SIMD)

    2、虚拟存储器

     ————

    (从上可见,栈从上向下增长,故亦称下推表)

    20170427

    精简指令集/复杂指令集的CPU架构:

    RISC: MIPS架构PowerPC架构(IBM)、ARM架构(ARM)、SPARC架构(Sun,后被Oracle收购) 等的CPU用RISC

    CISC:X86架构(Intel、AMD、VIA等) 等的CPU用CISC

    题外话:Intel 1代为8位机(8080、8085)、2代16位机(8086)、3代为32位机(80386)、4代及目前最新为64位机。64位已经远远够用了,所以位长应该不会再增了。

    20170428

    计算机内部负整数为什么用补码?

    原因是:只有这种形式,计算机才能实现正确的加减法。

    计算机其实只能做加法,1-1其实是1+(-1)。如果用原码表示,计算结果是不对的。比如说:

    1   -> 00000001

    -1 -> 10000001

    + ------------------

    -2 -> 10000010

    用符合直觉的原码表示,1-1的结果是-2。

    如果是补码表示:

    1   -> 00000001

    -1 -> 11111111

    + ------------------

    0  ->  00000000

    结果是正确的。

     

     

    计算机浮点数运算为什么不准确?

    如C下 printf("%.10f ",0.1f*0.1f); 为0.0100000007,Java下 System.out.println(0.1f* 0.1f); 为0.010000001,虽然保留位数少的话可得期望值,但其实真正的计算结果有很多位,所以计算结果不是我们期望的。

    原因:运算本身没错,而是计算机不能精确表示很多数,比如0.1,数都不能精确表示当然运算结果也不准确了。为什么不能精确表示呢——与十进制只能表示10的若干次方和的数一样,采用二进制的计算机只能表示2的若干次方和的数,前者不能准确表示无限小数如1/3,后者除不能表示无限小数外还有一些数也不能准确表示如0.1。

    处理不精确:如果要求的精度不高,可以四舍五入;否则可以将小数转化为整数进行运算,算完再转化为小数。

     20170719

    1、CPU视角看计算机启动过程(见 CPU阿甘——码农翻身

    2、CPU视角看程序装载运行过程(见 CPU阿甘之烦恼——码农翻身):地址重定位,分页、工作集、页表、缺页中断,分段、段表、段错误

    3、进程、线程实现(见 我是一个进程——码农翻身

    4、死锁相关(见 我是一个线程——码农翻身

    5、同步和锁

    同步和互斥问题(见 那些烦人的同步和互斥问题——码农翻身):脱机打印、信号量、生产者消费者问题(通过信号量解决)

    锁(见 编程世界的那把锁——码农翻身):CAS、TAS等操作 -> 自旋锁 -> 可重入锁 -> Semaphore、ReentrantReadWriteLock、CountDownLatch、CyclicBarrier
     
     
    20170907
    死锁的产生与解决:

    1、产生死锁的原因主要是:
    (1)因为系统资源不足。
    (2)进程运行推进的顺序不合适。
    (3)资源分配不当等。
    如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。其次,进程运行推进顺序与速度不同,也可能产生死锁。
    2、产生死锁的四个必要条件:
    (1)互斥条件:一个资源每次只能被一个进程使用。
    (2)请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
    (3)不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
    (4)循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
      这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
    3、死锁的预防:

    (1)资源一次性分配:(破坏请求和保持条件)
    (2)可剥夺资源:即当某进程新的资源未满足时,释放已占有的资源(破坏不可剥夺条件)
    (3)资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏循环等待条件)

    4、死锁的避免:(银行家算法)

    预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法

    5、死锁的检测:进程等待图(wait graph)看是否有环

    6、死锁的解除:

    (1)剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;

    (2)撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止;所谓代价是指优先级、运行代价、进程的重要性和价值等

     
    2017.09.08
    进程间通信(IPC)的方式:
    1、无名管道(Pipe)
    管道是一种具有两个端点的通信通道,一个管道实际上就是只存在在内存中的文件,对这个文件操作需要两个已经打开文件进行,他们代表管道的两端,也叫两个句槟,管道是一种特殊的文件,不属于一种文件系统,而是一种独立的文件系统,有自己的数据结构,根据管道的使用范围划分为无名管道和命名管道。是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
    2、有名管道(Named Pipe)
    是为了解决无名管道只能在父子进程间通信而设计的,命名管道是建立在实际的磁盘介质或文件系统(而不是只存在内存中),任何进程可以通过文件名或路径建立与该文件的联系,命名换到需要一种FIFO文件(有先进先出的原则),虽然FIFO文件的inode节点在磁盘上,但仅是一个节点而已,文件的数据还是存在于内存缓冲页面中,和普通管道相同。
    3、高级管道(popen)
    将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们成为高级管道方式。
    4、信号量(semophore )
    信号量是一个计数器,可用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
    5、信号(Signal)
    信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
    6、消息队列
    消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
    7、共享内存
    最快的IPC方式。共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量配合使用,来实现进程间的同步和通信。
    8、套接字
    与其他通信机制不同的是,套接字可用于不同机器间的进程通信。有Socket套接字(TCP/IP)、Unix域套接字等。
     
    套接字:Linux、Windows等平台,同一或不同Server上。
    有名管道和共享内存:Windows,同一Server上。
    Unix域套接字:Linux、Unix,同一Server上。
     
     
    RAID(Redundant Arrays of Independent Disk,独立磁盘冗余阵列):RAID0、RAID1、RAID5、RAID10、RAID01、RAID50等
     
     
    2017.09.11

    页面置换算法:(详见:页面置换算法])

    总结:

    最佳置换算法

    基于队列:FIFO。实现简单,时间效率高;效果差(缺页多),Belady异常

    基于栈:LRU。与FIFO比:实现较复杂,时间效率低;效果好,没有Belady异常

    LFU,与LRU类似

    ClOCK:时间开销比LRU小但效果接近LRU

    1、最佳置换算法(OPT)

    置换算法所选择的被淘汰页面将是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。由于人们目前无法预知进程在内存下的若干页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现

    示例:(9次缺页中断、6次页面置换)

    2、先进先出置换算法(FIFO)

    基于队列的算法。优先淘汰最早进入内存的页面,亦即在内存中驻留时间最久的页面。该算法实现简单,只需把调入内存的页面根据先后次序链接成队列,设置一个指针总指向最早的页面。但该算法与进程实际运行时的规律不适应,因为在进程中,有的页面经常被访问。FIFO算法还会产生当所分配的物理块数增大而页故障数不减反增的异常现象(由 Belady于1969年发现,故称为Belady异常)

    示例:(15次缺页中断、12次页面置换)

    3、最近最久未使用置换算法(LRU)

    基于栈的算法。选择最近最长时间未访问过的页面予以淘汰,它认为过去一段时间内未访问过的页面,在最近的将来可能也不会被访问。LRU性能较好,但需要寄存器和栈的硬件支持。LRU是堆栈类的算法。理论上可以证明,堆栈类算法不可能出现Belady异常。FIFO算法基于队列实现,不是堆栈类算法。

    示例:(12次缺页中断、9次页面置换) 

    基于优先队列实现LRU:假设优先级越高越不应被删除,则对于要访问的目标数据(访问的页)

    若缓存(三个物理块)中存在,则从缓存中访问该页,并把该页置为最高优先级;若不存在,则从缓存移除优先级最低的(最长时间未使用的物理块中到)页、把新页加入缓存,并把新页面置为最高优先级。

    基于LinkedHashMap实现LRU:实际上就是对上述基于优先队列方案的实现,LinkedHashMap的entry的entry按加入的顺序保存,故“晚加入”相当于上面的“高优先级”。对于某个数据:

    若LinkedHashMap中存在该元素,则访问删除该元素然后重新添加到map;若不存在,则直接加入(未满时)或 移除第一个entry并将元素加入(满时)到map。代码:

    abstract class DbUtilWithLruCache<T> {
        private final int cacheCapacity = 10;
        private LinkedHashMap<String, T> lruCache = new LinkedHashMap<>();// LinkedHashMap具有FIFO的特点,借助之来维护优先级,越晚添加者(越靠后)优先级越高
    
        // 添加缓存数据,添加的数据优先级最高
        private void setToLruCache(String key, T val) {
            if (lruCache.size() == cacheCapacity) {
                Iterator<?> it = lruCache.entrySet().iterator();
                lruCache.remove(it.next());
    
            }
            lruCache.put(key, val);
        }
    
        // 提高指定缓存数据的优先级
        private void increasePriority(String key, T val) {
            // 缓存中移除
            lruCache.remove(key);
            // 新加到缓存
            lruCache.put(key, val);
        }
    
        protected abstract T getValFromDB(String key);
    
        protected abstract void setValToDB(String key, T val);
    
        /** 获取数据 */
        public synchronized T get(String key) {
            T val = lruCache.get(key);
            
            if (null == val) {// 不存在:从db取、更新缓存
                // 从真实位置取得数据
                val = getValFromDB(key);
                // 加到缓存
                setToLruCache(key, val);
                
            } else {// 存在:提高优先级
                increasePriority(key, val);
            }
    
            return val;
        }
    
        /** 存数据 */
        public synchronized void set(String key, T val) {
            T oldVal = lruCache.get(key);
            
            if (oldVal != val) {//数据变化:存到db、更新缓存
                setValToDB(key, val);
                setToLruCache(key, val);
                
            } else {//数据没变化:提高优先级
                increasePriority(key, val);
            }
        }
    LRU_with_LinkedHashMap

     真实应用场景,对象存储方向代理服务ReverseProxyController的文件下载及上传功能中加上LRU,使得若代理服务中存在文件缓存则不用去对象存储中下载。

     

    4、最近最不常使用置换算法(LFU)

    与LRU类似,不过每次淘汰的是过去一定时期(看指定的是多长)内被访问次数最少的页

    5、时钟置换算法(Clock)

    LRU算法的性能接近于OPT,但是实现起来比较困难,且开销大;FIFO算法实现简单,但性能差。所以操作系统的设计者尝试了很多算法,试图用比较小的开销接近LRU的性能,这类算法都是CLOCK算法的变体。

    20200617

    操作系统文件缓存(Page Cache)

    参阅:码农翻身-https://mp.weixin.qq.com/s/fZWgCfUZ_sNAFfAR2kyhiw

    使用Page Cache的原因或作用:外存的读写速度远小于内存,根据局部性原理,一次IO时可以加载大于实际需要的数据(加载1Page,Linux通常是4kB大小、Windows通常是256KB大小)到内存,这样下次访问的数据很可能在上次访问的数据附近,这时就可减少IO次数、提高访问效率。

    内核态内存中有真实的物理页,每个程序也有自己的虚拟页,虚拟页映射到内存页。

    进程结束后该进程相关的Page Cache内存回收释放,因为很可能程序要再次运行,此时可减少IO。“最大限度地用尽Page Cache,直到达到上限才进行页面置换”。

    数据从硬盘读入内存时是放到内核态的内存中(即放在Page Cache);内核态的内存是不会直接让用户程序访问的,所以还会有一次从内核态到用户态的内存复制!!可见,很费内存,如下图:

       、    

    当然,也有手段来达到共享内存、减少内存占用的目的。如mmap系统调用(Memory-mapped files,java等很多语言也支持) ,原理图如下:

     另外,上述所说的是OS文件系统的缓存,并不是万金油,很多数据库为了提高效率会实现自己的缓存机制,而不是用OS文件系统的缓存。

    20170912

    锁在OS底层的实现机制:

    在硬件层面,CPU提供了原子操作、关中断、锁内存总线的机制;

    OS基于这几个CPU硬件机制,就能够实现锁;

    再基于锁,就能够实现各种各样的同步机制(信号量、消息、Barrier等等等等)。

    分布式和集群的区别与联系

    区别:

    分布式:一个业务分拆多个子业务,部署在不同的服务器上(将不同业务放在不同地方);
    集群:同一个业务,部署在多个服务器上(几台服务器集中在一起,实现同一业务,一个节点挂了,另一个能顶上)。

    如一个任务包含10个子任务,每个子任务处理需要1小时,则在单台服务器上处理一个任务需要10小时。若用10台机器组成一个分布式系统,每个机器负责处理一个子任务,则分布式系统一个任务需要1小时;若用10台机器组成一个集群,每个机器负责处理一个完整任务,则每台机器处理一个完整任务需要10小时,若10个服务器同时工作10个任务同时到达,则处理完10个任务需要10小时,平均每个任务1小时。从这看来,集群的作用是提高并发处理能力。

    联系:分布式中的每一个节点都可以做集群;而集群并不一定是分布式的,如单节点的Memcached冗余部署三套就不算是分布式的。

    20171012

    关于fork()及父子进程间的关系:(可参考fork系统调用fork后子进程复制了父进程的什么

    Linux中C/C++程序的内存布局包括text段、data段、bss段、堆段、栈段五部分(详见C/C++小记、42)。

    fork()函数的实质是一个系统调用(和write函数类似),其作用是创建一个新的进程,当一个进程调用它完成后就出现两个几乎一模一样的进程。其中由fork()创建的新进程被称为子进程,而原来的进程称为父进程。子进程是父进程的一个拷贝——即子进程从父进程得到了数据段和堆栈的拷贝,这些需要分配新的内存;而对于只读的代码段,通常使用共享内存方式进行访问。(代码段共享,数据段、堆、栈复制新的)。fork时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。每个进程都有自己的虚拟地址空间,不同进程的相同的虚拟地址显然可以对应不同的物理地址。因此地址相同(虚拟地址)而值不同没什么奇怪。 示例:

     1 #include <unistd.h>
     2 #include <stdio.h>
     3 #include <stdlib.h>
     4 
     5 int main(int argc,char *argv[])
     6 {
     7   pid_t pid;
     8   pid = fork();
     9   if(pid<0)
    10     {
    11     printf("fail to create!");
    12     exit(1);
    13     }
    14   else if(pid==0)
    15     printf("In child process, pid=%d
    ",getpid());
    16   else
    17     printf("In parent process, pid=%d
    ",getpid());
    18   return 0;
    19 }
    20 
    21 //输出:
    22 In parent process, pid=12072
    23 In child process, pid=12073
    24 
    25 
    26 
    27 /*这是一个调用fork()函数创建一个子进程,然后分别打印输出子进程和父进程中的变量的实例*/
    28 #include <unistd.h>
    29 #include <stdio.h>
    30 #include <stdlib.h>
    31 #include <errno.h>
    32 int glob = 6;
    33 int main(int argc,char *argv[])
    34 {
    35     int var;    //内部变量
    36     pid_t pid;      //文件标识符
    37     var = 88;   //内部变量赋值
    38     char  str[]="good";
    39 
    40     printf("创建新进程之前.
    ");   //还没有创建子进程
    41     if((pid=fork())<0)
    42     {
    43       perror("创建子进程失败!
    ");
    44     }
    45     else if(pid==0)
    46     {
    47       glob++;
    48       var++;
    49     }
    50     else
    51     {
    52       sleep(2); //父进程阻塞两秒
    53     }
    54     printf("进程标识符为=%d,glob=%d,var=%d,varAddr=%p,firstChAddr=%p
    ",getpid(),glob,var,&var,str);//分别在子进程中输出两个变量的值
    55     exit(0);
    56 }
    57 
    58 //输出:
    59 创建新进程之前.
    60 进程标识符为=12318,glob=7,var=89,varAddr=0x7ffe75b705e8,firstChAddr=0x7ffe75b705f0
    61 进程标识符为=12317,glob=6,var=88,varAddr=0x7ffe75b705e8,firstChAddr=0x7ffe75b705f0
    View Code

    子进程从父进程处继承了整个进程的地址空间,包括进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设定、进程优先级、进程组号、当前工作目录、根目录、资源限制、控制终端等,而子进程所独有的只有它的进程号、计时器等。因此可以看出,使用fork系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段里的绝大部分内容,使得fork系统调用的执行速度并不很快。 

    在Linux中,对fork进行了优化,调用时采用写时复制 (COW,copy on write)的方式,在系统调用fork生成子进程的时候,不马上为子进程复制父进程的资源,而是在遇到“写入”(对资源进行修改)操作时才复制资源。

    20180806

    为什么分支预测能提高执行效率?

    一条CPU指令的完整执行可能包括取指(Fetch)、解码(Decode)、执行(Execute)、写回(Write-back)。对于指令序列ABCDE,CPU不是依次完整执行完每条指令的各个阶段再执行下一条,而是流水线执行的:指令A处于执行阶段时可能指令B处于解码指令C处于取指。

    分支预测:然而若A执行完后是需要跳转到D的,则执行A时B的解码、C的取指就会白做。若有分支预测,则在执行A时流水线执行的就可能是D、E而不是B、C,从而避免白做,提高效率。

    示例:(如下求和排序前所用时间是排序后的六倍左右)

        public static void main(String[] args) throws URISyntaxException {
            // Generate data
            int arraySize = 32768;
            int data[] = new int[arraySize];
    
            Random rnd = new Random(0);
            for (int c = 0; c < arraySize; ++c)
                data[c] = rnd.nextInt() % 256;
    
            // !!! With this, the next loop runs faster
            Arrays.sort(data);
    
            // Test
            long start = System.nanoTime();
            long sum = 0;
    
            for (int i = 0; i < 100000; ++i) {
                // Primary loop
                for (int c = 0; c < arraySize; ++c) {
                    if (data[c] >= 128)
                        sum += data[c];
                }
            }
    
            System.out.println((System.nanoTime() - start) / 1000000000.0);
            System.out.println("sum = " + sum);
        }
    View Code

    参考:https://stackoverflow.com/questions/11227809/why-is-it-faster-to-process-a-sorted-array-than-an-unsorted-array

    20190530

    操作系统发展历程

    人工OS -> 单道批处理OS -> 多道批处理OS -> 分时OS -> 实时OS

    参考:https://www.cnblogs.com/enochzzg/p/9997978.html

    20191122

    不同操作系统(OS)所用的文件系统(FS)格式:

    Windows:NTFS

    Linux:ext4

    OSX:HFS+

    Solaris and Unix:ZFS

    通用(最早出现,各种OS都支持):FAT32(32位,文件最大4GB)、extFAT(extended fat32,64位,单文件最大16EB)。MS Dos就用这种格式,可参阅:https://zhuanlan.zhihu.com/p/25992179

     

    注:不同OS并不是只支持自己的默认FS,如也支持读NTFS的文件

    分区与分区表:

    分区:一个硬盘上可以有多个文件系统同时存在,即通过对硬盘分区(partition)每个区可以有自己的文件系统格式。硬盘必须先分区,才能指定每个区的文件系统。

    分区表:分区大小、起始位置、结束位置、文件系统等信息,都存在分区表里。分区表有MBR(Master Boot Record)、GPT(Globally Unique Identifier Partition Table)两种格式:

    MBR是传统格式,兼容性好;但一个分区最大2TB容量、最多 4个主分区 或 3个主分区+1个扩展分区+无限逻辑分区;

    GPT更现代,功能更强大,一个分区最大18EB、最多128个分区。一般来说,都推荐使用 GPT。

    MBR和GPT的区别可参阅:https://zh.wikipedia.org/wiki/%E4%B8%BB%E5%BC%95%E5%AF%BC%E8%AE%B0%E5%BD%95

    UEFI(Unified Extensible Firmware Interface,统一的可扩展固件接口)

    是由Intel发起制定的一个接口标准,抽象了操作系统与系统固件之间的交互,作为BIOS的替代方案,是的操作系统不与系统硬件绑定。

    详情可参阅:https://zhuanlan.zhihu.com/p/25281151

    20191127

    单线程模型:javascript、python异步编程、redis、nginx都是单线程模型来达到高并发能力,在这种模型下没有现存竞争所以不要考虑资源访问的竞争问题,而单线程模型在实现上通常用事件驱动机制。

    注:可见万变不离其宗

    20200218

    磁盘一次IO的时间:约10ms(忽略传输时间)

    磁盘读取数据靠的是机械运动,每次读取数据花费的时间可以分为寻道时间、旋转延迟、传输时间三个部分,寻道时间指的是磁臂移动到指定磁道所需要的时间,主流磁盘一般在5ms以下;旋转延迟就是我们经常听说的磁盘转速,比如一个磁盘7200转,表示每分钟能转7200次,也就是说1秒钟能转120次,旋转延迟就是1/120/2 = 4.17ms;传输时间指的是从磁盘读出或将数据写入磁盘的时间,一般在0.x 毫秒,相对于前两个时间可以忽略不计。那么访问一次磁盘的时间,即一次磁盘IO的时间约等于5+4.17 = 9ms左右。

    20200528

    CPU提高执行效率的措施:

    缓存:使用缓存的理论基础是基于局部性原理,有一级、二级、三级缓存。这里面涉及到缺页中断、页面调度等过程,见前文。

    流水线(乱序执行):多条指令的 取指、译码、执行、回写 四个过程有些可以并行

    分支预测:见前文

    这些措施虽然提高了CPU运行效率,但由于预测失败时数据已被缓存了,可能导致有漏洞被恶意利用,具体可参阅 https://mp.weixin.qq.com/s/XEDCCQntIRghWhfigciWSA

  • 相关阅读:
    能自证的任意类型即为动态类型
    类型系统:类型信息引用 isa
    类型系统:类型检查、类型转换、任意类型-强类型、类型转换
    动态类型与弱类型
    Swift Intermediate Language (SIL)
    swift -Dynamic Dispatch
    swift VTables
    Which dispatch method would be used in Swift?
    Which dispatch method would be used in Swift?-Existential Container
    Swift protocol extension method is called instead of method implemented in subclass
  • 原文地址:https://www.cnblogs.com/z-sm/p/6247458.html
Copyright © 2011-2022 走看看