Нетрадиционное исправление взаимоблокировки, вдохновленное «ожиданием» XCTest

Нетрадиционное исправление взаимоблокировки, вдохновленное «ожиданием» XCTest

13 июня 2023 г.

Вы когда-нибудь задумывались, что делать, когда происходит сбой самого инструмента, который вы используете для прогнозирования и обработки, Firebase Crashlytics, сама проблема? Вы можете подумать, что это тупик, но не волнуйтесь — в этом посте мы проведем детективную работу. Я столкнулся с уникальным тупиком в срочном режиме Firebase Crashlytics. После некоторых глубоких раскопок я нашел неожиданное, но эффективное решение, черпая вдохновение из маловероятного места — реализации «ожидания» XCTets.

Последний рубеж

Давайте начнем с определения того, что такое "срочный" режим. Мы тратим бесчисленные часы на тестирование и исправление ошибок перед развертыванием. Но затем происходит что-то неожиданное, и ваше приложение падает при запуске! Ни один запрос не сможет отправить и сообщить вам об этом инциденте. Но как узнать причину этого сбоя, если он не воспроизводим?

Firebase Crashlytics приходит на помощь! Он имеет функцию, которая обнаруживает сбой во время запуска приложения. Если это произойдет, Crashlytics приостановит инициализацию паузы основного потока, чтобы предотвратить его сбой; Мы надеемся, что информация о сбое будет отправлена ​​на сервер до того, как сбой произойдет снова. Эта функция называется "срочный режим".

Обнаружение преступника

Давайте вернемся к рассматриваемой проблеме. Я заметил, что запуск моего приложения занимает необычно много времени. Чтобы разобраться в этом, я использовал lldb, чтобы приостановить свое приложение и подробно изучить проблему. Просматривая стек, я быстро обнаружил виновника: Firebase Crashlytics прерывал процесс запуска.

В основном потоке появилась функция regenerateInstallIDIfNeededWithBlock. Это было странно, потому что если вы используете символическую точку останова, вы заметите, что regenerateInstallIDIfNeededWithBlock обычно вызывается из фонового потока, а не из основного потока. Это необычное изменение было явным красным флажком, указывающим на то, что ожидаемый поток процессов не работает.

Используй источник, Люк

Теперь давайте распутаем эту тупиковую ситуацию. Внимательное изучение показывает, что regenerateInstallID предшествует prepareAndSubmitReport, которому предшествует processExistingActiveReportPath.

Давайте углубимся в код, чтобы понять его. лучше.

- (void)processExistingActiveReportPath:(NSString *)path
                    dataCollectionToken:(FIRCLSDataCollectionToken *)dataCollectionToken
                               asUrgent:(BOOL)urgent {
  FIRCLSInternalReport *report = [FIRCLSInternalReport reportWithPath:path];

  if (![report hasAnyEvents]) {
    // call is scheduled to the background queue
    [self.operationQueue addOperationWithBlock:^{
      [self.fileManager removeItemAtPath:path];
    }];

    return;
  }

  if (urgent && [dataCollectionToken isValid]) {
    // called from the Main thread
    [self.reportUploader prepareAndSubmitReport:report
                            dataCollectionToken:dataCollectionToken
                                       asUrgent:urgent
                                 withProcessing:YES];
    return;
  }

Параметр «срочно» определяет, будет ли код выполняться в фоновом режиме или в основном потоке. Отправка отчета из основного потока выглядит ожидаемым поведением.

Но почему он останавливается?

regenerateInstallID ожидает сигнала семафора, который должен произойти после завершения [self.installations installIDWithCompletion]. Код regenerateInstallID< /code> выглядит так (для краткости код упрощен):

- (void)regenerateInstallID {
  dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

  // This runs Completion async, so wait a reasonable amount of time for it to finish.
  [self.installations
      installationIDWithCompletion:^(void) {
        dispatch_semaphore_signal(semaphore);
      }];

  intptr_t result = dispatch_semaphore_wait(
      semaphore, dispatch_time(DISPATCH_TIME_NOW, FIRCLSInstallationsWaitTime));
}

Чтобы выяснить, почему не срабатывает завершение, я просмотрел цепочку вызовов installationIDWithCompletion и не заметил ни одного пути, который мог бы игнорировать завершение.

Настоящая проблема обнаружилась, когда я заметил, что завершение завернуто в блок FBLPromise.then {}. Этот блок отправляется асинхронно в главном потоке, как показано ниже. здесь:

@implementation FBLPromise (ThenAdditions)

- (FBLPromise *)then:(FBLPromiseThenWorkBlock)work {
  // Where defaultDispatchQueue is gFBLPromiseDefaultDispatchQueue by default
  return [self onQueue:FBLPromise.defaultDispatchQueue then:work];
}

@end

static dispatch_queue_t gFBLPromiseDefaultDispatchQueue;

+ (void)initialize {
  if (self == [FBLPromise class]) {
    gFBLPromiseDefaultDispatchQueue = dispatch_get_main_queue();
  }
}

== Итак, взаимоблокировка по существу сводится к следующему ==: семафор ожидает в основном потоке сигнала от обработчика завершения, чтобы освободить его, но сам обработчик завершения завис, ожидая, пока основной поток выполнит dispatch_async. Эта круговая зависимость приводила к зависанию запуска нашего приложения.

Поиск оптимального решения

Итак, какие варианты у нас остались?

  1. Мы могли бы передать очередь промису, если дождемся завершения основного потока. Однако этот подход потребует предложения нового интерфейса для FBLPromise.
  2. Мы могли бы изменить очередь по умолчанию для всех промисов. Однако это рискованный шаг, который повлияет на каждый вызов в SDK.

Поскольку я предпочитаю содержать исправления ошибок в их локальном контексте, чтобы избежать появления новых ошибок, я решил не настраивать FBLPromise. Вместо этого я искал решение, которое было бы минимальным и ограничивалось этим конкретным случаем.

n Если бы мы только могли выполнять асинхронный обратный вызов в основном потоке, одновременно ожидая его... Звучит знакомо? Ну так и должно! У нас есть такая возможность в XCTest через waitForExpectations.

Вот пример:

// This test will pass
func testExample() throws {
    let testExpectation = expectation(description: "")
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        testExpectation.fulfill()
    }
    assert(Thread.isMainThread == true)
    waitForExpectations(timeout: .infinity)
}

