Linux 的 IO 通信 以及 Reactor 线程模型浅析

目录

随着计算机硬件性能不断提高,服务器 CPU 的核数越来越越多,为了充分利用多核 CPU 的处理能力,提升系统的处理效率和并发性能,多线程并发编程越来越显得重要。无论是 C++ 还是 Java 编写的网络框架,大多数都是基于 Reactor 模式进行设计和开发,Reactor 模式基于事件驱动,特别适合处理海量的 I/O 事件,今天我们就简单聊聊 Reactor 线程模型,主要内容分为以下几个部分:

  • 经典的 I/O 通信模型;
  • Reactor 线程模型详述;
  • Reactor 线程模型几种模式;
  • Netty Reactor 线程模型的实践;

IO 通信模型

我们先要来谈谈 I/O 通信。说到 I/O 通信,往往会提到同步(synchronous)I/O 、异步(asynchronous)I/O、阻塞(blocking)I/O 和非阻塞(non-blocking)I/O 四种。有关同步、异步、阻塞和非阻塞的区别很多时候解释不清楚,不同的人知识背景不同,对概念很难达成共识。本文讨论的背景是 Linux 环境下的 Network I/O。

一次 I/O 过程分析

对于一次 Network I/O (以 read 举例),它会涉及到两个系统对象,一个是调用这个 I/O 的进程或线程,另一个就是系统内核 (kernel)。当一个 read 操作发生时,会经历两个阶段(记住这两个阶段很重要,因为不同 I/O 模型的区别就是在两个阶段上各有不同的处理):

  • 第一个阶段:等待数据准备 (Waiting for the data to be ready);
  • 第二个阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process);

五种 I/O 模型

Richard Stevens 的《UNIX® Network Programming Volume》提到了 5 种 I/O 模型:

  1. Blocking I/O (同步阻塞 I/O)
  2. Nonblocking I/O(同步非阻塞 I/O)
  3. I/O multiplexing(多路复用 I/O)
  4. Signal driven I/O(信号驱动 I/O,实际很少用,Java 不支持)
  5. Asynchronous I/O (异步 I/O)

接下来我们对这 5 种 I/O 模型进行说明和对比。

Blocking I/O

在 Linux 中,默认情况下所有的 Socket 都是 blocking 的,也就是阻塞的。一个典型的读操作时,流程如图:

当用户进程调用了 recvfrom 这个系统调用, 这次 I/O 调用经历如下 2 个阶段:
1. 准备数据: 对于网络请求来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的 UDP 包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。
2. 数据返回:kernel 一但等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。

Nonblocking IO

Linux 下,可以通过设置 socket 使其变为 non-blocking,也就是非阻塞。当对一个 non-blocking socket 执行读操作时,流程如图:

当用户进程发出 read 操作具体过程分为如下 3 个过程:
1. 开始准备数据:如果 Kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。
2. 数据准备中: 从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作(重复轮训)。
2. 一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

I/O multiplexing

这种 I/O 方式也可称为 event driven I/O。Linux select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 I/O。它的基本原理就是 select/epoll 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。流程如图:

当用户进程调用了 select:
1. 整个进程会被 block,与此同时kernel 会 “监视” 所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。
2. 户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。这时和 blocking I/O 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select 和 recvfrom),而 blocking I/O 只调用了一个 system call (recvfrom)。
3. 在 I/O multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket I/O 给 block。

Asynchronous IO

Linux 下的 asynchronous I/O,即异步 I/O,其实用得很少(需要高版本系统支持)。它的流程如图:

当用户进程发出 read 操作具体过程:
1. 用户进程发起 read 操作之后,并不需要等待,而是马上就得到了一个结果,立刻就可以开始去做其它的事。
2. 从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个 signal,告诉它 read 操作完成了。


