Concurrency & performanceHistorical technical note

RunLoop and Keeping iOS Threads Alive

Uses the networking thread in AFNetworking 2.x to examine RunLoop-based thread lifetimes. It explains how copied run-loop code can retain objects or prevent threads from exiting, then explores more controlled lifecycle handling through examples.

Original byline:bestswifter

Originally credited to bestswifter; edited and archived by Bison.

Source listed in the original

English translation of the Chinese original, translated on . The article body uses automated translation; app descriptions, historical reading notes and video subtitles receive separate editorial checks. Original publication dates, attribution, code and version notes are retained.

Articles about runloop and AFNetworking(2.x) often explain keeping a thread alive with a runloop. Without examining the details, however, it is easy to learn an incorrect usage pattern. This article analyzes common misunderstandings.

I provide a Demo that can be downloaded from my Github and run it yourself. This article includes only part of the code.

Editor: Bison
Source: bestswifter

AFN's implementation

First, we know that Older versions of AFN uses NSURLConnection to initiate and handle network connections. AFN starts requests and parses responses on the same background thread. Background threads do not start a runloop by default and exit after their code finishes, like a C program. Since network requests are asynchronous, the thread may have exited before the response arrives, leaving delegate methods unable to run. AFN therefore keeps the thread alive with a runloop—the familiar code below:

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];

        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

This method alone tells us little. Let's examine where it is called in context:

+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });

    return _networkRequestThread;
}

This suggests that a runloop can keep a background thread alive for asynchronous work. More precisely, however, AFN's implementation does not meet our general requirements; it works in AFN's specific context.

Read the second code sample and compare it with normal NSThread usage. If you do not see the difference yet, keep it in mind; we will analyze it shortly.

NSThread and memory leaks

The first problem with this approach is a memory leak. The following example simply puts AFN's thread-creation code inside a loop:

- (void)memoryTest {
    for (int i = 0; i < 100000; ++i) {
        NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [thread start];
    }
}

