卷中卷1:关于「Block」的底层研究

时代背景


受到培训、疫情、大厂裁员的多重打击,IT行业现在极度内卷,而 IT行业里iOS是当之无愧的卷王之王
没啥好说的,其他行业与iOS都不在一个维度里面,根本不与辩驳。

不适应行业,就要重新选择行业。我选择适应行业。

为什么要研究


之所以选择block进行研究,道理很简单:之前对block没有深入研究过。
因为block用起来很简单,除了要考虑:

  • 1. 变量前加__block可以在block内部改变值
  • 2. weak-strong搭配解决引用循环

之外,根本不用思考就可以轻松使用,而OC也是为了方便我们使用才设计出block。
初衷不是让每个开发者都要「搞明白怎么实现block,才可以使用block!」,只是很多人被迫营业了。
比如现在,大家都知道了而我不知道,显得逼格很低,所以非常需要多深入了解一点。

进行底层研究之前,关于clang查看cpp源码文件,可以看下: iOS 使用Clang命令失败的解决

哪些方面值得研究


暂时总结出这几点:

  1. block本质是什么,怎么实现? 能手写做一下实现过程吗
  2. block有哪些类型,什么情况下会改变?
  3. block捕获变量有哪几种方式,__block对变量会做什么操作?
  4. block循环引用是常见的有哪些,怎么产生的,怎么解决?

接下来的内容是我查阅资料后,加上了一些个人理解,简明扼要的说出重点供各位同学参考。

1.「Block」的本质与实现


从表层OC使用来说,block本质是个对象(Object),拥有isa指针,可以当作数据进行传递(例如放入NSDictionary中)。
从底层C++实现来说,block本质是个结构体(Struct),并且由多个不同作用的结构体和函数构成。

接下来,看一下函数内创建的简单block,执行clang编译前后的源码对比:

OC

int main() {    // 对应找下面的main函数
    @autoreleasepool {
        void (^IIIIIIII)(void) = ^{ // block名称是IIIIIIII
            123456789; // 方便找clang后所在位置,如果是字符串会变成乱码
        };
    }
    return 0;
}

C++

struct __block_impl { // block对象
  void *isa;
  int Flags;
  int Reserved;
  void *FuncPtr;
};

static struct __main_block_desc_0 { // block描述信息
  size_t reserved;
  size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

struct __main_block_impl_0 { // block结构体创建
  struct __block_impl impl;  
  struct __main_block_desc_0* Desc;  
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) { // 初始化方法
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

static void __main_block_func_0(struct __main_block_impl_0 *__cself) { // 执行方法
    123456789;
}

int main() {   // 对应上面的main函数
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 
        void (*IIIIIIII)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, 
                                                                    &__main_block_desc_0_DATA));
    }
    return 0;
}

思考:创建block结构体的名称「__main_block_impl_0」里的main,是不是与block所在的main函数关系呢?
我们能够发现名称其实是「__xxx_block_impl_n」这样的结构,通过实践得出:

  1. 如果是block在方法内创建,则xxx为所在方法名,否则xxx为block自身名称。
  2. 最后的数字n,在函数作用域内有n个block时,是这些block的自增id,而函数外则一直为0

可以看出C++的代码风格,让做iOS开发的非常不适应:长长短短下划线,分不清究竟是干啥的。
原因在于C的命名风格是下划线命名法,不是驼峰命名法的,我们把这个方法名转换一下试试看:

O-C++

上面代码中相同颜色的方框,对应的是相同的结构名。按照颜色,推荐的理解顺序是红绿蓝白
转变以后,看起来是不是没有那么陌生了呢?其中的细节,我已经在上面的代码中逐行加了注释。

我们能够看出block的结构中,最得我们关注的四个结构:

  • block对象 BlockObject ➔ __block_impl
  • block创建 CreateBlock ➔ __main_block_impl_0
  • block执行 BlockAction ➔ __main_block_func_0
  • block信息 BlockDesc ➔ __main_block_desc_0

合起来就是:对象-创建-执行-信息。
其中,在block捕获外部变量时,BlockDesc中会提供拷贝方法copy以及释放方法dispose,后面再详细说这里。