通过以上 4 种 I/O 通信模型的说明,总结一下它们各自的特点:

  • Blocking I/O 的特点就是在 I/O 执行的两个阶段都被 block 了。
  • Non-blocking I/O 特点是如果 kernel 数据没准备好不需要阻塞。
  • I/O multiplexing 的优势在于它用 select 可以同时处理多个 connection。(如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking I/O 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
  • Asynchronous IO 的特点在于整个调用过程客户端没有任何 block 状态,但是需要高版本的系统支持。

生活中通信模型

以上五种 I/0 模型的介绍,比如枯燥,其实在生活中也存在类似的 “通信模型”,为了帮助理解,我们用生活中约妹纸吃饭这个不是很恰当的例子来说明这几个 I/O Model(假设我现在要用微信叫几个妹纸吃饭):

  • 发个微信问第一个妹纸好了没,妹子没回复就一直等,直到回复在发第二个 (blocking I/O)。
  • 发个微信问第一个妹纸好了没,妹子没回复先不管,发给第二个,但是过会要继续问之前 没有回复的妹纸有没有好(nonblocking I/O)。
  • 将所有妹纸拉一个微信群,过会在群里问一次,谁好了回复一下(I/O multiplexing)。
  • 直接告诉妹纸吃饭的时间地址,好了自己去就行(Asynchronous I/O)。

Reactor 线程模型

Reactor 是什么?

Reactor 是一种处理模式。 Reactor 模式是处理并发 I/O 比较常见的一种模式,用于同步 I/O,中心思想是将所有要处理的IO事件注册到一个中心 I/O 多路复用器上,同时主线程/进程阻塞在多路复用器上;一旦有 I/O 事件到来或是准备就绪(文件描述符或 socket 可读、写),多路复用器返回并将事先注册的相应 I/O 事件分发到对应的处理器中。

Reactor 也是一种实现机制。 Reactor 利用事件驱动机制实现,和普通函数调用的不同之处在于:应用程序不是主动的调用某个 API 完成处理,而是恰恰相反,Reactor 逆置了事件处理流程,应用程序需要提供相应的接口并注册到 Reactor 上,如果相应的事件发生,Reactor 将主动调用应用程序注册的接口,这些接口又称为 “回调函数”。用 “好莱坞原则” 来形容 Reactor 再合适不过了:不要打电话给我们,我们会打电话通知你。

为什么要使用 Reactor?

一般来说通过 I/O 复用,epoll 模式已经可以使服务器并发几十万连接的同时,维持极高 TPS,为什么还需要 Reactor 模式?原因是原生的 I/O 复用编程复杂性比较高。

一个个网络请求可能涉及到多个 I/O 请求,相比传统的单线程完整处理请求生命期的方法,I/O 复用在人的大脑思维中并不自然,因为,程序员编程中,处理请求 A 的时候,假定 A 请求必须经过多个 I/O 操作 A1-An(两次 IO 间可能间隔很长时间),每经过一次 I/O 操作,再调用 I/O 复用时,I/O 复用的调用返回里,非常可能不再有 A,而是返回了请求 B。即请求 A 会经常被请求 B 打断,处理请求 B 时,又被 C 打断。这种思维下,编程容易出错。

Reactor 线程模型

Reactor 有三种线程模型,用户能够更加自己的环境选择适当的模型。

  1. 单线程模型
  2. 多线程模型(单 Reactor)
  3. 多线程模型(多 Reactor)

单线程模式

单线程模式是最简单的 Reactor 模型。Reactor 线程是个多面手,负责多路分离套接字,Accept 新连接,并分派请求到处理器链中。该模型适用于处理器链中业务处理组件能快速完成的场景。不过这种单线程模型不能充分利用多核资源,所以实际使用的不多。

多线程模式(单 Reactor)

该模型在事件处理器(Handler)链部分采用了多线程(线程池),也是后端程序常用的模型。

多线程模式(多 Reactor)

比起多线程单 Rector 模型,它是将 Reactor 分成两部分,mainReactor 负责监听并 Accept新连接,然后将建立的 socket 通过多路复用器(Acceptor)分派给subReactor。subReactor 负责多路分离已连接的 socket,读写网络数据;业务处理功能,其交给 worker 线程池完成。通常,subReactor 个数上可与 CPU 个数等同。

Reactor 使用

软件领域很多开源的产品使用了 Ractor 模型,比如 Netty。

Netty Reactor 实践

服务端线程模型

服务端监听线程和 I/O 线程分离,类似于 Reactor 的多线程模型,它的工作原理图如下:

服务端用户线程创建

  • 创建服务端的时候实例化了 2 个 EventLoopGroup。bossGroup 线程组实际就是 Acceptor 线程池,负责处理客户端的 TCP 连接请求。workerGroup 是真正负责 I/O 读写操作的线程组。通过这里能够知道 Netty 是多 Reactor 模型。
  • ServerBootstrap 类是 Netty 用于启动 NIO 的辅助类,能够方便开发。通过 group 方法将线程组传递到 ServerBootstrap 中,设置 Channel 为 NioServerSocketChannel,接着设置 NioServerSocketChannel 的 TCP 参数,最后绑定 I/O 事件处理类 ChildChannelHandler。
  • 辅助类完成配置之后调用 bind 方法绑定监听端口,Netty 返回 ChannelFuture,
    f.channel().closeFuture().sync() 对同步阻塞的获取结果。
  • 调用线程组 shutdownGracefully 优雅推出,释放资源。
public class TimeServer {
    public void bind(int port) {
        // 配置服务端的NIO线程组
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workGroup).channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childHandler(new ChildChannelHandler());
            // 绑定端口,同步等待成功
            ChannelFuture f = b.bind(port).sync();
            // 等待服务端监听端口关闭
            f.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放线程池资源
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }

    private class ChildChannelHandler extends ChannelInitializer<SocketChannel> {
        @Override
        protected void initChannel(SocketChannel ch) throws Exception {
            ch.pipeline().addLast(new TimeServerHandler());
        }
    }

服务端 I/O 线程处理(TimeServerHandler)

  • exceptionCaught 方法: 当 I/O 处理发生异常时被调用,关闭 ChannelHandlerContext,释放资源。
  • channelRead 方法: 是真正处理读写数据的方法,通过 buf.readBytes 读取请求数据。通过 ctx.write(resp) 将相应报文发送给客户端。
  • channelReadComplete 方法: 为了提高性能,Netty write 是将数据先写到缓冲数组,通过 flush 方法可以将缓冲数组的所有消息发送到 SocketChannel 中。
public class TimeServerHandler extends ChannelHandlerAdapter {

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
            throws Exception {
        ctx.close();
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception {
        // msg转Buf
        ByteBuf buf = (ByteBuf) msg;
        // 创建缓冲中字节数的字节数组
        byte[] req = new byte[buf.readableBytes()];
        // 写入数组
        buf.readBytes(req);
        String body = new String(req, "UTF-8");
        String currenTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(
                System.currentTimeMillis()).toString() : "BAD ORDER";
        // 将要返回的信息写入Buffer
        ByteBuf resp = Unpooled.copiedBuffer(currenTime.getBytes());
        // buffer写入通道
        ctx.write(resp);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        // write读入缓冲数组后通过invoke flush写入通道
        ctx.flush();
    }
}

总结

通过以上大概了解 Reactor 相关知识。最后做个总结一下使用 Reactor 模型的优缺点。

  • 优点
    • 响应快,虽然 Reactor 本身依然是同步的,不必为单个同步时间所阻塞。
    • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销。
    • 可扩展性,通过并发编程的方式增加 Reactor 个数来充分利用 CPU 资源。
    • 可复用性,Reactor 框架本身与具体事件处理逻辑无关,具有很高的复用性。
  • 缺点
    • 相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,调试相对复杂。
    • Reactor 模式需要底层的 Synchronous Event Demultiplexer 支持,例如 Java 中的 Selector,操作系统的 select 系统调用支持。
    • 单线程 Reactor 模式在 I/O 读写数据时还是在同一个线程中实现的,即使使用多 Reactor 机制的情况下,共享一个 Reactor 的 Channel 如果出现一个长时间的数据读写,会影响这个 Reactor 中其他 Channel 的相应时间,比如在大文件传输时,I/O 操作就会影响其他 Client 的相应时间,因而对这种操作,使用传统的 Thread-Per-Connection 或许是一个更好的选择,或则此时使用 Proactor 模式。
0

浅谈 BI 与数据分析的可视化

一、名词解释

估计这个标题对大家来说不是很好理解,在开始正题之前,我们先对标题中的「BI 报表可视化」和「分析报表可视化」做个名词解释。

BI 报表可视化

  • 广义 BI:商务智能的一套整体解决方案,包括数据仓库、报表查询、数据分析、数据挖掘、数据可视化等;
  • 狭义 BI:敏捷 BI 产品,一款可视化产品,如永洪 BI、Tableau 等。

通过敏捷 BI 产品设计制作的,提供给业务部门日常使用的可视化报表,就是今天要说的 BI 报表可视化。

分析报表可视化

对某块业务某些具体现象或问题的洞察,应该就是咱们所说的分析。在业务分析结果报告中使用的可视化图表(请留意是结果报告),就是分析报表可视化。

二、说说共同点

他们的共性主要体现在三个方面。

1.都通过可视化来展示信息

同属于可视化,都是通过图表形式展现数据,帮助用户快速、准确理解信息,向用户揭示数据背后的规律。

2.具备可视化共有的基础特性

  • 准确、快速表达信息;
  • 好看、高大上但不复杂。

3.图表类型通用

可视化的各种图表类型,诸如:柱形图、散点图、瀑布图等,在分析报表中可用,在 BI 报表中同样可用。

下面举三个示例来对「图表类型通用」做个说明

示例1:假设你是某个班的班主任,你想要分析自己班上所有学生的成绩情况。你制作了这张箱线图,分析结果告诉你雷军成绩最好,马云成绩最不佳,李彦宏成绩最稳定,王小川最可能超常发挥。那么,如果是教导主任呢?他如果希望是教务系统中有一个可以直接查看全校学生成绩的页面,那么就可以将这个箱线图制作成一个 BI 视图。

image

示例2:某家公司做了一次运营策略调整,运营重点从获客转向流失用户的唤回。运营总监想知道下面的几位运营经理,是否将这个策略执行到位了。好,如果总监看到的结果不是如图所示的运营策略的调整已经被执行到位,那么总监可能很生气。他完全有理由让你制作一份常规报表放到 BI 系统中去,因为他需要每周、每月查看这个报表来检查各部门是否执行到位。

image

示例3:某家公司每年收入近两千万,Boss 希望你帮他分析 2017 年公司的营收都花在哪些模块了,最终毛利润是多少,那么你帮 Boss 制作了这张图作为分析结果呈现。Boss 看完后,认为分析的非常好,这个模式应该被推广被复用,他说你帮我做到 BI 系统里去,Boss 们每月、每季度都需要看到。

image

以上三个事例中,同一类型图表在分析报表可视化中 BI 报表可视化中都是可用的!

三、谈谈不同之处

「分析报表可视化」和「BI 报表可视化」的关键差异在于它们的出发点是不同的。分析报表可视化的核心是输出结论;BI 报表可视化是某业务主题下灵活的动态分析。

分析报表可视化:核心是结论

每一次的业务分析结论与建议都会被要求是明确的,所以我们会把图表里所有通用的模块都调整为很精细化的,紧紧地围绕本次分析展开。

分析报表的核心点是结论,一切都是围绕结论展开的。分析报表中的可视化图表是报告的组成部分,它必须是围绕分析结论,服务于结论表达的。你的分析结论越明确,越多的通用部分就会被抹去。

BI 报表可视化:业务主题下灵活地动态分析

BI 报表同样具备一定的主题性,但它不是这个主题下某个非常明确的结论,它更多地是一种分析模式,是某个主题下的动态分析。所以 BI 报表需要支持该主题下的各种场景、各种洞察需求。比如我们刚才提到的瀑布图,其中是一种技术分析模式,我们可以在瀑布图的基础上,按业务线、产品线的不同,结合相应的图表,搭建一个完整的仪表盘(Dashboard)。

一般来说,对于分析报表而言,老板看完,就代表这件事结束了;而 BI 则是可以被反复使用的。新上线一个 BI 报表,意味着这个 BI 报表的维护、优化才刚刚开始,需要根据战略目标、业务需求不断地进行调整,所以 BI 报表的核心需求是灵活地支持动态分析。

下面,我们举个简单的例子来说明:

image

假设有家公司的业务是做一个医院挂号的网站。老板提了一个需求,想知道今年患者挂号的科室分布情况,你可能会做左边这样的图,把很多小的科室合并了,因为老板更关心排名在前的科室。这样做可以突出重点。但是如果这个报表是存在于 BI 系统中的,那么用左边这张图就不行了。因为它当某些同事想要知道「眼科」患者挂号情况,那左边图就没有相关信息。

我们的分析报表只要给老板展示 Top N 的重点,而在 BI 报表里面,就需要考虑到信息的全面性,覆盖到不同业务部门各式各样的需求。

四、灵活地支持动态分析

我们将支持动态分析行为分为基础和进阶两个层次。
- 基础的就是维度下钻和上卷,能够进行维度的深入和聚合,以及界面的联动,有时候联动在层次太多的情况下不适用,就需要提供筛选器进行筛选。
- 进阶的是维度切换、子视图展示、视图动作跳转、下载数据格式自定义。

image

接下来重点说明其中的「维度下钻、上卷与切换」、「子视图展示」及「视图动作跳转」。其它的支持动态分析的技巧,如:界面联动、维度筛选器等都是市面上 BI 工具常见的功能,这里就不赘述了。

1.维度下钻、上卷与切换

某家中药销售公司,当前与五家合作方合作。运营人员经常会有需要分析:不同厂家的销售排名、不同剂型的销售排名,不同厂家下的剂型销售排名等。这里我们通过维度下钻、上卷与切换,使之在一个图表中实现。

通过维度下钻,我们在厂家销售排名基础上细分观察剂型销售排名;

通过维度上卷,我们隐藏了剂型信息,只观察厂家销售排名;

通过维度切换,我们实现「剂型-厂家」的细分维度,此时上卷后则就是观察剂型的销售排名。

image

2.子视图展示

下图,展示了某家公司在全美的销售分布。如果此时,我们想要观察每个州的类目成交分布,常规的做法往往是另做一个 BI 视图页,在这个视图中展示详细类目成交。但其实,我们可以将比较简单的分类信息在子视图中展示,当用户悬停在某个州时就显示类目成交。这可以极大的方便用户查阅数据。

image

3.视图动作跳转

子视图只适合信息量较小的附加,如果是大面积的详细信息添加,那么建议使用:视图跳转。下图是,某家公司一级类目、二级类目的销售情况,如果还想看到单个产品的销售利润情况,可以在这里制作一个子视图,提升查看商品详情,就可以跳转到每个产品的销售利润视图。

image

以上,就是个人关于「BI 报表可视化」和「分析报表可视化」异同的看法。欢迎讨论。

0

iOS 照片涂鸦功能的实现

上个版本项目图片编辑需求中有一项涂鸦涂鸦就是选取颜色后根据手指滑动路径在图片上生成对应路径,并最终生成一张新图片。接到项目,Google、Github 搜索一番,看到不少实现思路和 Demo,心想:so easy!然而,具体实现的时候却发现了这些 Demo 存在严重问题...

初版实现

gif_Memory03

实现了我们要的功能,是不是很完美?看效果是 OK,在讨论遇到的问题前,我们先来看看核心代码的实现。

初版核心代码

  1. 开始绘制的时候,获取初始坐标信息写入 CGPath 对象:
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);
}
  1. 随着手指的移动,触发 touchesMoved 方法,添加路径信息到 CGPath 对象,主动调用 setNeedsDisplay 使 view 得以执行 drawRect
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    //移动->重新绘图
    [self setNeedsDisplay];
}
  1. 按步撤销:
