热门标签 | HotTags
当前位置:  开发笔记 > 运维 > 正文

如何正确使用Android线程详解

线程是程序员进阶的一道重要门槛。除了了解各类开线程的API之外,更需要理解线程本身到底是个什么样的存在,并行是否真的高效?系统是怎么样去调度线程的?开线程的方式那么多,什么样的姿势才正确?下面通过本文来好好再学习下。

前言

对于移动开发者来说,“将耗时的任务放到子线程去执行,以保证UI线程的流畅性”是线程编程的第一金科玉律,但这条铁则往往也是UI线程不怎么流畅的主因。我们在督促自己更多的使用线程的同时,还需要时刻提醒自己怎么避免线程失控。

多线程编程之所以复杂原因之一在于其并行的特性,人脑的工作方式更符合单线程串行的特点。一个接着一个的处理任务是大脑最舒服的状态,频繁的在任务之间切换会产生“头痛”这类系统异常。人脑的多任务和计算机的多任务性能差异太大导致我们在设计并行的业务逻辑之时,很容易犯错。

另一个复杂点在于线程所带来的副作用,这些副作用包括但不限于:多线程数据安全,死锁,内存消耗,对象的生命周期管理,UI的卡顿等。每一个新开的线程就像扔进湖面的石子,在你忽视的远处产生涟漪。

把抽象的东西具像化是我们认知世界的主要方式。线程作为操作系统世界的“公民”之一,是如何被调度获取到CPU和内存资源的,又怎么样去和其他“公民”互通有无进而实现效益最大化?把这些实体和行为具像到大脑,像操作系统一样开“上帝视角”,才能正确掌控线程这头强大的野兽。

进程优先级(Process Priority)

线程寄宿在进程当中,线程的生命周期直接被进程所影响,而进程的存活又和其优先级直接相关。在处理进程优先级的时候,大部分人靠直觉都能知道前台进程(Foreground Process)优先级要高于后台进程(Background Process)。但这种粗糙的划分无法满足操作系统高精度调度的需求。无论Android还是iOS,系统对于Foreground,Background进程有进一步的细化。

Foreground Process

Foreground一般意味着用户双眼可见,可见却不一定是active。在Android的世界里,一个Activity处于前台之时,如果能采集用户的input事件,就可以判定为active,如果中途弹出一个DialogDialog变成新的active实体,直接面对用户的操作。被部分遮挡的activity尽管依然可见,但状态却变为inactive。不能正确的区分visibleactive是很多初级程序员会犯的错误。

Background Process

后台进程同样有更细的划分。所谓的Background可以理解为不可见(invisible)。对于不可见的任务,Android也有重要性的区分。重要的后台任务定义为Service,如果一个进程包含Service(称为Service Process),那么在“重要性”上就会被系统区别对待,其优先级自然会高于不包含Service的进程(称为Background Process),最后还剩一类空进程(Empty Process)。Empty Process初看有些费解,一个Process如果什么都不做,还有什么存在的必要。其实Empty Process并不Empty,还存在不少的内存占用。

在iOS的世界里,Memory被分为Clean MemoryDirty MemoryClean Memory是App启动被加载到内存之后原始占用的那一部分内存,一般包括初始的stack, heap, text, datasegment,Dirty Memory是由于用户操作所改变的那部分内存,也就是App的状态值。系统在出现Low Memory Warning的时候会首先清掉Dirty Memory,对于用户来说,操作的进度就全部丢失了,即使再次点击App图标,也是一切从头开始。但由于Clean Memory没有被清除,避免了从磁盘重新读取app数据的io损耗,启动会变快。这也是为什么很多人会感觉手机重启后,app打开的速度都比较慢。

同理Android世界当中的Empty Process还保存有App相关的Clean Memory,这部分Memory对于提升App的启动速度大有帮助。显而易见Empty Process的优先级是最低的。

综上所述,我们可以把Android世界的Process按优先级分为如下几类:

进程的优先级从高到低依次分为五类,越往下,在内存紧张的时候越有可能被系统杀掉。简而言之,越是容易被用户感知到的进程,其优先级必定更高。

线程调度(Thread Scheduling)

Android系统基于精简过后的linux内核,其线程的调度受时间片轮转和优先级控制等诸多因素影响。不少初学者会认为某个线程分配到的time slice多少是按照其优先级与其它线程优先级对比所决定的,这并不完全正确。

Linux系统的调度器在分配time slice的时候,采用的CFS(completely fair scheduler)策略。这种策略不但会参考单个线程的优先级,还会追踪每个线程已经获取到的time slice数量,如果高优先级的线程已经执行了很长时间,但低优先级的线程一直在等待,后续系统会保证低优先级的线程也能获取更多的CPU时间。显然使用这种调度策略的话,优先级高的线程并不一定能在争取time slice上有绝对的优势,所以Android系统在线程调度上使用了cgroups的概念,cgroups能更好的凸显某些线程的重要性,使得优先级更高的线程明确的获取到更多的time slice