2.「Block」的类型


网上很多文章中提到的是这三种类型:

  • _NSConcreteGlobalBlock「全局block」
  • _NSConcreteStackBlock「栈block」
  • _NSConcreteMallocBlock「堆block」

或者另外三种类型:

  • __NSGlobalBlock __ 「全局block」
  • __NSStackBlock __ 「栈block」
  • __NSMallocBlock __ 「堆block」

但是我认为,抛开程序运行过程不提,单说这两大类名称都是不准确的。
我们从OC打印和C++源码中,可以看到:

O-Block
打印__NSGlobalBlock,源码是_NSConcreteGlobalBlock
I-Block
打印__NSGlobalBlock,源码却是_NSConcreteStackBlock

同一个block却出现类型不同的情况,原因在于打印是运行时,源码是编译期
所以,我们应该通过程序运行的不同时期进行区分,而不应将两类名称进行混用。

编译期名称

  1. 函数初始化的block均为_NSConcreteGlobalBlock
  2. 函数初始化的block均为_NSConcreteStackBlock
  3. 我们无法主动初始化_NSConcreteMallocBlock类型的block

运行时名称

  1. _NSConcreteGlobalBlock在任何情况下,都会生成__NSGlobalBlock
  2. _NSConcreteStackBlock在没有引入外部变量1的情况下,会转为__NSGlobalBlock,在ARC下其他情况2都会自动拷贝到堆,转为__NSMallocBlock

注释:
1.引入外部变量:是指使用了外部变量,不论是用于打印还是作为参数。
2.其他情况:ARC下block任意使用场景都是堆状态,不必深究出现的样式多少种,底层来说只能算作是同一种情况。

至于为什么编译的_NSConcreteStackBlock,运行时如何变为__NSGlobalBlock,猜测可能是系统在运行时创建block过程中进行了判断处理。

3.「Block」的变量捕获


3.1 Block的变量捕获有哪些方式?

研究什么是变量捕获之前,先列出我们常见的多种变量类型:全局变量、静态变量、普通数值变量、OC对象以及带上__block修饰的变量,在block的代码块中打印一下,看看结果:

#import 
#import 

int ivar_global_int = 11111;
char * ivar_global_char = "1";

int main() {
    @autoreleasepool {
        static int ivar_static_int = 2;
        int ivar_int = 3;
        NSArray * ivar_array = @[@"1"];
        NSMutableArray * ivar_marray = [[NSMutableArray alloc] init];
        NSObject * ivar_object = [[NSObject alloc] init];

        __block int ivar_int_block = 4;
        __block NSArray * ivar_array_block = @[@"2"];
        __block NSMutableArray * ivar_marray_block = [[NSMutableArray alloc] init];
        __block NSObject * ivar_object_block = [[NSObject alloc] init];
       
        void (^IIIIIIIII)(void) = ^{
            ivar_global_int = 0;
            NSLog(@"ivar_global_int:   %d %p", ivar_global_int, &ivar_global_int);
            NSLog(@"ivar_global_char:  %s %p", ivar_global_char, &ivar_global_char);
            NSLog(@"ivar_static_int:   %d %p", ivar_static_int, &ivar_static_int);
            NSLog(@"ivar_int:          %d %p", ivar_int, &ivar_int);
            NSLog(@"ivar_int_block:    %d %p", ivar_int_block, &ivar_int_block);
            NSLog(@"ivar_array:        _ %p", &ivar_array);
            NSLog(@"ivar_array_block:  _ %p", ivar_array_block);
            NSLog(@"ivar_marray:       _ %p", ivar_marray);
            NSLog(@"ivar_marray_block: _ %p", ivar_marray_block);
            NSLog(@"ivar_object:       _ %p", ivar_object);
            NSLog(@"ivar_object_block: _ %p", ivar_object_block);
        };
        IIIIIIIII();
    }
    return 0;
}
2022-05-06 17:16:48.131846+0800 BlockClangTest[20824:284328] ivar_global_int:   0 0x100008010
2022-05-06 17:16:48.132069+0800 BlockClangTest[20824:284328] ivar_global_char:  1 0x100008018
2022-05-06 17:16:48.132090+0800 BlockClangTest[20824:284328] ivar_static_int:   2 0x100008020
2022-05-06 17:16:48.132103+0800 BlockClangTest[20824:284328] ivar_int:          3 0x10072c7a8
2022-05-06 17:16:48.132113+0800 BlockClangTest[20824:284328] ivar_int_block:    4 0x10072c7c8
2022-05-06 17:16:48.132123+0800 BlockClangTest[20824:284328] ivar_array:        _ 0x10072c770
2022-05-06 17:16:48.132133+0800 BlockClangTest[20824:284328] ivar_array_block:  _ 0x100008050
2022-05-06 17:16:48.132200+0800 BlockClangTest[20824:284328] ivar_marray:       _ 0x10072c6d0
2022-05-06 17:16:48.132269+0800 BlockClangTest[20824:284328] ivar_marray_block: _ 0x10072c720
2022-05-06 17:16:48.132289+0800 BlockClangTest[20824:284328] ivar_object:       _ 0x1007291b0
2022-05-06 17:16:48.132305+0800 BlockClangTest[20824:284328] ivar_object_block: _ 0x1007288b0
Program ended with exit code: 0