(void)revoke
{
    [_pathArray removeLastObject];
    [self setNeedsDisplay];
}
  1. 最后就是绘制核心方法:
(void)drawRect:(CGRect)rect 
{
    for (PathModal *modal in _pathArray)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        [modal.color setStroke];
        CGContextSetLineWidth(context, modal.width);
        CGContextAddPath(context, modal.path);
        CGContextDrawPath(context, kCGPathStroke);
    }

    if (_path != nil)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextAddPath(context, _path);
        [[XRPhotoEditTheme themeManager].color setStroke];
        CGContextSetLineWidth(context, _drawWidth);
        CGContextDrawPath(context, kCGPathStroke);  
    }    
}

性能问题?

上面的代码,虽然实现了需求,但是从代码里也能看到,不断的进行数组操作、调用 drawRect,连撤销操作都不是移除最后一条路径而是先清空页面绘制将余下的路径重新绘制。看到这里,就算对 drawRect 使用场景及注意事项不甚了解你也会疑问:这操作会有性能问题吧?

光靠代码逻辑的复杂程度毙掉此方案,略失严谨。但是通过真机体验可以感受到路径多了后,开始卡顿,然后收到 didReceiveMemoryWarning,之后大概率的会崩溃。通过 XCode 运行信息也可以看到内存的暴涨:

