CN

Android Systrace Basics - MainThread and RenderThread Explained

Word count: 3.3kReading time: 20 min
2019/11/06
loading

This is the ninth article in the Systrace series, primarily introducing the MainThread and RenderThread in Android Apps—commonly known as the Main Thread and Rendering Thread. This article examines their workflows from the perspective of Systrace and covers related topics: jank, software rendering, and dropped frame calculation.

The purpose of this series is to view the overall operation of the Android system from a different perspective using Systrace, while also providing an alternative angle for learning the Framework. Perhaps you’ve read many articles about the Framework but can never remember the code, or you’re unclear about the execution flow. Maybe from Systrace’s graphical perspective, you can gain a deeper understanding.

Table of Contents

Series Article Index

  1. Introduction to Systrace
  2. Systrace Basics - Prerequisites for Systrace
  3. Systrace Basics - Why 60 fps?
  4. Android Systrace Basics - SystemServer Explained
  5. Systrace Basics - SurfaceFlinger Explained
  6. Systrace Basics - Input Explained
  7. Systrace Basics - Vsync Explained
  8. Systrace Basics - Vsync-App: Detailed Explanation of Choreographer-Based Rendering Mechanism
  9. Systrace Basics - MainThread and RenderThread Explained
  10. Systrace Basics - Binder and Lock Contention Explained
  11. Systrace Basics - Triple Buffer Explained
  12. Systrace Basics - CPU Info Explained
  13. Systrace Smoothness in Action 1: Understanding Jank Principles
  14. Systrace Smoothness in Action 2: Case Analysis - MIUI Launcher Scroll Jank Analysis
  15. Systrace Smoothness in Action 3: FAQs During Jank Analysis
  16. Systrace Responsiveness in Action 1: Understanding Responsiveness Principles
  17. Systrace Responsiveness in Action 2: Responsiveness Analysis - Using App Startup as an Example
  18. Systrace Responsiveness in Action 3: Extended Knowledge on Responsiveness

Main Content

Version note — reviewed October 13, 2026: The original article and captures date from 2019; their exact device builds and Flutter SDK versions were not recorded. The View and process-entry explanations below use AOSP android-9.0.0_r1 / android-10.0.0_r1, with HWUI synchronization checked against android-16.0.0_r1. The old Flutter thread and Skia diagrams no longer describe all versions; that section includes the Flutter 3.27 and 3.29 changes.

The same topic continues in Choreographer Rendering in Perfetto and MainThread and RenderThread. The old captures show the relative positions of the UI thread, RenderThread, and SurfaceFlinger; the newer approach follows thread scheduling, FrameTimeline, and Buffer flow.

Start with the target frame rate, display refresh rate, and deadlines, then follow dependencies through the main thread, RenderThread, GPU, and presentation. Input, Animation, Insets Animation, Traversal, and Commit are separate doFrame phases; each need not have work on every frame. Scheduling, Binder, locks, and Buffer waits can also delay a frame.

Here is one hardware-accelerated View frame during list scrolling. Focus on the UI Thread and RenderThread rows. This is a concrete example, not a mandatory path for every View frame, game, or software-rendered window.

Read this capture in the following order:

  1. The main thread is waiting because it has no runnable work. Input and other events can also wake it; it does not wait exclusively for Vsync.
  2. After a frame has been requested, a Vsync event reaches FrameDisplayEventReceiver.onVsync. It posts an asynchronous message to its Looper, which later runs run() / doFrame(); receiving Vsync does not itself complete drawing.
  3. Due Input callbacks run. Not all input delivery goes through this batching phase.
  4. Due Animation callbacks run. Android 10/16 also have Insets Animation before Traversal, which this image does not separately show.
  5. Traversal performs Measure, Layout, and Draw as needed. Updating View drawing records can synchronize with RenderThread. Commit follows later in doFrame; this partial image does not establish whether it has callbacks.
  6. The main thread waits inside the synchronization call until RenderThread releases it. It can then overlap with later rendering when conditions permit. IdleHandler execution depends on queue-idle conditions, not merely on a frame ending.
  7. In this GL/EGL capture, RenderThread obtains a Buffer and submits rendering work. queueBuffer passes a Buffer and its associated fence; it does not prove GPU completion or display. SurfaceFlinger still handles the layer according to scheduling, readiness, and composition policy.