Заинтригованный, я углубился в исходный код фреймворка XCTest, чтобы понять, как он выполняет этот трюк.

Вот соответствующий фрагмент кода:

func primitiveWait(using runLoop: RunLoop, duration timeout: TimeInterval) {
    let timeIntervalToRun = min(0.1, timeout)

    runLoop.run(mode: .default, before: Date(timeIntervalSinceNow: timeIntervalToRun))
}

Удивительно, но я обнаружил, что мы можем обрабатывать отправленные обратные вызовы в текущем потоке, используя вложенный счетчик RunLoop. Это казалось многообещающим выходом из нашего тупика.

Исправление

Чтобы устранить эту взаимоблокировку, код был скорректирован для реализации механизма вращения цикла выполнения вместо семафора при работе в основном потоке. Эта настройка позволяет dispatch_async сигнализировать основному потоку о продолжении выполнения, предотвращая его блокировку.

- (void)regenerateInstallID {
    dispatch_semaphore_t semaphore = nil;

    bool isMainThread = NSThread.isMainThread;
    if (!isMainThread) {
      semaphore = dispatch_semaphore_create(0);
    }

    [self.installations
        installationIDWithCompletion:^(void) {
        NSAssert(NSThread.isMainThread, @"We expect to get a completion on the main thread");
        completed = true;
        if (!isMainThread) {
          dispatch_semaphore_signal(semaphore);
        }
    }];

    intptr_t result = 0;
    if (isMainThread) {
      NSDate *deadline =
          [NSDate dateWithTimeIntervalSinceNow:FIRCLSInstallationsWaitTime / NSEC_PER_SEC];
      while (!completed) {
        NSDate *now = [[NSDate alloc] init];
        if ([now timeIntervalSinceDate:deadline] > 0) {
          break;
        }
        [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:deadline];
      }
      if (!completed) {
        result = -1;
      }
    } else {  // isMainThread
      result = dispatch_semaphore_wait(semaphore,
                                       dispatch_time(DISPATCH_TIME_NOW, FIRCLSInstallationsWaitTime));
    }
}

Хотя предложенное решение сработало, сопровождающие Firebase SDK обнаружили еще более элегантное и оптимизированное решение. Они обнаружили, что вызов regenerateInstallID не требуется. Самое простое исправление является наиболее эффективным, обходя потребность в сложных или "больших мозгах" решениях. И я хочу подчеркнуть важность постоянной доработки и улучшения наших решений, чтобы сосредоточиться на простоте и эффективности нашего кода.

Заключительные мысли

Понимание и предотвращение взаимоблокировок является ключом к тому, чтобы ваше приложение отвечало требованиям. Такие инструменты, как циклы выполнения, блокировки и семафоры, могут помочь управлять задачами в нескольких потоках, но они также могут усложнить работу и привести к взаимоблокировкам, если они используются неправильно. При использовании этих инструментов важно избегать потенциальных проблем, таких как условия гонки и взаимоблокировки. Старайтесь, чтобы ваш код был простым, всегда уравновешивайте ожидание семафора сигналами и старайтесь не удерживать блокировки во время длительных задач. Правильное применение этих концепций может помочь вашему приложению оставаться отзывчивым и обеспечить удобство работы пользователей.


Оригинал