do while, exit, cycle и do concurrent

Когда число повторений заранее неизвестно, на сцену выходят do while, бесконечный do и управление потоком через exit и cycle.

Цикл do while повторяет тело, пока истинно заданное условие; exit досрочно выходит из цикла, cycle переходит к следующей итерации, минуя остаток тела.

Счётный do хорош, когда число итераций известно заранее. Но итерационные методы — Ньютона, простых итераций, бисекции — крутятся «пока не сошлось», и заранее неизвестно сколько. Для таких задач Fortran даёт условный цикл do while, бесконечный do с явным выходом, а также операторы тонкого управления exit и cycle. Этот урок завершает тему циклов самыми важными для численных методов конструкциями и знакомит с современным do concurrent для параллельных вычислений.

Два рода повторения: счёт и условие

Полезно мысленно разделить все циклы на два рода. Первый — счётные: мы заранее знаем, сколько раз повторить, и об этом был предыдущий урок. Второй — условные: мы повторяем, пока выполняется некоторое условие, а сколько это займёт шагов — выясняется только в процессе. Подавляющая часть «интересных» вычислений относится ко второму роду. Метод Ньютона сходится за разное число шагов в зависимости от начального приближения и требуемой точности; чтение данных продолжается, пока не встретится конец файла; моделирование идёт, пока система не достигнет равновесия. Заранее посчитать число итераций здесь невозможно в принципе — оно зависит от данных.

Исторически условные циклы в Fortran долго собирали «вручную» из меток и go to: ставили метку, в конце тела вычисляли условие и при необходимости прыгали назад. Это работало, но возвращало нас к спагетти-коду, от которого язык так старательно уходил в ветвлениях. Современные конструкции — do while, бесконечный do с exit, а также cycle — были введены именно для того, чтобы выразить условное повторение структурно, без единой метки и без go to. Освоив их, вы фактически закрываете последнюю нишу, где раньше требовались переходы по меткам, и получаете полный набор структурных средств управления потоком.

Цикл do while

Конструкция do while (условие) проверяет условие перед каждой итерацией и выполняет тело, пока оно истинно. Классическое применение — итерации до достижения точности.

program newton_sqrt
  implicit none
  integer, parameter :: dp = selected_real_kind(15)
  real(dp) :: x, guess, prev
  x = 2.0_dp
  guess = 1.0_dp
  prev = 0.0_dp
  do while (abs(guess - prev) > 1.0e-12_dp)
    prev = guess
    guess = 0.5_dp * (guess + x / guess)    ! итерация Ньютона для sqrt(x)
  end do
  print *, "sqrt(2) =", guess
end program newton_sqrt

Вывод:

 sqrt(2) =   1.4142135623730951

Цикл повторяет формулу Ньютона, пока изменение между шагами больше порога 1e-12. Поскольку условие проверяется до тела, при изначально ложном условии тело не выполнится ни разу. Критично инициализировать переменные так, чтобы первая проверка прошла, — иначе цикл не запустится.

Здесь видна важная характеристика do while: это цикл с предусловием — проверка стоит перед телом. В C есть две формы условного цикла: while с предусловием и do ... while с постусловием, где тело гарантированно исполняется хотя бы раз перед первой проверкой. Несмотря на похожее написание, фортрановский do while соответствует именно сишному while, а не do ... while — не дайте имени себя запутать. Отдельной формы с постусловием в Fortran нет, и это сознательное упрощение: если нужно «выполнить хотя бы раз, потом проверить», естественнее взять бесконечный do и поставить exit в конце тела, о чём ниже. Так одна гибкая конструкция заменяет вторую специальную, и язык остаётся компактнее.

С предусловием связана и типичная ошибка инициализации. В нашем примере prev намеренно задано далёким от guess значением, чтобы первая разность заведомо превысила порог и цикл стартовал. Забыв об этом и инициализировав обе переменные одинаково, мы бы получили ложное условие на самом входе — и метод не сделал бы ни шага, тихо вернув начальное приближение как «ответ». Это коварно тем, что программа не падает и не зависает, а выдаёт правдоподобный, но неверный результат. В итерационных методах подобную «инициализацию ради первого прохода» приходится продумывать всегда.

Бесконечный цикл и exit