The old annotation about a “finished” Buffer is shorthand. Modern HWUI can use different graphics backends, and Android 16 View Surface paths also involve BLASTBufferQueue. CPU submission, GPU completion, composition, and presentation remain distinct stages.

For the Choreographer portion, see Android’s Choreographer Rendering Mechanism. This article focuses on thread creation and responsibilities, followed by games and Flutter.

Creation of the Main Thread

An ordinary Android App process is derived from Zygote through a path involving fork. A process and a thread are different objects: the initial execution thread in the child continues runtime initialization and enters the App entrypoint. The forked process is not itself a Java Thread object.

ActivityThread does not extend Thread. It manages framework transactions on the App’s main thread. ActivityThread.main() prepares the main Looper and creates ActivityThread and its Handler H. Other Handlers and native Looper callbacks also run there; H does not own every App message.

Creation of ActivityThread

In the ordinary App branch of Android 10 ZygoteConnection, the path is ZygoteInit.zygoteInit() → RuntimeInit.applicationInit() → findStaticMain(), followed by android.app.ActivityThread.main(). The original childZygoteInit() excerpt belongs to the child-Zygote branch, not ordinary App startup.

Here is the complete method from ZygoteInit.java, android-10.0.0_r1. findStaticMain() is in RuntimeInit.java at the same tag.

1
2
3
4
5
6
7
8
9
10
11
12
13
public static final Runnable zygoteInit(int targetSdkVersion, String[] argv,
ClassLoader classLoader) {
if (RuntimeInit.DEBUG) {
Slog.d(RuntimeInit.TAG, "RuntimeInit: Starting application from zygote");
}

Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "ZygoteInit");
RuntimeInit.redirectLogStreams();

RuntimeInit.commonInit();
ZygoteInit.nativeZygoteInit();
return RuntimeInit.applicationInit(targetSdkVersion, argv, classLoader);
}

The corresponding ActivityThread.main() includes the startSeq parsing missing from the original excerpt. These are framework internals, not standalone code to paste into an App.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public static void main(String[] args) {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "ActivityThreadMain");

// Install selective syscall interception
AndroidOs.install();

// CloseGuard defaults to true and can be quite spammy. We
// disable it here, but selectively enable it later (via
// StrictMode) on debug builds, but using DropBox, not logs.
CloseGuard.setEnabled(false);

Environment.initForCurrentUser();

// Make sure TrustedCertificateStore looks in the right place for CA certificates
final File configDir = Environment.getUserConfigDirectory(UserHandle.myUserId());
TrustedCertificateStore.setDefaultUserDirectory(configDir);

Process.setArgV0("<pre-initialized>");

Looper.prepareMainLooper();

// Find the value for {@link #PROC_START_SEQ_IDENT} if provided on the command line.
// It will be in the format "seq=114"
long startSeq = 0;
if (args != null) {
for (int i = args.length - 1; i >= 0; --i) {
if (args[i] != null && args[i].startsWith(PROC_START_SEQ_IDENT)) {
startSeq = Long.parseLong(
args[i].substring(PROC_START_SEQ_IDENT.length()));
}
}
}
ActivityThread thread = new ActivityThread();
thread.attach(false, startSeq);

if (sMainThreadHandler == null) {
sMainThreadHandler = thread.getHandler();
}

if (false) {
Looper.myLooper().setMessageLogging(new
LogPrinter(Log.DEBUG, "ActivityThread"));
}

// End of event ActivityThreadMain.
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
Looper.loop();

throw new RuntimeException("Main thread loop unexpectedly exited");
}

attach() registers the App-side interface through AMS’s attachApplication; application binding is handled afterward. The main thread is now up and running, processing work inside Looper.loop() rather than waiting for main() to return. The final exception makes an unexpected main-loop exit explicit.

Functions of ActivityThread

Activity lifecycle callbacks, common Service lifecycle callbacks, and default broadcast delivery usually run on their process’s main thread. Android 10’s H contains these internal message constants, among others; they are not stable SDK interfaces:

1
2
3
4
5
6
7
8
public static final int BIND_APPLICATION = 110;
public static final int RECEIVER = 113;
public static final int CREATE_SERVICE = 114;
public static final int STOP_SERVICE = 116;
public static final int BIND_SERVICE = 121;
public static final int UNBIND_SERVICE = 122;
public static final int INSTALL_PROVIDER = 145;
public static final int EXECUTE_TRANSACTION = 159;