Android将线程分为多个group,其中两类group尤其重要。一类是default group,UI线程属于这一类。另一类是background group,工作线程应该归属到这一类。background group当中所有的线程加起来总共也只能分配到5~10%的time slice,剩下的全部分配给default group,这样设计显然能保证UI线程绘制UI的流畅性。

有不少人吐槽Android系统之所以不如iOS流畅,是因为UI线程的优先级和普通工作线程一致导致的。这其实是个误会,Android的设计者实际上提供了background group的概念来降低工作线程的CPU资源消耗,只不过与iOS不同的是,Android开发者需要显式的将工作线程归于background group

new Thread(new Runnable() {
   @Override
   public void run() {
     Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
   }
}).start();

所以在我们决定新启一个线程执行任务的时候,首先要问自己这个任务在完成时间上是否重要到要和UI线程争夺CPU资源。如果不是,降低线程优先级将其归于background group,如果是,则需要进一步的profile看这个线程是否造成UI线程的卡顿。

虽说Android系统在任务调度上是以线程为基础单位,设置单个thread的优先级也可以改变其所属的control groups,从而影响CPU time slice的分配。但进程的属性变化也会影响到线程的调度,当一个App进入后台的时候,该App所属的整个进程都将进入background group,以确保处于foreground,用户可见的新进程能获取到尽可能多的CPU资源。用adb可以查看不同进程的当前调度策略。

$ adb shell ps -P

当你的App重新被用户切换到前台的时候,进程当中所属的线程又会回归的原来的group。在这些用户频繁切换的过程当中,thread的优先级并不会发生变化,但系统在time slice的分配上却在不停的调整。

是否真的需要新线程?

开线程并不是提升App性能,解决UI卡顿的万金油。每一个新启的线程会消耗至少64KB的内存,系统在不同的线程之间switch context也会带来额外的开销。如果随意开启新线程,随着业务的膨胀,很容易在App运行的某个时间点发现几十个线程同时在运行。后果是原本想解决UI流畅性,却反而导致了偶现的不可控的卡顿。

移动端App新启线程一般都是为了保证UI的流畅性,增加App用户操作的响应度。但是否需要将任务放入工作线程需要先了解任务的瓶颈在哪,是i/o,gpu还是cpu?UI出现卡顿并不一定是UI线程出现了费时的计算,有可能是其它原因,比如layout层级太深。

尽量重用已有的工作线程(使用线程池)可以避免出现大量同时活跃的线程,比如对HTTP请求设置最大并发数。或者将任务放入某个串行的队列(HandlerThread)按顺序执行,工作线程任务队列适合处理大量耗时较短的任务,避免出现单个任务阻塞整个队列的情况。

用什么姿势开线程?

new Thread()

这是Android系统里开线程最简单的方式,也只能应用于最简单的场景,简单的好处却伴随不少的隐患。

new Thread(new Runnable() {
  @Override
  public void run() {
 
  }
}).start();

这种方式仅仅是起动了一个新的线程,没有任务的概念,不能做状态的管理。start之后,run当中的代码就一定会执行到底,无法中途取消。

Runnable作为匿名内部类还持有了外部类的引用,在线程退出之前,该引用会一直存在,阻碍外部类对象被GC回收,在一段时间内造成内存泄漏。

没有线程切换的接口,要传递处理结果到UI线程的话,需要写额外的线程切换代码。

如果从UI线程启动,则该线程优先级默认为Default,归于default cgroup,会平等的和UI线程争夺CPU资源。这一点尤其需要注意,在对UI性能要求高的场景下要记得

Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

虽说处于background group的线程总共只能争取到5~10%的CPU资源,但这对绝大部分的后台任务处理都绰绰有余了,1ms和10ms对用户来说,都是快到无法感知,所以我们一般都偏向于在background group当中执行工作线程任务。

AsyncTask

一个典型的AsyncTask实现如下:

public class MyAsyncTask extends AsyncTask {
 
   @Override
   protected Object doInBackground(Object[] params) {
      return null;
   }
 
   @Override
   protected void onPreExecute() {
     super.onPreExecute();
   }
 
   @Override
   protected void onPostExecute(Object o) {
     super.onPostExecute(o);
   }
}

和使用Thread()不同的是,多了几处API回调来严格规范工作线程与UI线程之间的交互。我们大部分的业务场景几乎都符合这种规范,比如去磁盘读取图片,缩放处理需要在工作线程执行,最后绘制到ImageView控件需要切换到UI线程。

