ArrayList.remove() (removeAt() в Котлине) вызывает странное исключение IndexOutOfBoundsException

Мой код прост, но вызывает странное исключение IndexOutOfBoundsException.

Такое случается лишь раз из десяти тысяч, но случается. Я вижу их только в отчете Firebase Crashlytics.

Вы видите проблему или мне следует ее игнорировать?

private val mTaskList = ArrayList<Task>()
// a element is added to the list.
// The list usually contains only one element.
private var worker: TaskWorker? = null
...
// Only one TaskWorker can exist.
  if (worker==null){
    Thread(TaskWorker().also{
      worker = it
    }).start()
  }
...

inner class TaskWorker : Runnable {
  override fun run() {
    while (mTaskList.size > 0) {
      TaskList.removeAt(0).run {  // causes java.lang.ArrayIndexOutOfBoundsException: length=10; index=-1 (or Index: 0, Size: 0)
        ...
      }
    }
    worker = null // Only one TaskWorker can exist.
  }
}

TaskList.removeAt(0) вызывает исключения. (Я вижу их только в отчете Firebase Crashlytics)

Fatal Exception: java.lang.ArrayIndexOutOfBoundsException: length=10; index=-1

или

Fatal Exception: java.lang.ArrayIndexOutOfBoundsException: Index: 0, Size: 0

Стектрейс такой

java.util.ArrayList.remove (ArrayList.java:503)
<my package>.MainActivity$TaskWorker.run (MainActivity.kt:452)
java.lang.Thread.run (Thread.java:923)

Похоже на состояние гонки, когда какой-то другой поток очищается mTaskList после mTaskList.size > 0, но до TaskList.removeAt(0). Может быть, еще один TaskWorker экземпляр?

Leviathan 15.04.2024 03:21

К сожалению, есть только один TaskWorker. Я добавлю код.

Youja 15.04.2024 06:06

Предоставленный вами код по-прежнему не защищает от нескольких рабочих. Два потока могут проверить, что worker имеет нулевое значение, и запустить двух рабочих процессов. Вы запускаете этот код только из основного потока? Если нет, вам нужно добавить синхронизацию.

broot 15.04.2024 08:32
1
3
79
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Причиной IndexOutOfBoundsException, скорее всего, будет состояние гонки, означающее, что несколько потоков конкурируют за один ресурс, а именно за доступ к mTaskList.

Когда два потока выполняются одновременно, они оба могут проверить, что mTaskList.size > 0 истинно, поэтому они оба продолжают выполнять TaskList.removeAt(0) (что, как я предполагаю, на самом деле и должно быть mTaskList.removeAt(0)). Первый успешен, второй выдает исключение, потому что не осталось элементов для удаления.

Предполагая, что к этому массиву нет другого доступа для записи, кроме TaskWorker, виновником, скорее всего, будет инициализация рабочего потока. Есть четыре неатомарные операции, которые имеют отношение к делу. неатомарность означает, что другой поток может вмешаться, поскольку они не выполняются как неотделимая единица:

  1. if (worker == null)
  2. TaskWorker()
  3. worker = it
  4. Thread::start()

Это еще худшее состояние гонки, чем то, что у вас есть с вашим массивом: два потока могут одновременно проверить (1.), а затем ошибочно предположить, что они могут безопасно выполнить все остальное. Будет два экземпляра TaskWorker (2.), но только второй будет доступен переменной worker (3.). Однако первый из них все еще живет в первом объекте Thread, и оба Thread будут start использоваться одновременно со своим собственным экземпляром TaskWorker (4.), что, в свою очередь, является причиной состояния гонки с mTaskList.

Есть несколько способов решить эту проблему. Как предложил @broot в комментариях, вы можете быть уверены, что несколько вызовов инициализации TaskWorker, а также доступа к массиву выполняются только в одном и том же потоке. Таким образом, гарантируется, что никакой другой поток не сможет вмешаться. Самый простой способ добиться этого — оставить вызовы в основном потоке. Однако это может оказаться неприемлемым вариантом, если нагрузка слишком велика. В этом случае вы можете ограничить вызовы определенным потоком, определив новый диспетчер с помощью newSingleThreadContext. Дополнительную информацию см. https://kotlinlang.org/docs/shared-mutable-state-and-concurrency.html#thread-confinement-coarse-grained.

Другой способ — положиться на структурированный параллелизм сопрограмм Kotlin и вообще отказаться от создания собственных потоков. Замените свой класс TaskWorker на это:

suspend fun executeTasks() = withContext(Dispatchers.Default) {
    while (true) {
        ensureActive()
        val task = taskListMutex.withLock { mTaskList.removeFirstOrNull() } ?: break
        // do whatever you need to do with the task
    }
}

Чтобы это работало, вам также понадобится экземпляр Mutex, который, вероятно, лучше всего расположен там, где также находится mTaskList:

private val taskListMutex = Mutex()

Теперь вместо создания Thread для начала выполнения задачи вы можете просто вызвать executeTasks(). Я выбрал Dispatchers.Default, поскольку предполагал, что задачи имеют большую вычислительную нагрузку. Если это связано с вводом-выводом, вместо этого следует использовать Dispatchers.IO. Или вы оставляете все это на усмотрение самого выполнения задачи и вообще удаляете withContext на этом этапе.

Что executeTasks эффективно отличает ваш код, так это синхронизация доступа к mTaskList с мьютексом. Мьютекс предоставляет простой механизм блокировки, где гарантируется, что код, заключенный в withLock, может выполняться только по одному (то есть атомарно). Поскольку withLock является функцией приостановки, все другие сопрограммы, пытающиеся также запустить код, приостанавливаются и ждут завершения текущей выполняющейся сопрограммы, прежде чем они смогут выполнить завернутый код.

При одновременном вызове из разных сопрограмм вместо состояния гонки, приводящего к IndexOutOfBoundsException, вы получите несколько отдельных очередей задач, которые будут выполняться параллельно с мьютексом, синхронизирующим их доступ к списку. Однако поскольку «список обычно содержит только один элемент», это не окажет большого влияния. Однако если вам нужна гарантия того, что никакие две задачи не могут выполняться одновременно, вам следует обернуть весь executeTasks в мьютекс withLock.

При наличии мьютекса вам также следует обернуть весь остальной доступ к списку в блок withLock (очевидно, для того же мьютекса), особенно код, который вставляет новые элементы в список.

Другие вопросы по теме