Activity client transactions also pass through EXECUTE_TRANSACTION / TransactionExecutor. The process already exists before these messages execute; H does not create it. Nor does this list mean every component method runs on the main thread: remote Service Binder calls and cross-process ContentProvider queries use Binder pool threads, and a receiver can be registered with a particular Handler. See Processes and threads.

Creation and Evolution of the Render Thread

HWUI introduced RenderThread in Android 5.0 (API 21) to move part of rendering work off the main thread. View GPU hardware acceleration already existed in Android 3.0 (API 11), however; pre-Lollipop does not mean CPU-only rendering.

Software Rendering

View hardware acceleration is enabled by default for targetSdkVersion >= 14, with configuration at several levels whose capabilities differ. This attribute fragment in <application> disables the application default; an Activity can override it:

1
android:hardwareAccelerated="false"

Software View drawing rasterizes on the CPU and submits through the window Surface. That window need not use the usual HWUI RenderThread path, but other windows or embedded components may still use HWUI in the same process. A software layer on one View also does not disable acceleration for the entire window. To identify the current drawing Canvas, use Canvas.isHardwareAccelerated(). See the hardware acceleration controls.

This old capture shows lockAsync, drawing, and Buffer submission on the main thread. Its selected 21.064 ms interval also contains a fence wait; the entire wall duration is not CPU rasterization time. Two different captures cannot quantify a universal slowdown from disabling acceleration. Extra main-thread work or waiting does reduce time available for other messages.

Hardware-Accelerated Rendering

The Draw phase records or reuses DisplayLists and synchronizes with RenderThread. Recording, including View.onDraw(), still costs main-thread time. RenderThread can continue after the main thread is released, but that release is conditional, as described below.

Rendering Thread Initialization

Distinguish creating the thread from initializing a window Surface. Android 9 constructs a native RenderProxy from ThreadedRenderer, and that proxy obtains RenderThread::getInstance(). RenderThread can therefore exist before the first Draw. The initializeIfNeeded() call in ViewRootImpl concerns renderer / Surface initialization; it is not the unique point where the native thread is born.

This complete draw() method comes from ThreadedRenderer.java, android-9.0.0_r1. The original attributed it to android/graphics/HardwareRenderer.java and mixed signatures from different versions; the excerpt now uses one tag throughout.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void draw(View view, AttachInfo attachInfo, DrawCallbacks callbacks,
FrameDrawingCallback frameDrawingCallback) {
attachInfo.mIgnoreDirtyState = true;

final Choreographer choreographer = attachInfo.mViewRootImpl.mChoreographer;
choreographer.mFrameInfo.markDrawStart();

updateRootDisplayList(view, callbacks);

attachInfo.mIgnoreDirtyState = false;

// register animating rendernodes which started animating prior to renderer
// creation, which is typical for animators started prior to first draw
if (attachInfo.mPendingAnimatingRenderNodes != null) {
final int count = attachInfo.mPendingAnimatingRenderNodes.size();
for (int i = 0; i < count; i++) {
registerAnimatingRenderNode(
attachInfo.mPendingAnimatingRenderNodes.get(i));
}
attachInfo.mPendingAnimatingRenderNodes.clear();
// We don't need this anymore as subsequent calls to
// ViewRootImpl#attachRenderNodeAnimator will go directly to us.
attachInfo.mPendingAnimatingRenderNodes = null;
}

final long[] frameInfo = choreographer.mFrameInfo.mFrameInfo;
if (frameDrawingCallback != null) {
nSetFrameCallback(mNativeProxy, frameDrawingCallback);
}
int syncResult = nSyncAndDrawFrame(mNativeProxy, frameInfo, frameInfo.length);
if ((syncResult & SYNC_LOST_SURFACE_REWARD_IF_FOUND) != 0) {
setEnabled(false);
attachInfo.mViewRootImpl.mSurface.release();
// Invalidate since we failed to draw. This should fetch a Surface
// if it is still needed or do nothing if we are no longer drawing
attachInfo.mViewRootImpl.invalidate();
}
if ((syncResult & SYNC_INVALIDATE_REQUIRED) != 0) {
attachInfo.mViewRootImpl.invalidate();
}
}