gif_Memory02

为了确认内存暴涨是因为不断的对象初始化操作还是 drawRect 绘制的,首先我们只保留了 setNeedsDisplay 调用和 drawRect 绘制所必需得代码,结果随着不断的绘制内存还是不断升高。

随后只保留手势、对象操作,注释掉绘制逻辑。在注释掉 drawRect 之后内存立刻恢复正常,我们终于抓到了消耗内存的元凶:不断的执行 drawRect 方法。

最终我们将内存暴增的原因找了出来,那为什么不断的调用 drawRect 绘制会收到内存警告而崩溃?有没有更好的解决方案呢?

重写

在 iOS 系统中所有显示的视图都是从基类 UIView 继承而来的,同时 UIView 负责接收用户交互。

但是实际上你所看到的视图内容,包括图形等,都是由 UIView 的一个实例图层属性来绘制和渲染的,那就是 CALayer,可以说
CALayer 是 UIView 的内部实现。而且基于 UIViewdrawRect 绘制方式存在内存暴涨的风险,我们综合研究后认为画板这种需要实时反馈在UI上的绘制需求应直接用专有图层 CAShapeLayer。至于为甚么是 CAShapeLayer,我们稍后讲。

  1. 初始化路径
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);

    CAShapeLayer * slayer = [CAShapeLayer layer];
    slayer.path = _path;
    // 略:设置 slyer 参数
    [self.layer addSublayer:slayer];
    _shapeLayer = slayer;

    [_pathArray addObject:slayer];
   }
  1. 路径绘制
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];
    //略:point有效值判断

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    _shapeLayer.path = _path;
}
  1. 按步撤销