Иногда удобнее цикл без условия в заголовке, с выходом из середины. do без параметров — это бесконечный цикл; покинуть его можно оператором exit. Это идеальный шаблон, когда проверку выхода логично делать в середине тела.

program read_until
  implicit none
  integer :: i, value
  i = 0
  do
    i = i + 1
    value = i * i
    if (value > 50) exit       ! выход из середины
    print *, i, value
  end do
  print *, "Остановились на i =", i
end program read_until

Вывод:

           1           1
           2           4
           3           9
           4          16
           5          25
           6          36
           7          49
 Остановились на i =           8

exit немедленно прекращает цикл и передаёт управление за end do. Здесь при i = 8 значение 64 > 50, и цикл прерывается до печати. Бесконечный do + exit — мощный и читаемый шаблон, часто яснее, чем хитрое условие в do while.

cycle: пропуск итерации

Оператор cycle не выходит из цикла, а пропускает остаток текущей итерации и переходит к следующей. Удобно для отсева ненужных случаев без глубокой вложенности if.

program skip_odd
  implicit none
  integer :: i, total
  total = 0
  do i = 1, 10
    if (mod(i, 2) /= 0) cycle    ! пропустить нечётные
    total = total + i
  end do
  print *, "Сумма чётных от 1 до 10:", total
end program skip_odd

Вывод:

 Сумма чётных от 1 до 10:          30

Когда i нечётно, cycle перепрыгивает к следующему значению, минуя total = total + i. Без cycle пришлось бы оборачивать прибавление в if (mod(i,2)==0); cycle часто читается чище, особенно при нескольких условиях отсева.

Управление вложенными циклами по имени

В одиночном цикле exit и cycle действуют на него. Но во вложенных циклах часто нужно выйти именно из внешнего. Для этого exit и cycle принимают имя цикла — здесь именование становится не украшением, а необходимостью.

program find_pair
  implicit none
  integer :: a, b
  outer: do a = 1, 5
    inner: do b = 1, 5
      if (a * b == 12) then
        print *, "Найдено:", a, "*", b, "= 12"
        exit outer            ! выход из ОБОИХ циклов
      end if
    end do inner
  end do outer
  print *, "Готово"
end program find_pair

Вывод:

 Найдено:           3 *           4 = 12
 Готово

Без имени exit покинул бы только внутренний цикл inner, и внешний продолжил бы работу. exit outer прерывает именованный внешний цикл целиком. Аналогично cycle outer перешёл бы к следующей итерации внешнего цикла. Это единственный чистый способ управлять вложенными циклами в Fortran — без меток и go to.

do concurrent: подсказка для параллелизма

Современный Fortran (2008) ввёл do concurrent — цикл, которым программист обещает, что итерации независимы и могут выполняться в любом порядке или параллельно. Это не команда «распараллелить», а гарантия отсутствия зависимостей, которой компилятор вправе воспользоваться для векторизации или многопоточности.

program concurrent_demo
  implicit none
  integer, parameter :: n = 5
  real :: a(n), b(n)
  integer :: i
  b = [1.0, 2.0, 3.0, 4.0, 5.0]
  do concurrent (i = 1:n)
    a(i) = b(i) ** 2          ! итерации независимы
  end do
  print *, a
end program concurrent_demo

Вывод:

   1.00000000       4.00000000       9.00000000       16.0000000       25.0000000

Ключевое правило: тело do concurrent не должно иметь зависимостей между итерациями — нельзя, чтобы итерация читала то, что пишет другая. Нарушение этого обещания даёт неопределённый результат. Зато при соблюдении компилятор может выполнить цикл максимально эффективно.

Зачем do concurrent в эпоху многоядерности

Появление do concurrent в стандарте 2008 года — прямой ответ на сдвиг в устройстве процессоров. Десятилетиями производительность росла за счёт тактовой частоты, и последовательный цикл сам собой ускорялся с каждым новым поколением «железа». Примерно с середины 2000-х этот рост упёрся в физические пределы, и индустрия свернула на путь параллелизма: больше ядер, векторные инструкции, графические ускорители. Чтобы использовать такие машины, цикл нужно раздать по исполнителям — но обычный do семантически последователен, и компилятор не вправе менять порядок итераций, пока сам не докажет их независимость. А доказать это автоматически мешает алиасинг — возможность того, что два разных имени массива на самом деле указывают на пересекающуюся память; в общем случае компилятор вынужден предполагать худшее и отказываться от распараллеливания.