It updates the DisplayList, registers animation nodes and callbacks, then reaches RenderProxy.cpp through JNI:

1
2
3
int RenderProxy::syncAndDrawFrame() {
return mDrawFrameTask.drawFrame();
}

This is not a fire-and-forget notification. In Android 9 DrawFrameTask.cpp, postAndWait() posts to RenderThread and waits on a condition variable. syncFrameState() determines whether the UI thread may be released early. Otherwise, release occurs after later drawing or fence waiting. Android 16 retains this conditional structure. A long main-thread synchronization interval calls for inspecting what RenderThread is doing or waiting for.

dequeueBuffer, flush commands, and eglSwapBuffersWithDamageKHR label this GL/EGL path. They are not universal backend markers, and their end times do not directly identify GPU completion or presentation.

Division of Labor: Main Thread vs. Render Thread

For a typical hardware-accelerated View pipeline, the main thread handles messages, input, common animations, layout, and drawing-record updates. RenderThread synchronizes the RenderNode tree and drives backend rendering. Both are CPU threads; neither thread’s wall duration is itself GPU time. Window or SurfaceControl operations can also involve the main thread in system graphics interactions, so “the main thread never interacts with SurfaceFlinger” is too broad.

A DisplayList records drawing operations. RenderNode also carries properties such as transforms; the two are not interchangeable concepts. This division provides several benefits:

  1. Unchanged content can reuse drawing records without repeating the View recording work.
  2. Node properties such as translation and scale can reuse content when applicable; content changes may still require recording again.
  3. The backend can optimize while preserving ordering, clipping, and blending semantics. It cannot necessarily draw all text in one batch.
  4. RenderThread can handle rendering and supported animations, while business logic and ordinary View updates still need the main thread.
  5. When synchronization permits, the two threads can overlap. The main thread is not guaranteed never to wait for RenderThread.

The original RenderThread reference remains historical reading; use the tagged AOSP sources above for specific branches.

Main Thread and Render Thread in Games

Game engines often submit Buffers through their own Surface and rendering pipeline instead of the View HWUI path. The Android main thread still handles lifecycle, windows, input, and some plugin calls. Game logic, rendering submission, and worker tasks are divided according to the engine and its configuration.

The following Honor of Kings example was labeled 30 fps in the original article. It describes that historical capture, not the game’s current frame-rate options, the display refresh rate, or SurfaceFlinger’s execution frequency.

The Android UI thread and UnityMain are distinct in this image. A thread name alone does not establish that it only renders or that it runs almost all game logic. The method Trace below shows main-thread input dispatch entering the game’s View path. It does not show the full subsequent engine workload, GPU execution, or presentation; proximity alone cannot associate a particular input with a particular frame.

Main Thread and Render Thread in Flutter

This 2019 Flutter capture has separate .ui and .gpu threads alongside Android’s platform thread. The .gpu thread performs raster work on the CPU; it is not the hardware GPU. Flutter content follows its Engine pipeline rather than ordinary View HWUI drawing. This is not simply a consequence of using Skia—Android HWUI also uses Skia. An App embedding native Views can still have an HWUI RenderThread.

The thread diagram is outdated: Flutter 3.29 merges UI and platform threads by default on Android and iOS, running Dart UI work on the platform thread. Raster work remains a separate responsibility; the whole engine has not become single-threaded. Do not require a distinct old-style .ui track when reading a new capture. Slow platform-thread plugin calls can now delay Dart UI work on that same thread. See the Flutter architecture overview.

Flutter still uses Android for windows, input, and frame scheduling. The class in the old method capture is VsyncWaiter, not the original text’s VsyncView. Flutter 3.29.3 VsyncWaiter.java also uses Choreographer.postFrameCallback(); its callback forwards timing through flutterJNI.onVsync().

Shipping the rendering engine with the App permits SDK updates independently of Android OS updates. It does not automatically give every App the newest Skia or prove that a screen is faster than its native counterpart. This presentation slide explains the Skia architecture of its time, not a workload-specific benchmark.

The backend changed too: Flutter 3.27 made Impeller the default on Android API 29+. The official documentation describes fallback to the legacy OpenGL renderer on older Android versions or devices without Vulkan support. “Flutter always uses Skia → OpenGL” is therefore no longer an adequate model. Record the Flutter version, Android version, and actual backend. See Impeller.