(void)revoke
{
    [_pathArray.lastObject removeFromSuperlayer];
    [_pathArray removeLastObject];
}
  1. 检验

gif_Memory01

以上代码用 CAShapeLayer 实现了路径的绘制,当然还有优化的空间:比如公用一个 CAShapeLayer 对象来实现;但不是本文的重点。
毕竟现在的代码运行起来发现内存和 CPU 的波动微乎其微,内存消耗也维持在较低的水平。

反思

重写 drawRect 方法为何会导致内存大量上涨?

要弄明白这个问题,我们先来回顾一下 iOS 中图像显示原理:

上文已经讲到 CALayerUIView 的内部实现,在每一个 UIView 实例当中,都有一个默认的图层 CALayerUIView 负责创建并且管理这个图层。而实际上这个 CALayer 图层并不是真正用来在屏幕上显示的。但为什么我们能看到 CALayer 的内容呢?是因为 CALayer 内部有一个 contents 属性。contents 默认可以传一个 id 类型的对象,但是只有你传 CGImage 对象的时候,它才能够正常显示在屏幕上。 所以最终图形渲染落脚点落在了 contents 上面。

contentsdrawRect 又有什么关系呢?

查阅文档发现 drawRect 方法没有默认的实现,因为对 UIView 来说,寄宿图并不是必须的,UIView 不关心绘制的内容。但如果 UIView 检测到 drawRect 方法被调用了,它就会为视图分配一个 contents (寄宿图)。这个寄宿图就是我们问题的关键:寄宿图的像素尺寸等于视图大小乘以 contentsScale (这个属性与屏幕分辨率有关,我们的画板程序在不同模拟器下呈现的内存用量不同也是因为它)的值。