AsyncTask的几处回调都给了我们机会去中断任务,在任务状态的管理上较之Thread()方式更为灵活。值得注意的是AsyncTask的cancel()方法并不会终止任务的执行,开发者需要自己去检查cancel的状态值来决定是否中止任务。

AsyncTask也有隐式的持有外部类对象引用的问题,需要特别注意防止出现意外的内存泄漏。

AsyncTask由于在不同的系统版本上串行与并行的执行行为不一致,被不少开发者所诟病,这确实是硬伤,绝大部分的多线程场景都需要明确任务是串行还是并行。

线程优先级为background,对UI线程的执行影响极小。

HandlerThread

在需要对多任务做更精细控制,线程切换更频繁的场景之下,Thread()AsyncTask都会显得力不从心。HandlerThread却能胜任这些需求甚至更多。

HandlerThread将HandlerThreadLooperMessageQueue几个概念相结合。Handler是线程对外的接口,所有新的message或者runnable都通过handler post到工作线程。LooperMessageQueue取到新的任务就切换到工作线程去执行。不同的post方法可以让我们对任务做精细的控制,什么时候执行,执行的顺序都可以控制。HandlerThread最大的优势在于引入MessageQueue概念,可以进行多任务队列管理。

HandlerThread背后只有一个线程,所以任务是串行执行的。串行相对于并行来说更安全,各任务之间不会存在多线程安全问题。

HandlerThread所产生的线程会一直存活,Looper会在该线程中持续的检查MessageQueue。这一点和Thread(),AsyncTask都不同,thread实例的重用可以避免线程相关的对象的频繁重建和销毁。

HandlerThread较之Thread(),AsyncTask需要写更多的代码,但在实用性,灵活度,安全性上都有更好的表现。

ThreadPoolExecutor

Thread(),AsyncTask适合处理单个任务的场景,HandlerThread适合串行处理多任务的场景。当需要并行的处理多任务之时,ThreadPoolExecutor是更好的选择。

public static Executor THREAD_POOL_EXECUTOR
= new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE,
TimeUnit.SECONDS, sPoolWorkQueue, sThreadFactory);

线程池可以避免线程的频繁创建和销毁,显然性能更好,但线程池并发的特性往往也是疑难杂症的源头,是代码降级和失控的开始。多线程并行导致的bug往往是偶现的,不方便调试,一旦出现就会耗掉大量的开发精力。

ThreadPool较之HandlerThread在处理多任务上有更高的灵活性,但也带来了更大的复杂度和不确定性。

IntentService

不得不说Android在API设计上粒度很细,同一样工作可以通过各种不同的类来完成。IntentService又是另一种开工作线程的方式,从名字就可以看出这个工作线程会带有service的属性。和AsyncTask不同,没有和UI线程的交互,也不像HandlerThread的工作线程会一直存活。IntentService背后其实也有一个HandlerThread来串行的处理Message Queue,从IntentService的onCreate方法可以看出:

@Override
public void onCreate() {
  // TODO: It would be nice to have an option to hold a partial wakelock
  // during processing, and to have a static startService(Context, Intent)
  // method that would launch the service & hand off a wakelock.
 
  super.onCreate();
  HandlerThread thread = new HandlerThread("IntentService[" + mName + "]");
  thread.start();
 
  mServiceLooper = thread.getLooper();
  mServiceHandler = new ServiceHandler(mServiceLooper);
}

只不过在所有的Message处理完毕之后,工作线程会自动结束。所以可以把IntentService看做是ServiceHandlerThread的结合体,适合需要在工作线程处理UI无关任务的场景。

总结

Android开线程的方式虽然五花八门,但归根到底最后还是映射到linux下的pthread,业务的设计还是脱不了和线程相关的基础概念范畴:线程的执行顺序,调度策略,生命周期,串行还是并行,同步还是异步等等。摸清楚各类API下线程的行为特点,在设计具体业务的线程模型的时候自然轻车熟路了,线程模型的设计要有整个app视角的广度,切忌各业务模块各玩各的。以上就是本文的全部内容,希望对大家开发Android能有所帮助,如果有疑问欢迎大家留言讨论。