思考:OC的内存对齐?
从打印的前三行,我们能够看出基本数值类型的内存对齐是 8 字节:
0x100008018 - 0x100008010 = 8
0x100008020 - 0x100008018 = 8 (提示:十六进制下8需要加8才可以进位变成10)
而OC对象的内存对齐是 16 字节,上图中看不出这个结论。
不过我们可以实例化一个NSObject对象object,通过打印 sizeof(object) 结果为 8 以及 malloc_size((__bridge const void *)object) 结果为 16 间接得出。

进行clang编译后,变量相关的代码量非常多,从中找出最关键的block创建结构体指针的源码,修改了一下对齐格式便于阅读,如下:

        // 生成block结构体指针
        void (*IIIIIIIII)(void) = ((void (*)())&__main_block_impl_0
                                   ((void *)__main_block_func_0,
                                    &__main_block_desc_0_DATA,
                                    &ivar_static_int,
                                    ivar_int,
                                    ivar_array,
                                    ivar_marray,
                                    ivar_object,
                                    (__Block_byref_ivar_int_block_0 *)&ivar_int_block,
                                    (__Block_byref_ivar_array_block_1 *)&ivar_array_block,
                                    (__Block_byref_ivar_marray_block_2 *)&ivar_marray_block,
                                    (__Block_byref_ivar_object_block_3 *)&ivar_object_block,
                                    570425344));
        // 调用block方法 代码就是: IIIIIIIII()
        ((void (*)(__block_impl *))((__block_impl *)IIIIIIIII)->FuncPtr)((__block_impl *)IIIIIIIII);

从这段源码中能够看出, 所谓block捕获变量,不过是将这些变量作为参数传递到结构体内部!

仔细看看,我们还能察觉到有些变量没有参与这个block的创建,它们是全局变量。而参与创建的变量,有些带有&符号表示指针访问,还有一些不带的表示值访问
另外,带__block修饰的变量,变成了编译器自动生成的新结构体 __Block_byref_xxx_n 的实例变量,并且带有&符号进行指针访问
这些差异充分说明不同类型的变量,被捕捉的方式是不同的。

总结一下,我们可以直观的发现规律:

网上很多文章都只将局部变量分成了两类:static变量auto变量,并将auto变量统归为值访问。但是,我认为这个说法不是很妥当,在接下来的小节3.2中,我们可以探究一下这个问题。

3.2 __block对变量会做什么操作?

从上一个小节「3.1 Block的变量捕获有哪些方式?」的第一个图的代码中,我们提取出一行带__block的简单代码进行单独分析,这行代码会编译成 __Block_byref_ivar_int_block_0 结构体和__attribute __ 开头的两段代码。

   __block int ivar_int_block = 4;               // 会编译成下面的struct结构体和__attribute__开头的两段代码