回到我们的涂鸦程序,当画板从屏幕上出现的时候,因为重写了 drawRect 方法,drawRect 方法就会自动调用。 生成一张寄宿图 后,绘制方法里面的代码利用 Core Graphics 去绘制路径,然后内容会缓存起来,等待下次调用 setNeedsDisplay 时再次调用 drawRect 方法更新到寄宿图。

理解到这里,我们可以知道:调用一次 drawRect 方法,图层就创建了一个绘制寄宿图上下文,而这个上下文所需要的内存可从这个公式得出:图层宽*图层高*4 字节,宽高的单位均为像素。那么一张 4000*3001 的高清图创建一个上下文占用多少内存呢:4000*3001*4,相当于 46MB 内存。而涂鸦要求随着手指的移动立马在图层上展示出路径,使用 drawRect 方法重绘则必须不停的去 setNeedsDisplay 从而频繁触发 drawRect不断的创建寄宿图。

这就是我们画板程序内存暴增的真正原因

为什么是 CAShapeLayer

CAShapeLayer 是一个通过矢量图形而不是 bitmap 来绘制的图层子类。用 CGPath 来定义想要绘制的图形,CAShapeLayer 会自动渲染,而且它采用了硬件加速处理速度非常快而且内存占用更低。它可以完美替代我们的直接使用 Core Graphics 绘制 layer

总结

最终我们决定放弃 drawRect 而选用图层 CAShapeLayerCAShapeLayer 不仅在功能上满足了我们的需求,对比之下 CAShapeLayer 在性能方面表现也非常出色。

另外,因为模拟器内存是分配的 Mac 系统的内存可用内存远高于真机,这种性能问题很难在使用模拟器的开发阶段被关注到,所以开发过程中就尽量使用真机来测试吧。

参考

http://www.cnblogs.com/yaokang/archive/2012/06/02/2532257.html
https://www.cnblogs.com/chrisbin/p/6391933.html?utm_source=itdadao&utm_medium=referral

0

捋一捋 App 性能测试中的几个重要概念

我们在使用各种 App 的时候基本会关注到:这款软件挺耗流量的?运行起来设备掉电有点快嘛?切换页面的时候还会有卡顿等现象?如果遇到有这些问题的 App 我们基本会将它请出我们的爱机。由此可见软件是否受欢迎除了提供必要的功能外,流畅性、流量/电池消耗也是很重要的指标。

今天就来从我们测试人员的角度,谈一谈 App 验收测试过程中需要关注到一些指标项目:

  • 内存占用
  • CPU 占用
  • 流量耗用
  • 电量耗用
  • 启动时间

因为最近就着腾讯 TMQ 团队出版的《移动 App 性能评测与优化》这本书在看 App 性能测试这一块的东西,看着看着发现有些名词或者概念不是很明白,所以在看这块东西的时候,还要一边去查询一些其他的点,现在将这些点记录下来,也算是一篇读书笔记了吧,下面针对每一个方面的一些重要知识点进行了整理。

一. 内存

1. 内存泄漏

说到内存方面,最经典的内存问题当数内存泄漏。百度上对内存泄漏的定义是这样的:内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。通俗点讲,在大部分应用中,会有一类功能是需要加载附加资源的,比如显示从网络下载的文本或图片。这类功能往往需要在内存中存放要使用的资源对象,退出该功能后,就需要将这些资源对象清空。如果忘了清理,或者是代码原因造成的清理无效,就会形成内存泄漏。

2. 垃圾回收