推荐阅读
  • 作为软件工程专业的学生,我深知课堂上教师讲解速度之快,很多时候需要课后自行消化和巩固。因此,撰写这篇Java Web开发入门教程,旨在帮助初学者更好地理解和掌握基础知识。通过详细记录学习过程,希望能为更多像我一样在基础方面还有待提升的学员提供有益的参考。 ... [详细]
  • 小王详解:内部网络中最易理解的NAT原理剖析,挑战你的认知极限
    小王详解:内部网络中最易理解的NAT原理剖析,挑战你的认知极限 ... [详细]
  • 提升 Kubernetes 集群管理效率的七大专业工具
    Kubernetes 在云原生环境中的应用日益广泛,然而集群管理的复杂性也随之增加。为了提高管理效率,本文推荐了七款专业工具,这些工具不仅能够简化日常操作,还能提升系统的稳定性和安全性。从自动化部署到监控和故障排查,这些工具覆盖了集群管理的各个方面,帮助管理员更好地应对挑战。 ... [详细]
  • 数字图书馆近期展出了一批精选的Linux经典著作,这些书籍虽然部分较为陈旧,但依然具有重要的参考价值。如需转载相关内容,请务必注明来源:小文论坛(http://www.xiaowenbbs.com)。 ... [详细]
  • 初探性能优化:入门指南与实践技巧
    在编程领域,常有“尚未精通编码便急于优化”的声音。为了从性能优化的角度提升代码质量,本文将带领读者初步探索性能优化的基本概念与实践技巧。即使程序看似运行良好,数据处理效率仍有待提高,通过系统学习性能优化,能够帮助开发者编写更加高效、稳定的代码。文章不仅介绍了性能优化的基础知识,还提供了实用的调优方法和工具,帮助读者在实际项目中应用这些技术。 ... [详细]
  • POJ3669题目解析:基于广度优先搜索的详细解答
    POJ3669(http://poj.org/problem?id=3669)是一道典型的广度优先搜索(BFS)问题。由于陨石的降落具有时间属性,导致地图状态会随时间动态变化。因此,可以利用结构体来记录每个陨石的降落时间和位置,从而有效地进行状态更新和路径搜索。 ... [详细]
  • C# .NET 4.1 版本大型信息化系统集成平台中的主从表事务处理标准示例
    在C# .NET 4.1版本的大型信息化系统集成平台中,本文详细介绍了主从表事务处理的标准示例。通过确保所有操作要么全部成功,要么全部失败,实现主表和关联子表的同步插入。主表插入时会返回当前生成的主键,该主键随后用于子表插入时的关联。以下是一个示例代码片段,展示了如何在一个数据库事务中同时添加角色和相关用户。 ... [详细]
  • 短信验证码安全性堪忧,多因素认证或成未来主流
    短信验证码安全性堪忧,多因素认证或成未来主流 ... [详细]
  • NOIP2000的单词接龙问题与常见的成语接龙游戏有异曲同工之妙。题目要求在给定的一组单词中,从指定的起始字母开始,构建最长的“单词链”。每个单词在链中最多可出现两次。本文将详细解析该题目的解法,并分享学习过程中的心得体会。 ... [详细]
  • ### 摘要`mkdir` 命令用于在指定位置创建新的目录。其基本格式为 `mkdir [选项] 目录名称`。通过该命令,用户可以在文件系统中创建一个或多个以指定名称命名的文件夹。执行此操作的用户需要具备相应的权限。此外,`mkdir` 还支持多种选项,如 `-p` 用于递归创建多级目录,确保路径中的所有层级都存在。掌握这些基本用法和选项,有助于提高在 Linux 系统中的文件管理效率。 ... [详细]
  • 在 Linux 环境下,多线程编程是实现高效并发处理的重要技术。本文通过具体的实战案例,详细分析了多线程编程的关键技术和常见问题。文章首先介绍了多线程的基本概念和创建方法,然后通过实例代码展示了如何使用 pthreads 库进行线程同步和通信。此外,还探讨了多线程程序中的性能优化技巧和调试方法,为开发者提供了宝贵的实践经验。 ... [详细]
  • 将解压缩版Tomcat集成至系统服务
    将解压缩版Tomcat集成至系统服务的方法如下:首先,在命令行中导航至Tomcat的`bin`目录,运行`service.bat install`命令以安装服务。需要注意的是,服务名称和显示名称已在`service.bat`脚本中预设,默认情况下会随不同版本有所变化。此外,建议检查并配置相关参数,确保服务能够稳定运行。 ... [详细]
  • 资源管理器的基础架构包括三个核心组件:1)资源池,用于将CPU和内存等资源分配给不同的容器;2)负载组,负责承载任务并将其分配到相应的资源池;3)分类函数,用于将不同的会话映射到合适的负载组。该系统提供了两种主要的资源管理策略。 ... [详细]
  • CSS3 @font-face 字体应用技术解析与实践
    在Web前端开发中,HTML教程和CSS3的结合使得网页设计更加多样化。长期以来,Web设计师受限于“web-safe”字体的选择。然而,CSS3中的`@font-face`规则允许从服务器端加载自定义字体,极大地丰富了网页的视觉效果。通过这一技术,设计师可以自由选择和使用各种字体,提升用户体验和页面美观度。本文将深入解析`@font-face`的实现原理,并提供实际应用案例,帮助开发者更好地掌握这一强大工具。 ... [详细]
  • PHP自学必备:从零开始的准备工作与工具选择 ... [详细]
author-avatar
MC_炽焰
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有