The Framework / Engine division remains useful: the Dart framework builds and lays out the UI and records drawing work; the Engine rasterizes and submits the scene. The Engine box containing only Skia below is historical, not a complete diagram for newer versions.

When UI updates are needed, the framework requests a frame. Platform timing drives animation, build, layout, and paint work, then the scene reaches the raster pipeline. Not every Layer necessarily becomes a new texture. GPU submission still precedes Android Buffer, fence, and composition handling rather than directly displaying pixels.

This last diagram is also historical. “GPU Thread” means a raster CPU thread, and the top “GPU → Vsync” arrow should not be read as Flutter waiting directly for GPU completion to begin a frame. The bottom already says GL or Vulkan, not OpenGL alone. Account for both merged threads and Impeller when applying it to newer versions.

Performance

Slow queries, computation, or synchronous waits on the main thread delay input and drawing. Separate two kinds of failure:

  1. Jank: Refresh intervals at 60 / 90 / 120 Hz are about 16.67 / 11.11 / 8.33 ms, but lateness depends on the target frame rate and actual deadlines. Main-thread and RenderThread work includes synchronization and overlap; adding their wall durations is not a dropped-frame test. GPU work or system composition may also delay presentation. On Android 12 (API 31) and later, Perfetto FrameTimeline can compare expected and actual timing when the relevant data was captured.
  2. ANR: Input, broadcasts, Services, and other cases have their own timeout rules. An arbitrary message taking a few seconds does not always produce a dialog, and dialog display depends on context and policy. Inspect the ANR type, logs, and blocking dependencies; see Diagnose and fix ANRs.

Both harm the user experience and need continuing attention. ANR stacks help, but a late capture may miss the original blockage. Intermittent jank needs system Trace analysis and, where useful, method sampling. Instrumented tools such as TraceView perturb timing, so their durations are not automatically representative of an uninstrumented run. Understanding the two threads and their dependencies is a central motivation for this series.

These three articles cover further causes. System scheduling and resource contention can contribute too; first establish where the delay occurs.

  1. Overview of Jank Causes in Android - Methodology
  2. Overview of Jank Causes in Android - System Side
  3. Overview of Jank Causes in Android - App Side

References

  1. https://juejin.im/post/5a9e01c3f265da239d48ce32
  2. http://www.cocoachina.com/articles/35302
  3. https://juejin.im/post/5b7767fef265da43803bdc65
  4. http://gityuan.com/2019/06/15/flutter_ui_draw/
  5. https://developer.android.google.cn/guide/components/processes-and-threads

Attachments

The original repository still contains the Honor of Kings and Flutter Systrace HTML ZIPs and corresponding method Trace files. Both ZIPs can be extracted and read. Compatibility of the old HTML viewer with modern Chrome has not been verified; choose a tool supporting the actual legacy Systrace or ART method Trace format. A method .trace file is not automatically a Perfetto system Trace.

Download the article’s attachments

Other Addresses

Please visit the Zhihu or Juejin pages for this article for comments or likes:
Juejin - Systrace Basics - MainThread and RenderThread Explained

About Me && Blog

Below is my personal intro and related links. I look forward to exchanging ideas with fellow professionals. “When three walk together, one can always be my teacher!”

  1. Blogger Intro: Includes personal WeChat and WeChat group links.
  2. Blog Content Navigation: A guide for my blog content.
  3. Curated Excellent Blog Articles - Android Performance Optimization Must-Knows: Welcome to recommend projects/articles.
  4. Android Performance Optimization Knowledge Planet: The paid community stopped operating in 2026 and no longer accepts new members; this link preserves its history.

One walks faster alone, but a group walks further together.

Scan WeChat QR Code

CATALOG
  1. 1. Table of Contents
  • Series Article Index
  • Main Content
  • Creation of the Main Thread
    1. 1. Creation of ActivityThread
    2. 2. Functions of ActivityThread
  • Creation and Evolution of the Render Thread
    1. 1. Software Rendering
    2. 2. Hardware-Accelerated Rendering
    3. 3. Rendering Thread Initialization
    4. 4. Division of Labor: Main Thread vs. Render Thread
  • Main Thread and Render Thread in Games
  • Main Thread and Render Thread in Flutter
  • Performance
  • References
  • Attachments
  • Other Addresses
  • About Me && Blog