- (void)run {
    @autoreleasepool {
        NSLog(@"current thread = %@", [NSThread currentThread]);
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        if (!self.emptyPort) {
            self.emptyPort = [NSMachPort port];
        }
        [runLoop addPort:self.emptyPort forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

Something strange happens: memory usage keeps growing even under ARC. Removing the runloop-related code from run eliminates the issue. Starting the runloop is therefore causing a memory leak: the thread object cannot be released.

emptyPort keeps the runloop running. According to the documentation, a runloop without any modeItem exits immediately rather than starting. I use a property instead of a temporary variable because each call to [NSMachPort port] appeared to consume memory, for reasons I have not yet determined.

We can try manually stopping the runloop and closing the thread:

- (void)memoryTest {
    for (int i = 0; i < 100000; ++i) {
        NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [thread start];
        [self performSelector:@selector(stopThread) onThread:thread withObject:nil waitUntilDone:YES];
    }
}

- (void)stopThread {
    CFRunLoopStop(CFRunLoopGetCurrent());
    NSThread *thread = [NSThread currentThread];
    [thread cancel];
}

Unfortunately, this still has no effect. It appears we have not stopped the runloop correctly.

Starting and exiting a Runloop

Time to test our English. First, the official documentation describes three ways to start a runloop:

Unconditionally

With a set time limit

In a particular mode

These three entry modes correspond to three methods. The first is the one we currently use:

run
runUntilDate
runMode:beforeDate:

The documentation explains each mode at length. Here is a brief summary; interested readers can consult the original.

Entering unconditionally is simplest but least recommended. It puts the thread into an endless loop, making the runloop difficult to control; the only way to end it is to kill it. With a timeout, the runloop ends after handling an event or timing out, and we can choose to start it again. That is better than the first approach. The third approach is preferable because it also lets us specify the runloop mode. The run documentation reveals that it repeatedly calls runMode:beforeDate: indefinitely. runUntilDate: also repeats that call, but stops doing so after its timeout.

In summary, runMode:beforeDate: represents one runloop invocation; the other two methods invoke it repeatedly.

Exiting a runloop is simpler than starting it. There are only two approaches:

1. Set a timeout

2. Stop manually

The second and third startup methods let you specify a timeout. However, when using a runloop to keep a thread alive, we want precise control—for example, stopping immediately when work finishes instead of relying on a timeout.

Fortunately, the documentation says CFRunLoopStop() can stop a runloop manually. Note this statement in its discussion of manual exit:

The difference is that you can use this technique on run loops you started unconditionally.

This explanation is easy to misread. Missing the distinction between exit and terminate can lead to confusion, because the run method's documentation also says:

If you want the run loop to terminate, you shouldn’t use this method

In general, do not use run if you want to return from the runloop. According to the experiments and documentation, the other two startup methods also cannot be stopped manually.

The correct approach

Does starting a runloop on a background thread make the thread impossible to stop and release? That would clearly be unreasonable. After searching, I found an article explaining why CFRunLoopStop() has no effect here:

CFRunLoopStop() ends only the current runMode:beforeDate: invocation, not later invocations. This explains why the Runloop documentation says it can exit a runloop, while documentation for run and related methods says the runloop cannot terminate this way.

The article recommends starting with CFRunLoopRun(), allowing CFRunLoopStop() to stop it. The documentation recommends another approach:

BOOL shouldKeepRunning = YES;        // global
NSRunLoop *theRL = [NSRunLoop currentRunLoop];
while (shouldKeepRunning && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);

I tried the documented approach, and it avoids the memory leak. However, it is inconvenient for verifying that the runloop starts and then stops. I therefore used the first approach:

- (void)memoryTest {
    for (int i = 0; i < 100000; ++i) {
        NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [thread start];
        [self performSelector:@selector(stopThread) onThread:thread withObject:nil waitUntilDone:YES];
    }
}

- (void)stopThread {
    CFRunLoopStop(CFRunLoopGetCurrent());
    NSThread *thread = [NSThread currentThread];
    [thread cancel];
}

- (void)run {
    @autoreleasepool {
        NSLog(@"current thread = %@", [NSThread currentThread]);
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        if (!self.emptyPort) {
            self.emptyPort = [NSMachPort port];
        }
        [runLoop addPort:self.emptyPort forMode:NSDefaultRunLoopMode];
        [runLoop runMode:NSRunLoopCommonModes beforeDate:[NSDate distantFuture]];
    }
}

Verification

With that approach, the memory leak disappears. But we still need to verify that the runloop actually starts and later stops.

To verify that the runloop actually starts, I designed the following method:

- (void)printSomething {
    NSLog(@"current thread = %@", [NSThread currentThread]);
    [self performSelector:@selector(printSomething) withObject:nil afterDelay:1];
}

performSelector:withObject:afterDelay depends on the thread's runloop because a timer schedules its execution there. If the method runs successfully, the current thread's runloop has started; otherwise, it has not.

To show that a runloop can be stopped, I created a button that executes this method when tapped:

- (void)stopButtonDidClicked:(id)sender {
    [self performSelector:@selector(stopRunloop) onThread:self.thread withObject:nil waitUntilDone:YES];
}

- (void)stopRunloop {
    CFRunLoopStop(CFRunLoopGetCurrent());
}

After tapping the button, console logging stops, verifying that the runloop has stopped.

Summary

This investigation is really about keeping a thread alive with a runloop. The main points to understand are:

1. Understand how a runloop keeps a thread alive, including the empty port added to it.

2. Understand the thread-object memory leak caused by a runloop.

3. Understand the runloop startup methods and how they relate.

4. Understand how runloops are released and why.

Relevant information is scarce and my understanding is limited. Although I have investigated carefully, I cannot guarantee absolute correctness. Corrections and discussion are welcome.

Finally, here is a small question based on the AFN analysis at the start: why does AFN's usage avoid this problem?

References

Official Run Loops documentation

Runloop not being …

Understanding RunLoop in depth