Конструкция do concurrent разрубает этот узел, перенося ответственность на программиста: вы утверждаете, что итерации независимы, и тем самым освобождаете оптимизатор от недоказуемого. Это особенно ценно в физическом моделировании, обработке сеток, линейной алгебре — там, где один и тот же расчёт применяется к миллионам независимых точек. На практике именно do concurrent стал тем рычагом, через который современные компиляторы Fortran умеют отгружать вычисления на GPU без единой строчки сторонних библиотек: цикл, помеченный как независимый, оптимизатор вправе превратить в тысячи параллельных нитей графического ускорителя. Тем самым один и тот же исходник способен эффективно работать и на обычном процессоре, и на массивно-параллельном устройстве — редкое и ценное свойство для научного кода, который живёт десятилетиями и переезжает с одной архитектуры на другую.

Важно не переоценить обещание. do concurrent ничего не распараллеливает сам по себе — это лишь разрешение, а воспользуется ли им компилятор и как именно, зависит от его возможностей, флагов сборки и целевой платформы. На старой машине без подходящих опций тот же цикл просто выполнится последовательно. И обратная сторона: раз гарантию независимости даёт программист, цена ошибки целиком на нём. Если итерации на деле зависимы — например, накапливают сумму в общую переменную или читают соседний элемент, который параллельно перезаписывается, — результат становится неопределённым, причём он может оказаться правильным при последовательном исполнении и «поломаться» лишь при реальном распараллеливании. Такие баги крайне неприятны, потому что прячутся до смены платформы или флагов. Поэтому помечать цикл как do concurrent следует только после честной проверки, что никакая итерация не зависит от результата другой.

Как работает под капотом

Чем do concurrent отличается от обычного do на уровне модели исполнения? Обычный цикл семантически последователен: итерации идут строго по порядку, и компилятор обязан это сохранить, если не докажет независимость сам (что не всегда возможно из-за алиасинга). do concurrent переворачивает контракт: программист утверждает независимость, снимая с компилятора бремя доказательства. Это даёт оптимизатору свободу переупорядочить, векторизовать или раздать итерации по ядрам. Что касается exit/cycle: на уровне машинного кода это безусловные переходы — exit прыгает за конец цикла, cycle — к коду инкремента и проверки. Именованные варианты просто указывают, к концу/началу какого из вложенных циклов прыгать, заменяя ненавистные метки и go to структурным механизмом.

Частые ошибки

  • Бесконечный do while. Если условие никогда не станет ложным (например, забыли обновлять переменную), цикл зависнет.
  • Неинициализированные переменные перед do while. Условие проверяется до тела; неверная инициализация может не запустить цикл вовсе.
  • exit без имени во вложенном цикле. Выйдет только из внутреннего; для внешнего нужно exit outer.
  • Зависимости в do concurrent. Если итерации не независимы, результат неопределён — это нарушение контракта, а не «иногда работает».
  • Путаница exit и cycle. exit покидает цикл, cycle лишь пропускает остаток итерации; перепутать легко.

Итоги

  • do while (условие) повторяет тело, пока условие истинно; проверка — перед итерацией.
  • do без параметров — бесконечный цикл; выход из него — оператором exit.
  • exit покидает цикл; cycle пропускает остаток итерации и идёт к следующей.
  • Во вложенных циклах exit/cycle с именем управляют нужным циклом — это замена go to.
  • do concurrent — обещание независимости итераций, дающее компилятору свободу распараллеливания.
  • Нарушение независимости в do concurrent ведёт к неопределённому результату.
Проверьте себя
1. Чем отличается exit от cycle?
AОни одинаковы
Bexit покидает цикл целиком, cycle пропускает остаток текущей итерации
Cexit пропускает итерацию, cycle выходит из цикла
DОба завершают программу
2. Что гарантирует программист, используя do concurrent?
AЧто цикл точно выполнится параллельно
BЧто итерации независимы и могут выполняться в любом порядке
CЧто цикл бесконечен
DЧто используется double precision
3. Как во вложенных циклах выйти сразу из внешнего цикла outer?
Aexit (выйдет из обоих)
Bexit outer
Cbreak outer
Dgo to end