我们想要修改一个变量的值时,通常我们会在前面带上__block修饰符。普通auto变量被__block修饰后,会被包装成一个新的结构体,也可以看作为是一个新的OC对象:

// block新对象
struct __Block_byref_ivar_int_block_0 {
    void *__isa;                                    // isa指针
    __Block_byref_ivar_int_block_0 *__forwarding;   // 指向自身的指针 被copy到堆以后 指向堆对象内存地址
    int __flags;                                    // 不太清楚干啥的
    int __size;                                     // 内存大小
    int ivar_int_block;                             // 携带的数值
};

这个新的结构体 __Block_byref_ivar_int_block_0 创建实例变量 ivar_int_block,并使用C语言的 __attribute __ 指令来设置变量的属性,包含自身内存地址内存大小变量数值等信息,如下:

// 相当于 __block int ivar_int_block = 4;  这行代码
__attribute__((__blocks__(byref))) __Block_byref_ivar_int_block_0 ivar_int_block = {
    (void*)0,                                            // 对应__isa
    (__Block_byref_ivar_int_block_0 *)&ivar_int_block,   // 对应__forwarding
    0,                                                   // 对应__flags
    sizeof(__Block_byref_ivar_int_block_0),              // 对应__size 
    4                                                    // 对应ivar_int_block
};

思考:__forwarding指针是自身内存地址,有什么用?
网上一些文章称,在变量a被block拷贝到以后:
1.变量a的__forwording指针地址就变成了堆中新变量b的地址
2.被拷贝到堆的新变量b,__forwording指针地址是b的自身地址
感到非常合理,但是我也不知道怎么验证。

从下面block创建结构体指针的源码中,能够发现被block捕获的不是代码中的看似带__block的int变量,而是新的实例变量,并且新变量不像普通变量一样进行值传递,而是带上了&符号进行指针传递

所以,我们能够总结出,带有__block修饰变量应该归为指针访问,不应该和普通auto变量一样认为是值访问。

这里有两点需要思考:

    1. 使用__block来修饰变量,是否为block捕获变量过程的一个环节
      当然不是,没有任何直接关系:不修饰也能捕获,修饰了也可以不使用block。这里可以认为创建了一个新的变量,而这个被修饰的数值参与生成新变量的过程,与接下来的block捕获变量的过程毫无关系
    1. 这里被block捕获的是谁,是旧变量的数值还是新的Block变量
      从源码中能够分析出,看似存在的“旧变量”,已经被新的Block变量收在里面,它就像是西游记里“紫金红葫芦”一样,“旧变量”想出去是出不去的,更不能被直接拿来使用了,而block创建当然也只能使用新变量

如果不通过自己的理解来领悟事物的底层逻辑,终究还是感觉心里没谱。在网上搜寻别人的看法后,我从中发现了一些自我感觉矛盾的地方,而现在弄明白后理解的更加深刻,实现了学习-理解-批判-进步这个我瞎编的过程。

3.3 Block对被捕获变量的内存管理

关于捕获变量相关的内容,这个文章第38条讲述的很细致:
ChenYilong/iOSInterviewQuestions · GitHub

其中主要的几点总结:

  • 1.Block不允许修改外部变量,因为进入 block 中的变量相当于进入了另外一个函数作用域。
  • 2.在 ARC 中无论是否添加 __block,block 中的 auto 变量都会被拷贝到堆上。
  • 3.改外部变量必要条件:将 auto 变量封装为结构体(对象)。

众所周知,程序员只能够管理堆上的内存,所以我们可以想到:

  • 1. 全局变量和局部静态变量不在堆上,他们不需要block进行内存管理。

至于其他的变量类型,我们可以列出来进行思考:

  • 2. 原本在栈上的变量。比如:基本数值类型。
  • 3. 原本就在堆上的变量。比如说:OC实例对象,__block修饰的变量。

<#未完工#>

4.「Block」循环引用的发生和解决


4.1 Block的循环引用

你可能感兴趣的:(卷中卷1:关于「Block」的底层研究)