说到了内存泄漏,又不得不提到垃圾回收(Garbage Collector,简称 GC),内存中的垃圾,主要指的是内存中已无效但又无法自动释放的空间,除非是重启系统不然永远也不会还给操作系统。这样以来,时间久了当程序运行的时候就会产生很多垃圾,一方面浪费了不少内存空间,另一方面如果同一个内存地址被删除两次的话,程序就会不稳定,甚至奔溃。

在 Java 程序运行过程中,一个垃圾回收器会不定时地被唤起检查是否有不再被使用的对象,并释放它们占用的内存空间。但垃圾回收器的回收是随机的,可能在程序的运行的过程中,一次也没有启动,也可能启动很多次,它并不会因为程序一产生垃圾,就马上被唤起而自动回收垃圾。所以垃圾回收也并不能完全避免内存泄漏的问题。

另一方面,垃圾回收也会给系统资源带来额外的负担和时空开销。它被启动的几率越小,带来的负担的几率就越小。

3. 内存指标

内存指标有 VSS、RSS、PSS、USS,他们的含义分别是:

VSS:Virtual Set Size 虚拟耗用内存(包含共享库占用的内存)

RSS:Resident Set Size 实际使用物理内存(包含共享库占用的内存)

PSS:Proportional Set Size 实际使用的物理内存(按比例分配共享库占用的内存)

USS:Unique Set Size 进程独自占用的物理内存(不包含共享库占用的内存)

一般来说内存占用大小有如下规律:VSS >= RSS >= PSS >= USS,一般测试中关注的比较多的是 PSS 这个指标。

4. 监控与分析工具

以下是几种常见的内存分析工具,具体使用方法这里就不详述了。

4.1 Memory Monitor

该工具位于 Android Monitor 下面,Android Monitor 是 Android Studio 自带的一个强大的性能分析工具,里面一共包含 5 个模块:Logcat、Memory、CPU、Network 及 GPU。

image

Memory Monitor 可以实时查看 App 的内存分配情况,判断 App 是否由于 GC 操作造成卡顿以及判断 App 的 Crash 是否是因为超出了内存。

4.2 Heap Viewer

该内存检测工具位于 DDMS 下面,在 Android Studio 里面可以通过 Tools-Android-Android Device Monitor 打开,Heap Viewer 可以实时查看 App 分配的内存大小和空闲内存大小,并且发现 Memory Leaks。

image

4.3 MAT

MAT(Memory Analyzer Tool),是一个被老生常谈的 Android 内存分析工具,它可以清楚的获知整体内存使用情况。虽然是 Eclipse 的工具,但也可以单独运行,不需要安装 Eclipse。

image

二. CPU

1. 时间片

时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。

2. Jiffies

2.1 Jiffies的概念

要讲 Jiffies 需要先提到这两个概念:HZ 和 Tick

HZ:Linux 核心每隔固定周期会发出 timer interrupt (IRQ 0),HZ 是用来定义每一秒有几次 timer interrupts。例如 HZ 为 1000,就代表每秒有 1000 次 timer interrupts。

Tick:HZ 的倒数,Tick = 1/HZ,即 timer interrupt 每发生一次中断的时间。如 HZ 为 250 时,tick 为 4 毫秒(millisecond)。

而 Jiffies 为 Linux 核心变量,是一个 unsigned long 类型的变量,被用来记录系统自开机以来,已经过了多少 tick。每发生一次 timer interrupt,Jiffies 变数会被加 1。

2.2 查看 Jiffies 的方法

Linux 下使用命令cat /proc/stat,查看具体整机的 Jiffies,如图:

image

Linux 下使用命令cat /proc/<进程id>/stat,查看具体某个进程的 Jiffies:

image

3. CPU 使用率

在 Linux 系统下,CPU 利用率分为用户态、系统态和空闲态,他们分别代表的含义为:用户态表示 CPU 处于用户态执行的时间,系统态表示系统内核执行的时间,空闲态表示空闲系统进程执行的时间。

而一个 App 的 CPU 使用率 = CPU 执行非系统空闲进程时间 / CPU 总的执行时间,也可以表示为 App 用户态 Jiffies + App 系统态 Jiffies / 手机总 Jiffies。

4. CPU 过高会带来的影响

可能会使整个手机无法响应,整体性能降低,引起 ANR,导致手机更耗电,降低用户体验等。

三. 流量

1. 定义

我们的手机通过运营商的网络访问 Internet,运营商替我们的手机转发数据报文,数据报文的总大小(字节数)即流量,数据报文是包含手机上下行的报文。

2. 常用流量测试方法

2.1 抓包测试法

主要是利用工具 Tcpdump 抓包,导出 pcap 文件,再在 wireshark 中打开进行分析。

2.2 统计测试法
2.2.1 读取 linux 流量统计文件

利用 Android 自身提供的 TCP 收发长度的统计功能,获取 App 的 tcp_snd 和 tcp_rcv 的值,测试一段时间后再分别统计一次,用 tcp_snd
两者的差值得到发送流量,用 tcp_rcv 两者的差值得到接受流量。

2.2.2 利用 Android 流量统计 API
  • TrafficStats

Android 2.2 版本开始加入 android.net.TrafficStats 类来实现对流量统计的操作。

部分方法如下:

static long getMobileRxBytes() //获取通过移动数据网络收到的字节总数 
static long getMobileTxBytes() //通过移动数据网发送的总字节数 
static long getTotalRxBytes() //获取设备总的接收字节数 
static long getTotalTxBytes() //获取设备总的发送字节数 
static long getUidRxBytes(int uid) //获取指定uid的接收字节数 
static long getUidTxBytes(int uid) //获取指定uid的发送字节数 

* NetworkStatsManager

Android 6.0 版本开始,为了打破了原本 TrafficStats 类的查询限制,官方又提供了 NetworkStatsManager 类,可以获取更精准的网络历史数据,也不再是设备重启以来的数据。部分方法如下:

 NetworkStats.Bucket querySummaryForDevice(int networkType, String subscriberId, long startTime, long endTime) // 查询指定网络类型在某时间间隔内的总的流量统计信息
 NetworkStats queryDetailsForUid(int networkType, String subscriberId, long startTime, long endTime, int uid) // 查询某uid在指定网络类型和时间间隔内的流量统计信息
 NetworkStats queryDetails(int networkType, String subscriberId, long startTime, long endTime) // 查询指定网络类型在某时间间隔内的详细的流量统计信息(包括每个uid)

四. 电量

1.耗电场景

  • 定位,尤其是调用 GPS 定位。
  • 网络传输,尤其是非 Wifi 环境。
  • 屏幕亮度
  • CPU 频率
  • 内存调度频度
  • wake_locker 时间和次数
  • 其他传感器

2.测试方法

2.1 通过系统文件获取电量记录

使用命令 adb shell dumpsys batterystats> batterystats.txt 可以打印出详细的耗电相关信息并保存统计的电量信息到 batterystats.txt 这个文件里。

image

2.2 通过导入 batterystats.txt 到 Google 开源工具 battery historian 进行分析

因为这个工具是 Go 语言开发,所以需要预装Go语言开发环境,当然如果你不想配置Go语言环境,官方还提供了一种更方便的方案,通过安装 docker 环境来使用这个工具。具体这个工具的配置安装和具体使用方法以及参数的代表含义,我会单独再写一篇文章记录,先抛砖引玉放一张这个工具的运行截图。

image

3.优化方法

3.1 CPU 时间片

当应用退到后台运行时,尽量减少应用的主动运行,当检测到 CPU 时间片消耗异常时,深入线程进行分析。

3.2 wake lock

前台运行时不要注册 wake lock。

后台运行时,在保证业务需要的前提下,应尽量减少注册 wake lock。

降低对系统的唤醒频率, 使用 partial wake lock 代替 wake lock。

3.3 传感器

合理设置 GPS 的使用时长和使用频率。

3.4 云省电策略

考虑到用户使用场景的多样性,导致很难定位用户异常耗电的根源,所以为了更深一层弄清楚这些问题,可以考虑定期上报灰度用户手机电量数据的方式来分析问题。

五. 启动时间

可使用命令 adb shell am start -W packagename/activity 查看 App 启动耗时,查看了一下我们自己的 App Android 版本的启动耗时如下:

image

注释:

WaitTime:总的耗时,包括前一个应用 Activity pause 的时间和新应用启动的时间

ThisTime:一连串启动 Activity 的最后一个 Activity 的启动耗时

TotalTime:新应用启动的耗时,包括新进程的启动和 Activity 的启动,但不包括前一个应用 Activity pause 的耗时

六. 总结

App性能问题会直接影响产品体验:耗流量、掉电快、卡顿、崩溃等现象会给用户造成不良的体验和印象,不利于产品的活跃及用户留存。许多经验丰富的程序员也会经常忽视这些不起眼的性能问题,因此作为测试人员,在版本发布前及早关注并发现上述性能问题就显得尤其重要。但真正做起App性能测试才发现这条路并不容易走,抓取内存、CPU 等一些项目的指标容易,但对一些数据的敏感度和处理方式都是要靠经验的慢慢积累。总之,性能测试算是一项比较繁琐的工作,但难者不易,易者不难,希望已经行走在这条路上的或者准备踏上这条路的同行都能不断提高自身素养,坚持到底。

参考

0