Русский
Русский
English
Статистика
Реклама

Низкоуровневое программирование

Свежий взгляд на честное 3D в браузере

23.02.2021 22:04:36 | Автор: admin

Приветствую.

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

Общие положения

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

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

Полигоны в пространстве

Утверждение: Любую фигуру можно представить в некотором конечном приближении как совокупность полигонов

Казалось бы, очевидная вещь. Все мы делали это, занимаясь моделированием - в явном или неявном виде. В явном виде получались угловатые параллелепипеды или пирамидки, в неявном виде - подобные принципы используются при натягивании Mesh`ей. Однако - как бы то ни было, с помощью традиционных треугольных полигонов неудобно хранить изогнутые поверхности - получается либо огромное число полигонов, либо слишком крупная сетка и вместо окружности мы получаем двадцатиугольник.

Однако, пробовал ли кто-либо рассматривать полигон как минимальную совокупность граней, но не на плоскости, в привычном нам понимании, а, например, на шаре? Насколько известно из открытых источников, таких проектов не было. Хотя казалось бы - работать должно точно так же, с той лишь разницей, что нужно условиться, какую именно часть сферы - внутреннюю или внешнюю относительно треугольника, проецируемого на его поверхность, считать "сферическим полигоном".

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

Утверждение: Любую фигуру можно представить в некотором конечном приближении как совокупность ограниченных частей поверхностей второго порядка.

Уже чуть менее (казалось бы) очевидное утверждение, однако - стоит вспомнить, что полигон в традиционном смысле (т.е. треугольник в пространстве) есть тоже часть плоскости, которая есть частный случай поверхности второго порядка, поэтому все, что мы сейчас сделали - обобщили предыдущее утверждение. Однако при необходимости получить нечто не треугольное - у нас появляется новые возможности, и для отрисовки, например, сферы, достаточно задать просто сферу, а для отрисовки некоторого элемента, содержащего часть сферы - например, щита (плоски срез сферы) или наплечника (четверть сферы) - требуется задать еще и секущие плоскости.

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

Отрисовка

Однако остается вопрос - как преобразовать множество поверхностей в полигоны? Тут все оказывается еще более тривиально - достаточно пустить множество лучей из камеры. В точках пересечения луча и поверхности можно ставить точку. А уже затем из данных точек формировать полигоны.

Что дает данный подход? Как минимум, можно динамически менять "полигональность" окружающего мира - чем большая плотность таких лучей, тем больше будет точек пересечения луча и поверхности, и тем ближе полученная сетка будет прилегать к самой поверхности. Кроме того, если задать лучам некоторое расхождение, например, лучи будут идти не параллельно, а расходиться некоторым довольно плотным пучком - объекты, удаленные от игрока, будут менее детальными, чем приближенные к камере, более того - чем ближе объект, тем более детальным он будет. Кроме того, плотность пуска лучей не обязательно должна быть равномерная - если плотность будет чуть ниже на краях, для бокового зрения графика будет низкополигональная, а для фронтального - напротив, высокополигональная. Само собой, это больше актуально для VR, но тем не менее - концепт получается интересный.

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

Непосредственно, код

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

Для реализации описанного алгоритма нам потребуются классы с описанием математических операций над матрицами (в частности, сложение/умножением, дискриминант и т.д), класс, описывающий точку, линию, полигон и поверхность. Остальное, из того, что требуется для разработки игры, лобо не было реализовано и оттестировано, либо - не получило согласования на публикацию.

Точка - самая базовая сущность, все, что она делает - хранит и получает/устанавливает собственные координаты, а также - измеряет расстояние до товарки.

class Point{    x=0;    y=0;    z=0;    constructor(x,y,z) {        this.x = x?x:0;        this.y = y?y:0;        this.z = z?z:0;    }    get x(){        return this.x;    }    set x(x){        this.x = x;    }    get y(){        return this.y;    }    set y(y){        this.y = y;    }    get z(){        return this.z;    }    set z(z){        this.z = z;    }    toString(){        return `{"x": ${this.x}, "y": ${this.y}, "z": ${this.z}}`;        //for debug    }    /**     * @param {object} point1 - coordinates of first point     * @param {object} point2 - coordinates of second point     * @returns {number} - distance between points     */    static getDistance(point1,  point2){        return Math.sqrt(Math.pow(point2.x - point1.x, 2) +Math.pow(point2.y - point1.y, 2)+            Math.pow(point2.z - point1.z, 2)  )    }}//console.log(new Point(2,3,4).toString()); //debug todo removeexport  {Point}

Однако точка - слишком мелкая единица. Введем код для полигона - пока что как как просто координаты трех точек в пространстве, и для несколких методов работы с ним.

 /**   * @returns {object} - coefficients for equation of plane,   * that contains current polygon   */  getPlaneEquation(){    let a1 = this.point2.x - this.point1.x;    let b1 = this.point2.y - this.point1.y;    let c1 = this.point2.z - this.point1.z;    let a2 = this.point3.x - this.point1.x;    let b2 = this.point3.y - this.point1.y;    let c2 = this.point3.z - this.point1.z;    let a = b1 * c2 - b2 * c1;    let b = a2 * c1 - a1 * c2;    let c = a1 * b2 - b1 * a2;    let d = (- a * this.point1.x- b * this.point1.y - c * this.point1.z);    let plane = {      a:a,      b:b,      c:c,      d:d    }    return plane  }  /**   * @param {object} line - line in space crossing this plane   * @param {object} plane - coefficient for equation of plane that is crossed by line   * @returns {object} - coordinates of point of crossage   */  getCrossingPointOfLIneAndPlane(line, plane){    let point1 = line.point1;    let point2 = line.point2;    let n= point2.y - point1.y;    let m = point2.x - point1.x;    let p = point2.z - point1.z;    let matrixLeft = [      [n, -1*m, 0],      [p, 0, -1*m],      [plane.a, plane.b, plane.c]    ]    let matrixRight = [[n*point2.x-m*point2.y],[p*point2.x-m*point2.z],[-1*plane.d]]    let matrixLeft_1 = MatrixOperations.InverseMatrix(matrixLeft);    let solution = MatrixOperations.MultiplyMatrix(matrixLeft_1, matrixRight);    let point = {      x:solution[0][0],      y:solution[1][0],      z:solution[2][0],    }    return point;  }  /**   * @param {object} point - some point in space   * @returns {boolean} - is that point inside of current polygon   */  isInsidePolygon(point){    let squareFull = this.getSquare(this.point1, this.point2, this.point3);    let square1 = this.getSquare(point, this.point2, this.point3);    let square2 = this.getSquare(point, this.point1, this.point3);    let square3 = this.getSquare(point, this.point2, this.point1);    const dimension = 0.0001;    /*console.log("s1 " + square1)    debug todo remove    console.log("s2 " + square2)    console.log("s3 " + square3)    console.log("s " + squareFull)*/    return Math.abs(squareFull - (square1+square2+square3)) < dimension;  }  /**   * @param {object} polygon - polygon is crossing need to check   * @returns {object} - 3 points that are lying on prolongation of passed polygon borders   * and in current polygon plane   */  getPolygonBorderLinesCrossPoints(polygon){    let plane = this.getPlaneEquation();    let crossPoint1 = this.getCrossingPointOfLIneAndPlane(new Line(polygon.point1, polygon.point2), plane);    let crossPoint2 = this.getCrossingPointOfLIneAndPlane(new Line(polygon.point1, polygon.point3), plane);    let crossPoint3 = this.getCrossingPointOfLIneAndPlane(new Line(polygon.point3, polygon.point2), plane)    let crossingPoints = {      p1: crossPoint1,      p2: crossPoint2,      p3: crossPoint3,    }    return crossingPoints;  }  /**   * @param {object} polygon - polygon is crossing need to check   * @returns {boolean} - is any of border of passed polygon crossing my plane inside my borders   */  doesPolygonCrossMe(polygon){    let points = this.getPolygonBorderLinesCrossPoints(polygon);    return this.isInsidePolygon(points.p1) && polygon.isInsidePolygon(points.p1)        ||        this.isInsidePolygon(points.p2) && polygon.isInsidePolygon(points.p2)        ||        this.isInsidePolygon(points.p3) && polygon.isInsidePolygon(points.p3);  }  /**   * @param {object} polygon - polygon is crossing need to check   * @returns {boolean} - is passed polygon crossing me or am I crossing it   */  arePolygonsCrossing(polygon){     //needs tests    return this.doesPolygonCrossMe(polygon) && polygon.doesPolygonCrossMe(this);  }  /**   * @param {object} point1 - vertex of triangle   * @param {object} point2 - vertex of triangle   * @param {object} point3 - vertex of triangle   * @returns {float} - square of triangle   */  getSquare(point1, point2, point3){    let a = Point.getDistance(point1, point2);    let b = Point.getDistance(point2, point3);    let c = Point.getDistance(point1, point3)    let p = (a+b+c)/2;    let square = Math.sqrt(p*(p-a)*(p-b)*(p-c));    return square;  }

Однако для работы физического движка требуется проверять пересечения полигонов - для этого была реализована функция arePolygonsCrossing, с довольно интересным алгоритмом.

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

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

Далее - стоит посмотреть на класс Surface. В нем нет ограничивающих плоскостей - предполагалось вынести это на вышестоящий слой абстракции, который не ходит в текущую реализацию, однако для проверки нахождения какого-либо полигона между двумя (для каждого измерения) плоскостями, задача тривиальная (хотя если мелко разбить каждую из задач - каждая из них тривиальная). В рамках данной статьи ключевая задача стояла в том, чтобы рассмотреть глобальный концепт представления графики не как запеченный массив треугольничков), а как некоторую абстракцию высокого уровня. Собственно, сама реализация представлена под спойлером - там есть как раз необходимый функционал для реализации описанной концепции - конвертация массива поверхностей и массива линий в массив полигонов, который затем передается в вебГл для отрисовки, путем обхода всех точек и преобразования трех ближайших в полигон. Безусловно, честный обход в ширину был бы чуть более корректен, однако при равномерной плотности точек в пространстве, а также отсутствие резких переходов между поверхностями, позволяют утверждать, что приближение по трем самым близким точкам, будет достаточно точно.

 getPointsOfCrossedByLine(line) {        let a = this.a2 * Math.pow(line.getEquation().m, 2) +            this.b2 * Math.pow(line.getEquation().n, 2) + this.c2 * Math.pow(line.getEquation().p, 2);        let b = this.a2 * 2 * line.getEquation().m + line.getEquation.m * this.a1 +            this.b2 * 2 * line.getEquation().n + line.getEquation.n * this.b1 +            this.c2 * 2 * line.getEquation().p + line.getEquation.p * this.c1;        let c = this.a2 * line.getEquation().x1 * line.getEquation().x1 + this.a1 * line.getEquation().x1 +            this.b2 * line.getEquation().y1 * line.getEquation().y1 + this.b1 * line.getEquation().y1 +            this.c2 * line.getEquation().z1 * line.getEquation().z1 + this.c1 * line.getEquation().z1 + this.d0;        let t = this.quadraticEquation(a, b, c);        return t.map((value) => {            return new Point(line.getEquation().x1 + value * line.getEquation().m,                line.getEquation().y1 + value * line.getEquation().n,                line.getEquation().z1 + value * line.getEquation().p,            )        })    }    getPointsOfCrossedByLineArray(lineArray) {        let rezultArray = [];        for (let i of lineArray) {            let temp = this.getPointsOfCrossedByLine(i);            for (let j of temp)                rezultArray.push(j)        }        return rezultArray;    }    getSortedGraphOfPoints(lineArray) {        let pointsNotVisited = this.getPointsOfCrossedByLineArray(lineArray);        let visitedPoints = [];        visitedPoints.push(pointsNotVisited[0])        pointsNotVisited.shift();        while (pointsNotVisited.length) {            pointsNotVisited.sort((p1, p2) => {                return Point.getDistance(visitedPoints[visitedPoints.length - 1], p1) -                    Point.getDistance(visitedPoints[visitedPoints.length - 1], p2);            })            visitedPoints.push(pointsNotVisited[0])            pointsNotVisited.shift();        }    }    getPolygonMeshingArray(sortedPointsArray) {        let polygonArray = [];        for (let i = 9; i < sortedPointsArray - 3; i++)            polygonArray.push(new Polygon(sortedPointsArray[i], sortedPointsArray[i + 1], sortedPointsArray[i + 2]))        return polygonArray;    }

Ввиду описательности названий и подробного описания алгоритма комментарии излишни.

Заключение

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

Подробнее..

Перевод Неблокирующие паттерны атомарные операции и частичные барьеры памяти

13.03.2021 16:07:18 | Автор: admin

В первой статье цикла мы познакомились с простыми неблокирующими алгоритмами, а также рассмотрели отношение happens before, позволяющее их формализовать. Следующим шагом мы рассмотрим понятие гонки данных (data race), а также примитивы, которые позволяют избежать гонок данных. После этого познакомимся с атомарными примитивами, барьерами памяти, а также их использованием в механизме seqcount.


С барьерами памяти некоторые разработчики ядра Linux уже давно знакомы. Первый документ, содержащий что-то похожее на спецификацию гарантий, предоставляемых ядром при одновременном доступе к памяти он так и называется: memory-barriers.txt. В этом файле описывается целый зоопарк барьеров вместе с ожидаемым поведением многопоточного кода вядре. Также там описывается понятие парных барьеров (barrier pairing), что похоже на пары release-acquire операций и тоже помогает упорядочивать работу потоков.


В этой статье мы не будем закапываться так же глубоко, как memory-barriers.txt. Вместо этого мы сравним барьеры с моделью acquire и release-операций и рассмотрим, как они упрощают (или, можно сказать, делают возможной) реализацию примитива seqcount. К сожалению, даже если ограничиться лишь наиболее популярными применениями барьеров это слишком обширная тема, поэтому о полных барьерах памяти мы поговорим в следующий раз.



Гонки данных и атомарные операции


Рассматриваемое здесь определение гонок данных впервые сформулировано в C++11 и с тех пор используется многими другими языками, в частности, C11 и Rust. Все эти языки довольно строго относятся к совместному доступу к данным без использования мьютексов: так позволяется делать только со специальными атомарными типами данных, используя атомарное чтение и атомарную запись в память.


Гонка данных возникает между двумя операциями доступа к памяти, если 1) они происходят одновременно (то есть, не упорядочены отношением A происходит перед B), 2) одна из этих операций это запись, и 3) хотя бы одна из операций не является атомарной. Врезультате гонки данных (сточки зрения C11/C++11) может произойти что угодно неопределённое поведение. Отсутствие гонок данных ещё не означает невозможность состояний гонки (race conditions) валгортимах: гонка данных это нарушение стандарта языка, а состояние гонки это ошибка вреализации алгортима, вызванная неправильным использованием мьютексов, acquire-release семантики, или и того и другого.


Избежать гонок данных и вызываемого ими неопределённого поведения очень легко. Самый простой способ это работать с данными только из одного потока. Если данные должны быть доступны другим потокам, то работа с ними должна быть упорядочена нужным вам образом с помощью acquire и release-операций. Наконец, вы можете воспользоваться атомарными операциями.


C11, C++11, Rust предоставляют целый спектр атомарных операций доступа к памяти, гарантирующих некоторый порядок доступа (memory ordering). Нас интересуют три вида: acquire (для чтения), release (для записи), и relaxed (для того и другого). Что делают acquire и release вам уже должно быть ясно, в ядре Linux это называется smp_load_acquire() и smp_store_release(). А вот relaxed-операции обеспечивают так называемый нестрогий порядок доступа к памяти. Нестрогие операции не создают никаких отношений порядка между потоками. Их единственная задача это предотвратить гонку данных и избежать нарушения строгой буквы стандарта.


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


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


Эти макросы уже встречались в первой части:


    поток 1                           поток 2    -----------------------------     ------------------------    a.x = 1;    smp_wmb();    WRITE_ONCE(message, &a);          datum = READ_ONCE(message);                                      smp_rmb();                                      if (datum != NULL)                                        printk("%x\n", datum->x);

Они похожи на smp_load_acquire() и smp_store_release(), но первый аргумент тут является lvalue, а неуказателем (внимание на присваивание message в потоке1). При отсутствии иных механизмов, избегающих гонок данных (вроде захваченного спинлока), настоятельно рекомендуется использовать READ_ONCE() и WRITE_ONCE() при обращении к данным, доступным другим потокам. Сами эти операции не упорядочены, но всегда используются вместе с чем-то ещё, вроде другого примитива или механизма синхронизации, который уже обладает release и acquire семантикой. Так атомарные операции оказываются в итоге упорядочены нужным образом.


Пусть, например, у вас есть struct work_struct, которые в фоне затирают ненужные массивы единичками. После запуска задачи у вас есть другие важные дела и массив вам не нужен. Когда понадобится, то вы делаете flush_work() и гарантированно получаете единички. flush_work(), как и pthread_join(), обладает acquire-семантикой и синхронизируется с завершением struct work_struct. Поэтому читать из массива можно и обычными операциями чтения, которые гарантированно произойдут после записи, выполненной задачей. Однако, если вы забиваете единичками регионы, которые могут пересекаться и обновляться из нескольких потоков, то им следует использовать WRITE_ONCE(a[x],1), а не просто a[x]=1.


Всё становится сложнее, если release и acquire-семантика обеспечивается барьерами памяти. Рассмотрим в качестве примера реальный механизм seqcount.



Seqcounts


Seqcount (sequence counter) это специализированный примитив, сообщающий вам, а не изменилась ли структура данных, пока вы с ней работали. У seqcounts довольно узкая зона применимости, где они показывают себя хорошо: защищаемых ими данных должно быть немного, при чтении не должно быть никаких побочных эффектов, а записи должны быть сравнительно быстрыми и редкими. Но зато читатели никогда не блокируют писателей и никак не влияют на их кеш. Эти довольно существенные преимущества, когда вам нужна масштабируемость.


Seqcount работает с одним писателем и множеством читателей. Обычно seqcount комбинируется смьютексом или спинлоком для того, чтобы гарантировать эксклюзивный доступ писателям; врезультате получается примитив seqlock_t, как его называют в Linux. Вне ядра слова seqlock и seqcount порой используются как синонимы.


Фактически, seqcount это счётчик поколений. Нечётный номер поколения означает, что в этот момент времени со структурой данных работает писатель. Если читатель увидел нечётный номер на входе в критическую секцию или если номер изменился на выходе из критической секции, то структура данных возможно изменялась. Читатель мог увидеть лишь несогласованную часть этих изменений, так что ему следует повторить всю свою работу с начала. Для корректного функционирования seqcount читатель должен корректно опознавать начало и конец работы писателя. По паре load-acquire и store-release операций на каждую сторону. Если раскрыть все макросы, то простая [и неправильная] реализация seqcount на уровне отдельных операций с памятью выглядит примерно так:


    поток 1 (писатель)                 поток 2 (читатель)    --------------------------------    ------------------------                                        do {    WRITE_ONCE(sc, sc + 1);                 old = smp_load_acquire(&sc) & ~1;    smp_store_release(&data.x, 123);        copy.y = READ_ONCE(data.y);    WRITE_ONCE(data.y, 456);                copy.x = smp_load_acquire(&data.x);    smp_store_release(&sc, sc + 1);     } while(READ_ONCE(sc) != old);

Этот код слегка похож на передачу сообщений из первой части. Здесь видно две пары load-acquire store-release операций: для sc и для data.x. И довольно легко показать, что они обе необходимы:


  • Когда поток 2 выполняется после потока1, то при первом чтении sc он должен увидеть значение, которое поток1 туда записал вторым присваиванием. Пара smp_store_release() и smp_load_acquire() здесь гарантирует, что чтение произойдёт после записи.
  • Когда потоки исполняются одновременно, то если поток2 уже увидел новое значение какого-либо поля пусть data.x то он должен увидеть и новое значение sc при проверке цикла. Пара smp_store_release() и smp_load_acquire() здесь гарантирует, что как минимум первый инкремент sc будет видно и поток 2 уйдёт на второй круг.

Вопрос на самопроверку: зачем читатель делает & ~1?


Но если внимательно присмотреться и подумать*, то в коде есть хитрая ошибка! Так как писатель неделает ни одной acquire-операции, то присваивание data.y в принципе может произойти ещё до первого инкремента sc. Конечно, можно психануть и делать вообще всё исключительно через load-acquire/store-release, но это пальба из пушки по воробьям и только маскирует проблему. Если подумать ещё чуть-чуть, то можно найти правильное и эффективное решение.
________
* Вот я, например, сразу не заметил этой ошибки и мне уже в комментариях подсказали.


В первой статье мы видели, что порой в Linux используют WRITE_ONCE() и smp_wmb() вместо smp_store_release(). Аналогично, smp_rmb() и READ_ONCE() вместо smp_load_acquire(). Эти частичные барьеры памяти создают особый тип отношений порядка между потоками. А именно, smp_wmb() делает все последующие неупорядоченные присваивания release-операциями, а smp_rmb(), соответственно, превращает предыдущие неупорядоченные чтения в load-acquire. (Строго говоря, это несовсем так, но примерно так о них можно думать.)


Попробуем улучшить работу с полями data:


    поток 1 (писатель)                 поток 2 (читатель)    ------------------------------      ------------------------    // write_seqcount_begin(&sc)        do {    WRITE_ONCE(sc, sc + 1);                 // read_seqcount_begin(&sc)    smp_wmb();                              old = smp_load_acquire(&sc) & ~1;    WRITE_ONCE(data.x, 123);                copy.y = READ_ONCE(data.y);    WRITE_ONCE(data.y, 456);                copy.x = READ_ONCE(data.x);    // write_seqcount_end(&sc)              // read_seqcount_retry(&sc, old)    smp_store_release(&sc, sc + 1);         smp_rmb();                                        } while(READ_ONCE(sc) != old);

Даже если не знать семантики smp_wmb() и smp_rmb(), любому программисту очевидно, что такой код гораздо проще завернуть в удобный API. С данными можно работать, используя обычные атомарные операции (а модель памяти Linux даже позволяет и неатомарные), тогда как волшебные барьеры можно спрятать за read_seqcount_retry() и write_seqcount_begin().


Добавленные барьеры разделяют READ_ONCE() и WRITE_ONCE() на группы, обеспечивая безопасность работы seqcount. Но тут есть пара нюансов:


  • Во-первых, неупорядоченные атомарные операции остаются неупорядоченными. Поток 2 может увидеть новое значение data.y вместе со старым data.x. Для seqcount это непроблема, так как последующая проверка sc приведёт к повтору цикла.
  • Во-вторых, барьеры дают меньше гарантий, чем load-acquire и store-release. Чтение через smp_load_acquire() гарантированно происходит перед чтениями и записями в память, которые следуют за ним. Аналогично, присваивание через smp_store_release() происходит не только после предыдущих записей в память, но и чтений в том числе. Тогда как smp_rmb() упорядочивает лишь чтения, а smp_wmb() только записи. Правда, взаимный порядок между чтениями и записями, наблюдаемый из других потоков редко важен на практике именно по этой причине в ядре долгое время использовались только smp_rmb() и smp_wmb().

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


Предыдущий абзац очень неформально всё описывает, но это хорошая иллюстрация того, почему важно знать паттерны неблокирующего программирования. Это знание позволяет размышлять окоде на более высоком уровне без потери точности. Вместо того, чтобы описывать каждую инструкцию отдельно, вы можете просто сказать: data.x и data.y защищены seqcount sc. Иликаквпредыдущем примере: a передаётся другому потоку через message. Мастерство неблокирующего программирования отчасти состоит в умении узнавать и использовать подобные паттерны, облегчающие понимание кода.


На этом, пожалуй, пока и остановимся. Естественно, тема барьеров далеко не исчерпана, поэтому вследующей статье мы рассмотрим полные барьеры памяти, как барьеры вообще работают, и ещё больше примеров их использования в ядре Linux.

Подробнее..

Перевод Приёмы неблокирующего программирования полные барьеры памяти

26.03.2021 04:11:58 | Автор: admin

В первых двух статьях цикла мы рассмотрели четыре способа упорядочить доступ к памяти: load-acquire и store-release операции впервой части, барьеры чтения и записи в память вовторой. Теперь пришла очередь познакомиться с полными барьерами памяти, их влиянием на производительность, и примерами использования полных барьеров в ядре Linux.


Рассмотренные ранее примитивы ограничивают возможный порядок исполнения операций спамятью четырьмя различными способами:


  • Load-acquire операции выполняются перед последующими чтениями и записями.
  • Store-release операции выполняются после предыдущих чтений и записей.
  • Барьеры чтения разделяют предыдущие и последующие чтения из памяти.
  • Барьеры записи разделяют предыдущие и последующие записи в память.

Внимательный читатель заметил, что одна из возможных комбинаций в этом списке отсутствует:

Чтение выполняется... Запись выполняется...
после чтения smp_load_acquire(), smp_rmb() smp_load_acquire(), smp_store_release()
после записи ??? smp_store_release(), smp_wmb()
Оказывается, обеспечить глобальный порядок записей и последующих чтений из памяти гораздо сложнее. Процессоры вынуждены прилагать отдельные усилия для этого. Сохранение такого порядка стоит недёшево и требует явных инструкций. Чтобы понять причину этих особенностей, нам придётся спуститься на уровнь ниже и присмотреться к тому, как процессоры работают спамятью.



Что творится внутри процессоров


В первой статье уже упоминалось, что на самом деле процессоры обмениваются сообщениями через шину вроде QPI или HyperTransport, поддерживая таким образом когерентность кешей. На уровне ассемблера, правда, этого не видно. Там есть только инструкции записи и чтения из памяти. Acquire и release-семантика отдельных операций реализуется уже конвеером исполнения инструкций конкретного процессора, с учём его архитектурных особенностей.


Например, на x86-процессорах любое чтение из памяти это load-acquire операция, а любая запись это store-release, потому что так того требует спецификация архитектуры. Тем не менее, это ещё незначит, что в коде для x86 можно никак не обозначать acquire и release-операции. Барьеры влияют не только на процессор, но и на оптимизации компилятора, которые тоже могут переупорядочивать операции с памятью.


Кроме того, архитектура x86 негарантирует, что все процессоры будут видеть операции с памятью в одинаковом порядке. Рассмотрим следующий пример, где по адресам a и b изначально расположены нули:


    CPU 1                    CPU 2    -------------------      --------------------    store 1 => a             store 1 => b    load  b => x             load  a => y

Как бы вы ни переставляли эти операции между собой, как минимум одна из записей в память будет находиться перед соответствующим чтением. Соответственно, можно было бы ожидать, что либо в x, либо в y, либо в обоих окажется единица. Но даже на x86 может случиться так, что ивx, ивy будут считаны нули.


Почему? Дело в том, у каждого процессора есть так называемый буфер записей (store buffer), находящийся между процессором и его L1-кешем. Запись в память обычно изменяет только часть кеш-линии. Если кеш-линия инвалидирована, то процессору сперва надо достать новые данные из памяти аэто медленно. Поэтому новые данные для записи складываются в буфер, который позволяет процессору продолжить работу не ожидая обновления кеша.


Буфер записей не особо мешает процессорам сохранить глобальный порядок операций с памятью, когда это чтениечтение, чтениезапись, записьзапись:


  • Если процессор переупорядочивает инструкции в конвеере для увеличения производительности (out-of-order execution), то механизм спекулятивного исполнения инструкций может сохранять порядок операций относительно чтений из памяти. Процессор начинает отслеживать кеш-линии смомента их чтения и до момента, когда инструкция чтения покидает конвеер. Если на этом промежутке кеш-линия оказывается вытеснена из кеша, то все спекулятивно исполненные и незавершённые операции, зависящие от считанного значения, требуется повторить, считав новое значение из памяти. Если же отслеживаемая кеш-линия остаётся на месте, то все последующие операции спамятью будут завершены после чтения, так как инструкции покидают конвеер и завершают исполение уже впорядке их следования впрограмме.
  • Сохранить порядок записей между собой ещё проще: каждому процессору достаточно переносить записи в кеш в порядке их поступления в буфер записей. Дальше протокол когерентности всё сделает сам.

Но вот гарантировать, что только что записанное одним процессором значение будет считано другим процессором это гораздо сложнее. Во-первых, новое значение может застрять на некоторое время в буфере записей одного процессора, и пока оно не попадёт в L1-кеш, другие процессоры его не увидят. Во-вторых, чтобы процессор всегда видел свои же записи, все чтения сперва проходят через буфер записей (механизм store forwarding). То есть, если у CPU1 или CPU2 вих буферах записей окажутся значения для b и a соответственно, то они увидят именно их предыдущие значения (нули), независимо от состояния кешей.


Единственный способ получить ожидаемое поведение это сбросить весь буфер записей вкеш после записи и перед чтением. Несамая дешёвая операция (пара-тройка десятков циклов), но именно это делает полный барьер памяти smp_mb() в Linux. Рассмотрим теперь, как это выглядит наC:


    поток 1                       поток 2    -------------------           --------------------    WRITE_ONCE(a, 1);             WRITE_ONCE(b, 1);    smp_mb();                     smp_mb();    x = READ_ONCE(b);             y = READ_ONCE(a);

Допустим, в x получается ноль. Что должно для этого произойти? Волнистой линией обозначим ситуацию, когда WRITE_ONCE(b,1) не успевает перезаписать значение, считываемое другим потоком. (Вмодели памяти ядра такое отношение называется from-reads.) Поведение потоков можно описать так:


    WRITE_ONCE(a, 1);           |      -----+----- smp_mb();           |           v    x = READ_ONCE(b);   >  WRITE_ONCE(b, 1);                                         |                                    -----+----- smp_mb();                                         |                                         v                                  y = READ_ONCE(a);

Вспомните, что атомарные операции сами по себе ничего не упорядочивают между потоками. Барьеры тоже не обладают acquire- или release-семантикой. Никаких общих гарантий порядка выполнения операций здесь нет, но есть некоторые специальные гарантии наблюдаемого поведения, на которые всё же можно рассчитывать.


Полный барьер в потоке2 гарантирует, что к моменту выполнения READ_ONCE(a) буфер записей будет сброшен в кеш. Если это произойдёт перед READ_ONCE(b), то она уже увидит запись WRITE_ONCE(b,1) и в x должна будет оказаться единица. Соответственно, если там оказался ноль, порядок выполнения должен быть другим READ_ONCE(b) должна выполниться первой:


    WRITE_ONCE(a, 1);              WRITE_ONCE(b, 1);           |                              |           |                         -----+----- smp_mb();           |                              |           v                              v    x = READ_ONCE(b); -----------> y = READ_ONCE(a);                      (если x = 0)

Благодаря транзитивности, READ_ONCE(a) втаком случае увидит эффект WRITE_ONCE(a,1) и, соответственно, y=1 когда x=0. Аналогично, если второй поток всё ещё видит ноль вa, то полный барьер в первом потоке гарантирует, что READ_ONCE(a) выполнится перед READ_ONCE(b):


    WRITE_ONCE(a, 1);              WRITE_ONCE(b, 1);           |                              |      -----+----- smp_mb();               |           |                              |           v                              v    x = READ_ONCE(b); <----------- y = READ_ONCE(a);                      (если y = 0)

То есть, если y=0, то обязательно x=1. Порядок выполнения операций негарантируется, но каким бы он ни оказался, x и y теперь немогут одновременно содержать нули. Иначе READ_ONCE(a) должна была бы выполниться перед READ_ONCE(b), а READ_ONCE(b) перед READ_ONCE(a), что невозможно.


Модель памяти Linux не считает такие ситуации отношением happens-before между потоками, ведь ни одна из операций не имеет acquire или release-семантики и порядок между ними, строго говоря, не определён. Но тем не менее, барьеры памяти всё же способны влиять на поведение потоков, что позволяет писать высокоуровневые примитивы синхронизации, пользователи которых могут рассчитывать на вполне определённое неопределённое поведение. Рассмотрим теперь, как барьеры применяются на практике.




Синхронизация сна и пробуждения


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


Рассмотрим типичный пример. Пусть один поток хочет попросить другой что-то сделать. Адругой поток часто бывает занят, либо вообще устаёт работать и хочет поспать самоудаляется из планировщика потоков и отправляется на неопределённое время всон. Первому потоку надо растормошить второй. Вкоде это выглядит как-то так:


    поток 1                               поток 2    -------------------                   --------------------------    WRITE_ONCE(dont_sleep, 1);            WRITE_ONCE(wake_me, 1);    smp_mb();                             smp_mb();    if (READ_ONCE(wake_me))               if (!READ_ONCE(dont_sleep))      wake(thread2);                        sleep();

Если второй поток видит в dont_sleep ноль, то первый поток увидит в wake_me единицу иразбудит второй поток. Выглядит, как будто у первого потока release-семантика (представьте, что wake() это как mutex_unlock()). Если же первый поток увидит в wake_me ноль, то второй поток обязательно увидит единицу в dont_sleep и просто непойдёт спать. Второй поток это как бы acquire-половинка операции.


Всё это держится на предположении, что команды первого потока нетеряются. Если, например, wake() вызывается после READ_ONCE() во втором потоке, но до sleep(), то второй поток недолжен витоге заснуть. Как вариант, эти операции могут блокироваться на общем мьютексе. Вот вам ещё один пример того, как неблокирующие приёмы программирования применяются вместе с традиционной синхронизацией. Вданном случае это неявляется проблемой, так как блокировки будут очень редкими.


Приём действительно работает и применяется, например, в интерфейсах prepare_to_wait() и wake_up_process(). Они были добавлены в ядро ещё в ветке 2.5.x, что в своё время подробно разбиралось наLWN. Если раскрыть вызовы функций, то можно увидеть знакомые строки:


    поток 1                                       поток 2    -------------------                           --------------------------    WRITE_ONCE(condition, 1);                     prepare_to_wait(..., TASK_INTERRUPTIBLE) {    wake_up_process(p) {                            set_current_state(TASK_INTERRUPTIBLE) {      try_to_wake_up(p, TASK_NORMAL, 0) {             WRITE_ONCE(current->state, TASK_INTERRUPTIBLE);        smp_mb();                                     smp_mb();        if (READ_ONCE(p->state) & TASK_NORMAL)      }          ttwu_queue(p);                          }      }                                           if (!READ_ONCE(condition))    }                                               schedule();

Как и с seqcount, все барьеры спрятаны за удобным высокоуровневым API. Собственно, как раз использование барьеров или load-acquire/store-release операций и придаёт acquire- или release-семантику всему интерфейсу. Вданном случае wake_up_process() обладает release-семантикой, аset_current_state() распространяет свою acquire-семантику на вызов prepare_to_wait().


Ещё часто бывает, что флажок проверяют дважды, дабы по возможности избежать лишних вызовов wake():


    поток 1                               поток 2    -------------------                   --------------------------    WRITE_ONCE(dont_sleep, 1);            if (!READ_ONCE(dont_sleep)) {    smp_mb();                               WRITE_ONCE(wake_me, 1);    if (READ_ONCE(wake_me))                 smp_mb();      wake(thread2);                        if (!READ_ONCE(dont_sleep))                                              sleep();                                          }

В ядре подобные проверки можно найти в tcp_data_snd_check(), вызываемой из tcp_check_space() одним потоком и tcp_poll() в другом потоке. Код здесь довольно низкоуровневый, так что разберём его подробнее. Если в буфере сокета закончилось место, то надо подождать, пока оно освободится. tcp_poll() в одном потоке устанавливает флаг SOCK_NOSPACE раз места нет, то надо спать перед проверкой __sk_stream_is_writeable(), вот здесь:


    set_bit(SOCK_NOSPACE, &sk->sk_socket->flags);    smp_mb__after_atomic();    if (__sk_stream_is_writeable(sk, 1))      mask |= EPOLLOUT | EPOLLWRNORM;

Если возвращаемая маска будет пустой, то для вызывающего кода это означает, что сокет не готов (и надо ждать, пока будет готов). smp_mb__after_atomic() это специализированная версия smp_mb() с аналогичной семантикой. Коптимизациям барьеров мы ещё вернёмся, но позже.


Другой поток, занятый отправкой данных из сокета, должен впоследствии разбудить первый поток. tcp_data_snd_check() сперва отправляет TCP-пакеты, освобождая место в буфере можно неспать, место появилось затем проверяет флажок SOCK_NOSPACE, и наконец (через указатель нафункцию sk->sk_write_space()) попадает в sk_stream_write_space(), где флажок сбрасывается и если там кто-то спит, то его будят. Вызовов функций тут немного, так что я думаю, вы сами легко разберётесь. Также обратите внимание на комментарий в tcp_check_space():


    /* pairs with tcp_poll() */    smp_mb();    if (test_bit(SOCK_NOSPACE, &sk->sk_socket->flags))      tcp_new_space(sk);

Парное использование барьеров означает, что функции составляют взаимосвязанную пару операций с acquire и release-семантикой. По барьерам чтения или записи в память легко понять, какая у чего семантика: acquire для чтения, release для записи. Сполными барьерами же для понимания семантики следует читать код вокруг них и думать. Внашем случае мы знаем, что функция, инициирующая пробуждение tcp_check_space() обладает release-семантикой. Соответственно, у tcp_poll() acquire-семантика.


Подобный приём идиому можно заметить почти везде, где в ядре используется smp_mb(). Например:


  • Workqueues таким образом решают, может ли рабочий поток отправиться поспать, если ему больше нечего делать. Будильником здесь выступает insert_work(), тогда как wq_worker_sleeping(), очевидно, хочет спать.
  • Системный вызов futex() с одной стороны имеет пользовательский поток, записывающий новое значение в память, а барьеры являются частью futex(FUTEX_WAKE). Сдругой стороны находится поток, выполняющий futex(FUTEX_WAIT) и все операции с флажком wake_me внутри ядра. futex(FUTEX_WAIT) получает через аргумент ожидаемое значение в памяти, и потом решает, надо ли спать или уже нет. См.длинный комментарий в начале kernel/futex.c, где подробно рассматривается этот механизм.
  • В контексте KVM роль сна играет переход процессора в гостевой режим, когда он отдаётся враспоряжение виртуальной машины. Для того, чтобы выбить процессор из рук гостевой ОС ивернуть его себе обратно, kvm_vcpu_kick() отправляет межпроцессорное прерывание. Вглубине стека вызовов можно найти kvm_vcpu_exiting_guest_mode(), где видно знакомые нам комментарии о парных барьерах вокруг флажка vcpu->mode.
  • В драйверах virtio можно найти два места, где smp_mb() используется похожим образом. Содной стороны находится драйвер, который иногда хочет прервать операцию и пинает занятое устройство прерыванием. Сдругой стороны есть устройство, которому иногда надо отсигналить ожидающему драйверу о завершении операции.

На этом знакомство с барьерами памяти оглашается оконченным. Вследующей части мы примемся за операции compare-and-swap, их применение для оптимизации блокировок и важную роль вреализации неблокирующих связных списков.

Подробнее..

EDSAC (только для самых суровых)

03.04.2021 20:16:48 | Автор: admin
Что приходит Вам в голову, когда Вы слышите низкоуровневое программирование? Может быть, C++? Непрекращающийся контроль указателей, попытки оптимизации быстродействия, потребляемой памяти? Или, вероятно, вы представляете инструкции ассемблера какой-нибудь популярной ныне архитектуры?

Если же Вы вспоминаете, как перфоратором прокалывали перфоленту, одно отверстие за другим, в течение многих часов, то эта статья для Вас! Добро пожаловать под кат! :)

Lasciate ogni speranza, voi ch'entrate
Dante Alighieri, La Divina Commedia

EDSAC Electronic Delay Storage Automatic Calculator, был создан в Кембридже в 1949 для военных нужд. Ныне в забвении, вспоминают о нём лишь в образовательных целях в N-й губернии в малоизвестной Alma Mater, дочери Петербурхского государственного унивеситета.
Существует также проект The EDSAC Replica Project, где могучая кучка энтузиастов создаёт эмулятор и гайды по EDSAC для сохранения памяти об этом удивительном устройстве.

One of the nicer features of the EDSAC is that it is conceptually a very simple machine.
Tutorial Guide to the EDSAC Simulator

Выглядит дрындулет вот так:

Рис 1.1. EDSAC Simulator вблизи.


Рис 1.2. EDSAC Simulator вдали.

Скачать его можно здесь.

Слева Вы можете видеть текстовый редактор для инструкций. Инструкции бывают двух типов: Initial Orders 1 (далее IO1) и Initial Orders 2 (мега продвинутые). Измененную строку редактор подсвечивает жёлтым, зелёным сохранённые. Комментарии заключаются в []. Редактор имеет память на несколько шагов назад, поэтому можно откатывать программу, нажимая Ctrl+Z. Всё это раньше набирали на перфоленте, это новодел. Первая инструкция в IO1 T N S, где N адрес последней строки с инструкцией + 1. Простейшая программа на EDSAC имеет вид:
Листинг 1. EDSAC order code, простейшая программа.
T32S

Она не делает ничего. Как и большинство моих программ. Отсчёт начинается с 31, т. к. ячейки с 0..30 изначально используются для запуска самой машины, вспоследствии их можно использовать как ячейки памяти.

Кстати, по умолчанию симулятор настроен на IO2, чтобы переключить его на IO1, нажмите на врехней панели EDSAC -> IO1. Запускается по кнопке Start, останавливается по Stop, Single E. P. пошаговая отладка, для этого надо написать вначале Z0S/ZS/Z0F/ZF.

Справа Вы можете симулятор и содержимое памяти вычислительной машины. Слова 17-битные, всего 1024 ячеек памяти (или 35-битные, 512 ячеек памяти, как будет удобнее, посередине таинственный sandwich-digit). Еще есть аккумулятор (оперативная память), 71-бит, и 35-битный регистр умножения. Симулятор поддерживает ввод с помощью дискового телефона, вывод на печать. Включив hints, вы можете смотреть на содержимое ячеек памяти, наводя на них мышкой. Сверху будет появляться число в 10СС. Также можно смотреть содержимое аккумулятора и регистра умножения.


Рис 2. Формат машинного слова.


Рис 3. Формат машинной инструкции.

О числах. Число записывается в память в дополнительном коде с помощью инструкции: P N S или P N L, где N множитель перед двойкой. Например, P0S = 2*0+0=0, P0L = 2*0+1=1, P1S = 2, P1L = 3 и т. д. Отрицательные числа записываются в дополнительном коде, об этом можно почитать в туториале. EDSAC также работает с дробными числами. Символ короткого слова S (F для IO2) и L (D). Не только числа, но и инструкции меняют свой смысл в зависимости от этого.

Есть пара гайдов, основной и сокращённый, рекомендую последний т. к. там меньше буков понятнее изложен материал, работающие примеры. Чтобы сделать приведенный в них код работающим, надо заменить первую строку ZOS/ZS/Z0F/ZF на X0S/X0F. Инструкции смотрите там, либо уже в комментариях к коду.

В течение нескольких месяцев было изготовлено множество версий процедуры печати десятичных чисел. Если программист был непроходимо глуп, или был полным идиотом и совершенным лозером, то подпрограмма конверсии отняла бы у него около сотни команд. Но любой хакер, стоивший своего имени, мог уместить ее в меньший объем. В конечном счете, попеременно убирая инструкции то в одном, то в другом месте, процедура была уменьшена до примерно пятидесяти инструкций.
Стивен Леви, Хакеры: Герои компьютерной революции

Итак, моим заданием было написать программу по выводу n-й строки треугольника Паскаля.
Листинг 2. Kotlin, вывод 3-й строки треугольника Паскаля.
fun main(args: Array<String>) {    var a = 1    var row = 3    row += 1    for (i in 1..row) {        print(a)        print(" ")        a = a * (row - i) / i    }}


Здесь будет вложенный цикл, т. к. деление нужно реализовывать вручную.
Листинг 3. EDSAC order code, целочисленное деление, округление вниз.
[31] T56S
[32] E37S [безусловный переход через константы]
[33] P3L [делимое = 5]
[34] P1L [делитель = 2]
[35] P0S [ну для подсчета частного, 0]
[36] P0L [1]
[37] A35S [выгружаю ну для подсчета частного]
[38] T2S [частное будет во 2 ячейке]
[39] A33S [загружаю делимое в аккумулятор]
[40] T1S [записываю делимое в 1 ячейку]
[41] A1S [читаю содержимое делимого]
[42] S34S [вычитаю делитель]
[43] T1S [записываю новое значение делимого в ячейку 1]
[44] A2S [загружаю частное]
[45] A36S [прибавляю 1]
[46] T2S [записываю увеличенное на 1 частное]
[47] A1S [выгружаю в аккумулятор делимое]
[48] G50S [если делимое < 0, стоп]
[49] T1S [чищу содержимое аккумулятора]
[50] E41S[иначе повторять цикл]
[51] T0S [чищу содержимое аккумулятора]
[52] A2S [выгружаю частное]
[53] S36S [вычитаю 1]
[54] T2S [загружаю частное обратно]
[55] Z0S [иначе стоп]


Как Вы могли заметить, адресация абсолютная, что доставляет головную боль, агрессию, вызывает скрип зубов, ненависть, разочарование, злобу, сложности (это пофиксили во 2 версии Initial Orders). При сдвиге одной строки сдвигается сразу МНОГО. И надо переписывать адреса всех поплывших строк.

Наконец решение:
Листинг 4. EDSAC order code, IO1, n-я строка треугольника Паскаля.
[31] T154S [указатель на последнюю строку программы +1]
[32] E84S [безусловный переход к 84 строке, начало тела программы]
[33] PS [следующая цифра в десятичной СС]
[34] PS [степень десятки]
[35] P10000S [для корректного вывода чисел, степени 10, 10^4]
[36] P1000S [для корректного вывода чисел, степени 10, 10^3]
[37] P100S [для корректного вывода чисел, степени 10, 10^2]
[38] P10S [для корректного вывода чисел, степени 10, 10^1]
[39] P1S [для корректного вывода чисел, степени 10, 10^0]
[40] QS [для формирования чисел]
[41] #S [перевод на цифровой регистр]
[42] A40S [загрузка в аккумулятор символа формирования чисел]
[43] !S [символ пробела]
[44] &S [символ переноса строки на новую]
[45] @S [символ возврата каретки]
[46] O43S [печать телепринтером пробельного символа]
[47] O33S [печать телепринтером следующей цифры числа в 10СС]
[48] PS [число для вывода]
[49] A46S [загружаю в аккумулятор команду печати пробела для исполнения из 46 ячейки]
[50] T65S [загружаю в ячейку памяти 65, обнуляю аккумулятор]
[51] T300S [обнуляю аккумулятор]
[52] A35S [загружаю в аккумулятор число из ячейки памяти 35, 10000<<1]
[53] T34S [загружаю его в ячейку памяти 34, обнуляю аккумулятор]
[54] E61S [если число в аккумуляторе >= 0, перехожу к строке 61]
[55] T48S [загружаю новое значение числа для вывода в ячейку 48, обнуляю аккумулятор]
[56] A47S [загружаю число из 47 ячейки в аккумулятор]
[57] T65S [загружаю число из аккумулятора в 65, обнуляю аккумулятор]
[58] A33S [загружаю число из 33 ячейки в аккумулятор]
[59] A40S [прибавляю число из 40 ячейки к содержимому аккумулятора]
[60] T33S [загружаю число из аккумулятора в 33, обнуляю аккумулятор]
[61] A48S [загружаю число для вывода из ячейки памяти 48 в аккумулятор]
[62] S34S [вычитаю текущую степень десятки]
[63] E55S [если число >= 0, повторяю, перехожу к строке 55]
[64] A34S [загружаю степень десятки из ячейки 34 в аккумулятор]
[65] PS [цифра или пробел, в зависимости от ситуации]
[66] T48S [загружаю число из аккумулятора в ячейку 48, там число для вывода]
[67] T33S [загружаю число из аккумулятора в ячейку 33, там следующая цифра
десятичной записи числа]
[68] A52S [загружаю инструкцию из ячейки 52 в аккумулятор]
[69] A4S [прибавляю 1]
[70] U52S [записываю содержимое аккумулятора в ячейку 52]
[71] S42S [вычитаю из аккумулятора символ формирования чисел]
[72] G51S [если число в аккумуляторе < 0, перехожу к 51 ячейке]
[73] A103S [загружаю инструкцию из ячейки 103 в аккумулятор]
[74] T52S [загружаю содержимое аккумулятора в ячейку 52, обнуляю аккумулятор]
[75] PS [ячейка памяти для хранения инструкции возврата]
[76] PS [ячейка памяти для хранения индекса цикла]
[77] PS [const = 0]
[78] PS [const = 0]
[79] PS [const = 0]
[80] E100S [переход к строке 100]
[81] E104S [переход к строке 104]
[82] P5S [показатель треугольника Паскаля, вводится просто как число между P и S,
по правилам обычной арифметики в 10СС]
[83] E123S [переход к выводу переноса строки, строка программы 123]
[84] A110S [загружаю в аккумулятор число 2]
[85] T30S [выгружаю в ячейку памяти 30, обнуляю аккумулятор]
[86] O41S [перевод на цифровой регистр телепринтера]
[87] T300S [обнуляю аккумулятор]
[88] O44S [печатаю переноса строки на новую]
[89] O44S [печатаю переноса строки на новую]
[90] A76S [загружаю в аккумулятор индекс цикла]
[91] A4S [увеличиваю его на 1]
[92] T76S [перезаписываю изменённый индекс, обнуляю аккумулятор]
[93] E113S [безусловный переход к ячейке 113]
[94] T300S [обнуляю аккумулятор]
[95] A30S [загружаю в аккумулятор число из 30 ячейки, обнуляю аккумулятор]
[96] T48S [выгружаю в ячейку памяти 48, обнуляю аккумулятор]
[97] A80S [загружаю в аккумулятор инструкцию перехода из ячейки 80]
[98] T75S [загружаю её в 75 ячейку, обнуляю аккумулятор]
[99] E49S [перехожу к печати текущего числа]
[100] A81S [загружаю в аккумулятор инструкцию перехода из ячейки 81]
[101] T75S [выгружаю в ячейку памяти 75, обнуляю аккумулятор]
[102] E49S [перехожу к печати текущего числа]
[103] A35S [перехожу к печати текущего числа]
[104] A76S [загружаю в аккумулятор индекс цикла из ячейки 76]
[105] S82S [вычитаю показатель треугольника Паскаля]
[106] S110S [вычитаю 2]
[107] G87S [если полученное число <0, перехожу к строке 87]
[108] X0S [пустая команда]
[109] ZS [конец программы, сигнальный звонок]
[110] P1S [const = 2]
[111] P2S [const = 4]
[112] P0L [const = 1]
[113] T300S [обнуляю аккумулятор]
[114] A76S [загружаю в аккумулятор содержимое 76 ячейки]
[115] S111S [вычитаю содержимое 111 ячейки, 4]
[116] E124S [если число в аккумуляторе >=0, перехожу к 124 ячейке]
[117] T300S [иначе обнуляю аккумулятор]
[118] A30S [загружаю в аккумулятор число из 30 ячейки]
[119] T48S [загружаю в ячейку памяти 48, число для вывода, обнуляю аккумулятор]
[120] A83S [загружаю в аккумулятор инструкцию из адреса 83]
[121] T75S [загружаю ее в 75 ячейку]
[122] E49S [перехожу к печати числа из памяти]
[123] O44S [печатаю переноса строки на новую]
[124] O44S [печатаю переноса строки на новую]
[125] T300S [обнуляю аккумулятор]
[126] A82S [загружаю в аккумулятор показатель треугольника Паскаля]
[127] A110S [добавляю 2]
[128] S76S [вычитаю индекс цикла]
[129] T29S [записываю содержимое аккумулятора в ячейку 29, обнуляю аккумулятор]
[130] H29S [загружаю в умножающий регистр число из 29 ячейки]
[131] V30S [умножаю на число из 30 ячейки, результат в аккумуляторе]
[132] L64S [сдвигаю аккумулятор влево на 8 ячеек]
[133] L32S [сдвигаю аккумулятор влево на 7 ячеек]
[134] T30S [выгружаю число из аккумулятора в 30 ячейку памяти]
[135] T2S [выгружаю частное в ячейку памяти 2, обнуляю аккумулятор]
[136] A30S [загружаю делимое в аккумулятор из ячейки 30]
[137] T1S [записываю делимое в 1 ячейку, обнуляю аккумулятор]
[138] A1S [читаю содержимое делимого из ячейки памяти 1, начало цикла]
[139] S76S [вычитаю делитель из ячейки памяти 76]
[140] T1S [записываю новое значение делимого в ячейку 1, обнуляю аккумулятор]
[141] A2S [загружаю частное из ячейки 2]
[142] A110S [прибавляю 1 из ячейки памяти 110]
[143] T2S [записываю увеличенное на 1 частное в ячейку 2, обнуляю аккумулятор]
[144] A1S [выгружаю в аккумулятор делимое из ячейки 1]
[145] G147S [если делимое < 0, стоп, переход на строку 147]
[146] T1S [иначе записываю содержимое аккумулятора в ячейку 1, обнуляю аккумулятор]
[147] E138S[если делимое >= 0, повторять цикл деления, переход на 138 строку, конец цикла]
[148] T300S [обнуляю аккумулятор]
[149] A2S [выгружаю частное из ячейки 2]
[150] S110S [вычитаю 1 из ячейки памяти 110]
[151] U2S [загружаю частное обратно в ячейку 2]
[152] T30S [загружаю частное в ячейку 30, обнуляю аккумулятор]
[153] E94S [безусловный переход к строке 94, подготовка к выводу числа]


Что сказать про IO2? Поддерживает подпрограммы, относительную адресацию, набор команд расширен.
Та же программа на IO2:
Листинг 5. EDSAC order code, IO2, n-я строка треугольника Паскаля.
..PK
T56K
[P6, подпрограмма для вывода чисел из стандартной библиотеки]
GKA3FT25@H29@VFT4DA3@TFH30@S6@T1F
V4DU4DAFG26@TFTFO5FA4DF4FS4F
L4FT4DA1FS3@G9@EFSFO31@E20@J995FJF!F
..PZ
[программа для нахождения n-й строки треугольника Паскаля]
GK [фиксирую абсолютное значение ячейки памяти для относительной индексации]
[0] XF [для отладки, нулевая инструкция]
[1] O34@ [вывод переведён на цифровой регистр, загрузка данных для печати из ячейки 34]
[2] O35@ [вывод новой строки, загрузка данных для печати из ячейки 35]
[3] O36@ [возврат каретки телепринтера, загрузка данных для печати из ячейки 36]
[4] TF [обнуление аккумулятора]
[5] A27@ [значение из ячейки 27 добавляется в аккумулятор]
[6] TF [запись этого значения в 0 ячейку для вывода, обнуление аккумулятора]
[7] A7@ [загрузка замкнутой подпрограммы по выводу числа]
[8] G56F [печать числа с использованием P6]
[9] T20@ [зануляю 20 ячейку памяти]
[10] A28@ [загружаю в аккумулятор индекс цикла]
[11] A31@ [добавляю 1]
[12] U28@ [обновляю значение индекса цикла в ячейке 28]
[13] T20@ [записываю значение индекса цикла в ячейку 20, обнуляю аккумулятор]
[14] A33@ [загружаю показатель в аккумулятор]
[15] A31@ [+1]
[16] S28@ [вычитаю текущий индекс из ячейки 28]
[17] T20@ [записываю значение индекса цикла в ячейку 20, обнуляю аккумулятор]
[18] E37@ [безусловный переход к умножению, ячейка 37]
[19] PD [const = 1]
[20] XF [нулевая инструкция]
[21] XF [нулевая инструкция]
[22] A33@ [загружаю показатель из ячейки 33 в аккумулятор]
[23] A31@ [+1]
[24] S28@ [вычитаю содержимое 28 ячейки, индекс]
[25] E2@ [если >= 0, повторяю цикл, переход к строке 2]
[26] ZF [конец программы, сигнальный звонок]
[27] PD [число для вывода]
[28] PF [индекс цикла]
[29] PD [используется в подпрограмме для печати]
[30] PD [используется в подпрограмме для печати]
[31] PD [const = 1]
[32] P1F [символ перехода на новую строку]
[33] P2D [показатель треугольника Паскаля, записывается по правилам EDSAC]
[34] #F [символ перевода вывода на цифровой регистр]
[35] @F [символ возврата каретки]
[36] &F [символ переноса строки на новую]
[37] H20@ [загружаю в умножающий регистр число из 20 ячейки]
[38] V27@ [умножаю на число из 27 ячейки, результат в аккумуляторе]
[39] L1024F [сдвигаю аккумулятор влево на 12 ячеек]
[40] L4F [сдвигаю аккумулятор влево на 4 ячеек]
[41] T20@ [выгружаю число из аккумулятора в 20 ячейку памяти, обнуляю аккумулятор]
[42] T102@ [выгружаю частное в ячейку памяти 102, обнуляю аккумулятор]
[43] A20@ [загружаю делимое в аккумулятор из ячейки 20]
[44] T101@ [записываю делимое в 101 ячейку, обнуляю аккумулятор]
[45] A101@ [читаю содержимое делимого из ячейки памяти 101, начало цикла]
[46] S28@ [вычитаю делитель из ячейки памяти 28]
[47] T101@ [записываю новое значение делимого в ячейку 101, обнуляю аккумулятор]
[48] A102@ [загружаю частное из ячейки 102]
[49] A31@ [прибавляю 1 из ячейки памяти 31]
[50] T102@ [записываю увеличенное на 1 частное в ячейку 102, обнуляю аккумулятор]
[51] A101@ [выгружаю в аккумулятор делимое из ячейки 101]
[52] G54@ [если делимое < 0, стоп, переход на строку 54]
[53] T101@ [иначе записываю содержимое аккумулятора в ячейку 101, обнуляю аккумулятор]
[54] E45@ [если делимое >= 0, повторять цикл деления, переход на 45 строку, конец цикла]
[55] T200@ [чищу содержимое аккумулятора]
[56] A102@ [выгружаю частное]
[57] S31@ [вычитаю 1 из ячейки памяти 102]
[58] U102@ [загружаю частное обратно в ячейку 102]
[59] T27@ [загружаю частное в ячейку 27, обнуляю аккумулятор]
[60] E22@ [безусловный переход к строке 22, подготовка к выводу числа]
[61] EZPF [указатель на исполнение кода]



Рис 4. Вывод для показателя = 5.

Надеюсь, Вам понравилась эта статья и Вы прониклись истоками программирования. Когда пишешь подобный код, начинаешь ценить все те уровни абстракции, которые появились впоследствии, понимаешь, какой же большой путь прошли ЯП, чтобы стать такими, какие они есть. Спасибо за Ваше время!

P. S. Милейшие друзья из губернии N, не стоит благодарности за любезно предоставленный мной код, если у Вас совпал со мной вариант. Удачи с RISC-V!

P. P. S. Прекрасные примеры кода для EDSAC IO2 приведены здесь.

P. P. P. S. Код публикую под лицензией MIT.
Copyright 2021, ALEKSEI VASILEV
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the Software), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Подробнее..

Перевод Приёмы неблокирующего программирования введение в compare-and-swap

27.04.2021 06:17:05 | Автор: admin

В первой части этого цикла статей мы рассмотрели теорию, стоящую за одновременным доступом в моделях памяти, а также применение этой теории к простым чтениям и записям в память. Правда, этих примитивов оказывается недостаточны для построения высокоуровневых механизмов синхронизации вроде спинлоков, мьютексов и условных переменных. Хоть и полные барьеры памяти позволяют синхронизировать потоки с помощью приёмов, рассмотренных в предыдущей части (алгоритм Деккера), современные процессоры позволяют получить нужный эффект проще, быстрее и гибче да, всё сразу! с помощью операций compare-and-swap.


Для программистов ядра Linux операция обмена compare-and-swap выглядит так:


    T cmpxchg(T *ptr, T old, T new);

где T может быть либо числовым типом не больше указателя, либо указателем на что-нибудь. Так как в C нет обобщённых функций, то подобный полиморфизм реализуется макросами. cmpxchg() это очень аккуратно реализованный макрос, который ведёт себя как функция (например, вычисляет аргументы только один раз). В Linux также есть макрос cmpxchg64(), который работает с 64-битными целыми числами и недоступен на 32-битных платформах.


cmpxchg() читает значение по указателю *ptr и, если оно равно old, то заменяет его наnew. Иначе же после чтения ничего непроисходит. Считанное значение возвращается как результат операции, независимо от того, произошла ли запись. И всё это выполняется атомарно: если другой поток одновременно с cmpxchg() записывает что-то по адресу *ptr, то cmpxchg() ничего неменяет. Либо old становится new, либо текущее значение остаётся нетронутым. Поэтому cmpxchg() называют атомарной операцией read-modify-write.


В ядре Linux cmpxchg() также предоставляет окружающему коду гарантии порядка операций с памятью. Для выполнения атомарного обмена значений требуется и читать, и писать в память. С некоторыми оговорками можно считать, что чтение здесь имеет acquire-семантику, а запись это release-операция. То есть cmpxchg() будет синхронизироваться с load-acquire и store-release операциями в других потоках.



Стеки и очереди без блокировок


Статья Lockless algorithms for mere mortals рассказывает, как compare-and-swap позволяет работать со списками без мьютексов с подробным разбором и иллюстрациями. Здесь же мы ограничимся рассмотрением односвязного списка в качестве примера: как его реализовать наC, где он может пригодиться. Начнём с простого: как добавляется элемент в начало односвязного списка.


    struct thing {        struct thing *next;        ...    };    struct thing *first;    node->next = first;    first = node;

Как мы теперь знаем, присваивание first следует выполнять release-операцией, чтобы другие потоки увидели node->next после выполнения load-acquire. Хорошо, пока всё идёт по плану и шаблону из первой статьи.


Однако, такой простой код корректно работает лишь для случая, когда ровно один поток может модифицировать список. Если таких потоков-писателей может быть несколько, то уже оба присваивания должны быть защищены критической секцией. В противном случае, если какой-то другой поток изменяет значение first (добавляет элемент, например), то node->next в свежедобавленном элементе может указывать на старое значение first и один элемент списка окажется утерян. Из этого следует важный урок: корректное использование acquire и release-семантики это необходимое, но отнюдь недостаточное условие корректности неблокирующих алгоритмов. Сами по себе они незащищают вас от логических ошибок и гонок потоков.


К счастью, в данной ситуации вместо блокировки можно воспользоваться cmpxchg(), чтобы поймать ситуацию, когда какой-то другой поток изменил first первым. Следующий код будет корректным уже для любого числа потоков-писателей:


    if (cmpxchg(&first, node->next, node) == node->next)        /* ура, сработало! */    else       /* и что теперь? */

И тут возникают вопросы. Во-первых, что же делать, когда cmpxchg() заметит изменения в first? В нашем случае ответ простой: считать новое значение, обновить node->next, попробовать добавить node в список ещё раз. Это новый элемент, который пока ещё не видно из других потоков. Если нам не повезло с добавлением никто ничего не заметит.


Второй вопрос гораздо более хитрый: как именно в таком случае следует считывать first? По идее нам не нужна acquire или release-семантика, ведь нас волнует только значение first само по себе. С другой стороны, слишком умный оптимизирующий компилятор может подумать, что раз мы ничего не присваиваем first в этом потоке, то значение не могло измениться и ничего повторно читать из памяти не нужно. Хоть cmpxchg() в Linux и запрещает подобные оптимизации, хорошим тоном считается явно показывать с помощью READ_ONCE(), что мы хотим считать новое значение из памяти.


Улучшенная версия кода выглядит так:


    struct thing *old, *expected;    old = READ_ONCE(first);    do {        node->next = expected = old;        old = cmpxchg(&first, expected, node);    } while (old != expected);

Отлично, добавление в список работает. Перейдём к другой стороне проблемы: как следует забирать значения из этого списка. Зависит из того, чего мы хотим добиться, передавая значения через список, сколько потоков будут из него читать, и в каком порядке они хотят это делать: LIFO или FIFO, от новых элементов к старым или наоборот.


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


Если чтения выполняются редко или группами, то можно сделать так, что писатели работают совместно, не блокируя друг друга, а доступ читателей строго синхронизируется. Берём rwlock и переворачиваем его! Писатели захватывают блокировку совместно (через read_lock()), а читатели требуют эксклюзивного доступа (через write_lock()). В таком случае запись в список не может происходить одновременно с чтением, так что читателям снова не нужны никакие атомарные операции. Остаётся только надеяться, что вы пишете хорошие комментарии к своему коду, чтобы у ваших читателей не случился взрыв мозга.


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


    my_tasks = xchg_acquire(&first, NULL);

xchg() подобно cmpxchg() атомарно выполняет одновременно чтение и запись в память. В данном случае читает и возвращает старое значение first, всегда записывая вместо него NULL. То есть переносит значение first в my_tasks. Здесь используется вариант xchg_acquire(), придающий acquire-семантику чтению из памяти, но при этом запись выполняется просто атормано, без release-семантики (подобно WRITE_ONCE()). Тут достаточно лишь acquire-семантики, потому что release-половинка находится в другом месте (уписателей). Вцелом, это всё тот же приём из первой части, только расширенный на случай более чем двух потоков.


Можно ли при этом заменить cmpxchg() у писателя на cmpxchg_release() с записью через release, но обычным чтением? По идее так можно сделать, ведь писателю важно только то, чтобы его запись в node->next была видна другим потокам. Однако, acquire-семантика чтения у cmpxchg() имеет полезный побочный эффект: так каждый следующий писатель синхронизируется с предыдущими писателями. Вызовы cmpxchg() упорядочиваются благодаря парам acquire и release-операций:


    поток 1: load-acquire first (возвращает NULL)             store-release node1 --> first                  \      поток 2: load-acquire first (возвращает node1)               store-release node2 --> first                    \         поток 3: load-acquire first (возвращает node2)                  store-release node3 --> first                       \            поток 4: xchg-acquire first (возвращает node3)

Только cmpxchg() из третьего потока синхронизирована с xchg_acquire() в четвёртом потоке. Однако благодаря транзитивности, все другие cmxchg() происходят перед xchg_acquire(). Если писатели используют cmpxchg(), то после xchg_acquire() по списку можно пройти уже с помощью обычных операций чтения.


Если бы писатели использовали cmpxchg_release(), то отношения порядка между потоками выглядели бы так:


    поток 1: load first (возвращает NULL)             store-release node1 --> first      поток 2: load first (возвращает node1)               store-release node2 --> first         поток 3: load first (возвращает node2)                  store-release node3 --> first                       \            поток 4: xchg-acquire first (возвращает node3)

Четвёртый поток конечно же прочитает node2 из node3->next, потому что он уже прочитал значение first, которое записал третий поток. Но вот для последующих элементов списка наблюдаемый порядок записей других потоков уже не гарантируется при использовании обычных операций чтения поэтому четвёртому потоку требуется использовать smp_load_acquire() для прохода по списку, чтобы точно увидеть node1 при чтении node2->next.


Конечно же односвязные списки давно реализованы в ядре Linux linux/llist.h поэтому можно не переизобретать колесо и пользоваться готовыми решениями. Теперь обратим внимание на ещё пару интересных функций: llist_del_first() и llist_reverse_order().


llist_del_first() вынимает и возвращает первый элемент списка. Документация предупреждает, что эта функция безопасна только для единственного читателя. Если несколько потоков одновременно забирают элементы списка, то при определённом стечении обстоятельств возможна так называемая проблема ABA. Я не буду вдаваться здесь в детали, просто не надо так делать. Возникает ситуация, похожая на предыдущий пример с rwlock: для корректной работы алгоритма в целом необходимо пользоваться блокировками, но определённые части могут обойтись без них. llist_del_first() позволяет любому количеству писателей добавлять элементы в список с помощью llist_add() без каких-либо блокировок, но если читателей несколько, то они между собой должны пользоваться мьютексом или спинлоком.


llist_del_first() превращает список в LIFO-контейнер (стек). Если же вам требуется FIFO-порядок (очередь), то можно воспользоваться следующим приёмом с llist_reverse_order(). Если забирать элементы из списка пачками с помощью xchg() (или же llist_del_all()), то пачки сохраняют FIFO-порядок между собой, только элементы в них следуют в обратном порядке. Воу, а что если...


    struct thing *first, *current_batch;    if (current_batch == NULL) {        current_batch = xchg_acquire(&first, NULL);        /* перевернуть список current_batch */    }    node = current_batch;    current_batch = current_batch->next;

Таким образом можно забирать элементы из списка в порядке их поступления, подобно очереди. Как и в случае с llist_del_first(), это работает только если элементы current_batch обрабатываются одним потоком. Пооная реализация этого алгоритма с помощью API llist оставляется читателю в качества упражнения.


На этом у меня пока всё. В следующей части мы продолжим изучать атомарные read-modify-write операции, посмотрим, что ещё можно сделать с compare-and-swap и как её можно использовать для ускорения счётчиков ссылок.


Дейв Чиннер один из основных сопровождающих XFS и Btrfs оставил замечательный комментарий касательно производительности cmpxchg().

Есть пара достаточно важных моментов про неблокирующие алгоритмы, использующие cmpxchg. Они как бы подразумеваются в статье, но явно не рассматриваются. cmpxchg определяется как атомарная операция read-modify-write, что технически корректно, но умалчивает некоторые ограничения масштабирования, с которыми сталкиваются неблокирующие алгоритмы на основе cmpxchg.


  1. cmpxchg это блокирующая операция: на уровне железа, не программном, но всё же блокирующая. Кроме того, она инвалидирует кеш-линии, со всеми вытекающими: неизбежно наступает момент, когда межпроцессорная шина насыщается, нагрузка на процессоры начинает расти нелинейно и производительность падает.
  2. cmpxchg не очень равномерно и справедливо распределяет нагрузку между процессорами, что легко может привести к взаимным блокировкам и просадкам производительности под высокой нагрузкой. Здесь уже вылезают особенности реализации протоколов когерентности кеша (при должном опыте по задержкам в исполнении инструкций можно догадываться о том, что там происходит на уровне железа).

Поэтому большинство алгоритмов в ядре выходят из цикла cmpxchg() после некоторого числа неудач, а потом переходят к плану Б: скажем, захватывают спинлок, чтобы избежать негативных последствий высокой нагрузки на cmpxchg. Например, dentry-кеш использует механизм lockref (lockref_get/lockref_put), который прекращает крутить цикл cmpxchg() после 100 неудачных попыток.


Dentry-кеш ускоряет работу с именами файлов, чтобы ядро не бегало каждый раз в файловую систему заново. Например, если вы открываете только что созданный файл, то он наверняка будет в dentry-кеше и ядру не придётся лишний раз перепроверять, что все промежуточные директории существуют.


Чтобы вы примерно себе представляли, когда конфликты за кеш-линии становятся проблемой (при достижении протоколом когерентности кешей точки насыщения), на моей машине нагрузка на процессор перестаёт линейно расти где-то при 2,53 миллионах операций в секунду, если взять простой цикл с cmpxchg() для 64-битного значения на своей собственной кеш-линии, где больше ничего не происходит. Это в 32 потока на машине двухлетней давности с 32 ядрами. Они не прям под завязку нагружены, но если попрофайлить конкретный цикл, создавая 650000 файлов в секунду, то можно наблюдать, как суммарно этот цикл начинает занимать вполне значимое количество ресурсов: единицы процентов процессорного времени.


Если сравнить со спинлоками, то при похожей нагрузке приблизительно треть времени тратится только на то, чтобы крутить спинлоки. Конкретно, если посмотреть на спинлок, защищающий список inode в суперблоке файловой системы, то при 2,6 миллионах доступов в секунду на спинлоки уходит околе 10% процессорных ресурсов (то есть 3 из 32 процессоров). Очень похоже на cmpxchg, который начинает тупить где-то в этом же районе, и если подумать, как оба варианта обращаются с кешем, то наблюдаемое поведение вполне логично.


Другими словами, cmpxchg() масштабируется лучше, чем блокирующие алгоритмы, но это не волшебная таблетка, делающая всё быстрее. У cmpxchg() тоже есть пределы масштабируемости, так как это не вполне неблокирущая операция. Врочем, мой опыт проектирования многопоточных алгоритмов подсказывает, что масштабируемость зависит больше от того, насколько часто алгоритму требуется читать или обновлять разделяемые данные, а не от того, насколько (не)блокирующий алгоритм используется. Мьютекс это ж фактически просто атомарный флажок захвачено и немного оптимизаций. Так что неудивительно, что cmpxchg() и атомарные примитивы имеют схожие ограничения в масштабируемости с блокировками фундаментально, если посмотреть с точки зрения кеша, все эти подходы об одном и том же. Просто cmpxchg() и атомарные операции дают более тонкий контроль над памятью, уменьшая частоту одновременного доступа к данным. Отсюда и выгода: если меньше трогать общую память, то можно делать больше независимой работы.


Искусство многопоточного программирования во многом завязано на понимание того, как алгоритмы работают с памятью. Недостаточно лишь примерно представлять, как работают неблокирующие алгоритмы нужно чётко понимать, что именно они делают с памятью и когда упираются в пределы своих возможностей. Естественно, знать, когда не нужно использовать неблокирующие алгоритмы вовсе, потому что есть другие способы распараллелить работу это тоже искусство :)


На что последовал ответ автора Паоло.

Дейв, огромное спасибо за ответ. Прям в корень зришь я сомневался, стоит ли вообще рассказывать о связных списках, во многом из-за описанных тобой причин. С одной стороны, связные списки это прямо классический пример неблокирущих структур данных. С другой стороны, они не настолько широко применимы, как другие вещи, о которых я рассказываю, вот как раз из-за ограничений в масштабируемости. Нет, у связных списков есть своя ниша, но их слишком легко использовать там, где не надо. Например, в тех немногих случаях, когда мне были нужны именно llist, я принял такое решение не потому, что неблокирующее добавление в список это круто (нет, это прикольно, но у него проблемы с распределением работы между процессорами при повышенной нагрузке). Наоборот, я знал, что писателей будет немного и записи будут редкими, а вот читатель будет лишь один и его хотелось сделать как можно более быстрым. Я бы не назвал это очевидным решением задачи.


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


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


  • Если вы знаете пару-тройку широко известных приёмов (которые часто ещё и завёрнуты в удобные API), то внезапно ваш код становится проще поддерживать и масштабируемость тоже скорее всего будет получше, несмотря на то, что неблокирующее программирование сложнее мьютексов. И вот тут неблокирующие связные списки это как раз плохой пример, потому что в неопытных руках от них порой больше вреда чем пользы.
  • Следует как можно чётче разделять быструю неблокирующую часть кода которая либо реализует один из рассмотренных приёмов, либо использует какую-нибудь готовую абстракцию вроде того же lockref и медленную часть, где можно позволить себе блокировки. Гораздо проще выбрать правильный примитив, если неблокирующая часть небольшая и аккуратная.
  • На выбор очень сильно влияют обстоятельства. Если у вас только один читатель или писатель, то дело существенно упрощается, так как скорее всего ваш алгоритм попадает в какой-нибудь из привычных, готовых шаблонов.

Чтобы добиться максимальной производительности, вам необходимо знать относительную частоту записей и чтений (или распределение работы между быстрой и медленной частями алгоритма). Иначе вы ж понятия не имеете, где у вас могут возникнуть проблемы с масштабируемостью. Неблокирующие алгоритмы это всегда компромисс (моя история с llist служит примером, но пожалуй наиболее известный пример это RCU). Стоит иметь это в виду, ведь если ваш код выполняется достаточно редко, то на медленную часть можно не особо обращать внимание, добавить туда мьютекс и превратить много читателей/писателей в одного (в один момент времени), что может значительно упростить вам жизнь, расширив количество применимых шаблонов неблокирующего программирования.

Подробнее..

Вычисления без инструкций на x86

16.12.2020 16:22:14 | Автор: admin

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

Данная публикация основана на статье под названием "The Page-Fault Weird Machine: Lessons in Instruction-less Computation". Греет душу, что один из её соавторов, Sergey Bratus, давний выпускник Физтеха. Впервые я узнал о данной работе из заметки Gwern Barwen "Surprisingly Turing-Complete" , содержащей множество удивительных примеров Тьюринг-полных систем. Кстати, часть её переведена на Хабре, правда описываемому в данной статье явлению там отведено всего три предложения.

Оригинальная статья была опубликована в 2013 году, что может натолкнуть на вопрос об актуальности данного её пересказа в связи с завершившимся за эти годы переходом на x86-64, а следовательно отказом от защищённого режима исполнения в пользу long mode, в котором аппаратное переключение задач не поддерживается. Однако благодаря обратной совместимости архитектуры x86 излагаемая идея может быть реализована на современной аппаратуре и гипервизорах.

Продолжая тему актуальности, замечу, что первоисточник не претендовал на прямое практическое применение описанных в статье идей, а лишь приоткрыл завесу "странных вычислителей", обнаруживающихся в современных процессорах. Тем не менее, определённый полезный результат из этой работы следует напрямую: во-первых, запуск программы, которая нестандартным способом утилизирует крайние случаи, при этом полностью соответствуя спецификации отличный способ поиска ошибок в железе и гипервизорах (и немало было найдено в QEMU, по словам авторов). Во-вторых, так как обычные гипервизоры тестируются и ориентированы на наиболее распространённые операционные системы, результат исполнения в них подобной неординарной программы может отличаться от ожидаемого на голом железе. Данный феномен, позволяющий обнаружить факт исполнения под гипервизором, называется red pill, по аналогии с красной таблеткой, которая позволила Нео осознать виртуальность привычного ему мира. Число red pills является важной характеристикой гипервизора, так как многие вредоносные программы пользуются ими для определения уровня "осторожности", с которой нужно исполняться, чтобы не быть обнаруженными антивирусным ПО и системами отладки, которые обычно запускаются в безопасной среде. На самом деле тренд применения red pill в последние годы пошёл на спад, так как всё больше сервисов мигрируют в облако, где всё работает в виртуальной среде, и создатели вирусов не готовы пожертвовать этим "рынком" ради усложнения собственного анализа. И тем не менее, приведу несколько примеров вредоносного ПО, меняющего своё поведение при наличии гипервизора. Хотя часть его уже и ушла в прошлое, в своё время оно нанесло немало вреда. Среди таких программ: Neutrino(2016 г.), Conficker(2008 г.), Rebhip(2011 г.), IRC боты: Phatbot(2008 г.), Rbot, SDbot/Reptile, Mechbot, SpyBot и другие (ссылки ведут на упоминания об использовании ими методов проверки наличия виртуальной среды). В-третьих, нестандартные способы произведения вычислений открывают широкие просторы для обфускации. Особенно перспективной видится обфускация с помощью подобных "странных" вычислений в контексте unikernel.

Red pill программа, обнаруживающая виртуальную среду. Blue pill вредоносный гипервизорRed pill программа, обнаруживающая виртуальную среду. Blue pill вредоносный гипервизор

В чём же заключается "странность" вычислителя, которому посвящена эта статья, и как построить Тьюринг-полную машину без единого гвоздя без единой инструкции? Чтобы ответить на этот вопрос, нужно сначала напомнить читателям некоторые аспекты архитектуры x86.

Обработка исключений и аппаратное переключение задач

В архитектуре IA-32 существует функционал аппаратного переключения задач, предназначенный облегчить работу разработчикам операционных систем. Использованный вместе с механизмом call gates совершения системных вызовов, как предполагалось, он минимизирует затраты программистов на описание логики смены контекста. Но на сегодняшний день, насколько мне известно, всё это мёртвый кремний, давно заброшенный операционными системами в пользу более производительных программных решений; со стороны же производителей процессоров деоптимизированный до микрокода. А в x86-64 поддержка аппаратного переключения задач вообще отключена.

Единственный пример использования аппаратного переключения задач, с которым я сталкивался (и который, кстати, близок к описанному в работе), это использование в 32-bit Linux отдельной задачи (в терминах процессора) для обработки double fault, дословно, двойного исключения. Double fault возникает, если процессор столкнулся с ошибкой на этапе запуска или исполнения обработчика какого-то другого исключения или прерывания; например, в обработчике совершена попытка деления на ноль. Обычно, конечно, причиной double fault является не арифметика, а глубоко сломанное состояние процессора, именно поэтому системные таблицы, о которых пойдёт речь ниже, в Linux настроены так, что для обработки double fault используется отдельная задача, которая могла бы вывести в kernel panic "грязное" состояние основной задачи. Если же и в double fault возникает какая-то критическая ошибка, происходит triple fault, также известный как перезагрузка компьютера.

С каждой задачей в x86 ассоциировано состояние, хранящееся в task-state segment(TSS). Главным образом, в него сохраняются регистры процессора, подлежащие восстановлению при возобновлении исполнения задачи. Среди этих регистров нас будут особенно интересовать EIP (instruction pointer) указатель на текущую исполняемую инструкцию, ESP (stack pointer) указатель на вершину стека, CR3 указатель на таблицу страниц (строго говоря, page directory), а также регистры общего назначения EAX и ECX, о необходимости в которых чуть ниже.

Содержимое TSS. Обратите внимание на расположение CR3, EIP, EAX, ECX, EDX, ESP.Содержимое TSS. Обратите внимание на расположение CR3, EIP, EAX, ECX, EDX, ESP.

Аппаратное переключение задач тесно связано с механизмами обработки прерываний, исключений, а также сегментацией памяти. Одними из важнейших таблиц, конфигурирующих работу процессора, являются GDT(Global Descriptor Table) глобальная таблица дескрипторов и IDT(Interrupt Descriptor Table) таблица дескрипторов прерываний. Опуская подробности, перечислю некоторые типы дескрипторов, которые могут в них располагаться: memory segment descriptor дескриптор сегмента памяти(кода, данных), TSS descriptor указатель на task-state segment, interrupt-gate descriptor указатель на дескриптор одного из первых двух типов, определяющий, как обрабатывать прерывание с переключением задачи или совершив far call. Неудивительно, что эти три головы гидры управление памятью, задачами и прерываниями неразлучны.

Тьюринг-полнота во всей красе

Это всё выглядит довольно сложно и, наверное, скрывает в себе вычислительную мощность, но откуда возникает Тьюринг-полнота? Дело в том, что при вызове обработчиков некоторых исключений, таких как page fault и double fault, в стек кладётся 4-байтный код ошибки. Сам код ошибки нам неинтересен, важен лишь побочный эффект, состоящий в уменьшении(не забываем, что стек в x86 растёт вниз) значения регистра ESP на 4. Но что произойдёт, если ESP на момент срабатывания прерывания уже равен нулю, то есть уменьшать его некуда? Было бы очень странно, если бы указатель на стек оборачивался вокруг 32 бит, поэтому в таких случаях срабатывает double fault-исключение.

Попробуем создать из описанных выше примитивов наш странный вычислитель. Для начала, так как никакие вычисления не будут производиться инструкциями процессора, присвоим регистру EIP во всех задачах недействительное значение, например, 0xFFFFFFFF. Это будет постоянно приводить к page fault, который срабатывает при ошибках доступа к памяти. Как мы уже выяснили, при попытке обработать page fault произойдёт нечто подобное:

if (esp == 0) {    goto double_fault_handler;} else {    esp -= 4;    goto page_fault_handler;}

Уже намечается какое-то уменьшение значения, ветвление Но для Тьюринг-полноты нам этого мало. Как уже можно догадаться, каждая "инструкция" нашего странного вычислителя будет исполняться побочными эффектами от срабатывания исключения. А так как одним из таких эффектов (согласно нашей конфигурации IDT и GDT) является загрузка в регистры содержимого очередной TSS, на момент обработки каждого исключения значение регистра CR3, отвечающего, как мы помним, за таблицу страниц, может быть своё(то, которое хранится в TSS, ассоциированного с данным исключением). И поскольку регистры IDTR и GDTR (не сохраняемые в TSS, а следовательно неизменные) хранят виртуальные адреса IDT и GDT, получается, что при исполнении очередной "инструкции" срабатывании исключения смене задачи загрузке нового значения CR3 смене таблицы страниц мы можем менять IDT и GDT, которые видит процессор. Вместе с IDT меняется и хранящийся в нём указатель на TSS, это также пригодится нам в дальнейшем.

Взаимосвязь между IDT, GDT и TSS. TR (Task Register) содержит индекс дескриптора текущей TSS в GDT.Взаимосвязь между IDT, GDT и TSS. TR (Task Register) содержит индекс дескриптора текущей TSS в GDT.

Реализация movdbz-вычислителя

Получается, что исполнение "инструкции" состоит из следующих этапов:

  1. Генерация исключения, которое выполнит следующую "инструкцию". Согласно конфигурации IDT и GDT, это приводит к смене задачи.

  2. Загрузка TSS, ассоциированного со сгенерированным исключением, в регистровый файл. В частности, из TSS загружаются EIP, ESP, CR3.

  3. В "стек" загруженной TSS кладётся код ошибки, а также (что более важно), если регистр ESP > 0, то из ESP вычитается 4, иначе ESP не изменяется.

  4. Так как загрузка CR3 из шага 2 имела побочный эффект в виде смены таблицы страниц, результирующий ESP будет помещён в TSS, на который указывает ячейка в новом GDT (см. также Task Register) и который не обязан совпадать с исходным.

  5. Если условие из пункта 3 оказалось истинным и вычитание было успешным, то произойдёт попытка исполнения инструкций процессора, что невозможно ввиду недействительного значения EIP, следовательно произойдёт переход на задачу, ассоциированную с исключением page fault; в противном случае следующей задачей будет ассоциированная с исключением double fault.

Запишем последовательность выше в виде псевдокода, в котором сразу введём некоторые обозначения: rsrc это ESP, подгруженный из исходной TSS; rdest это ESP, хранящийся в TSS, на которую указывает GDT после загрузки регистра CR3 из исходной TSS. label_zero задача, ассоциированная с исключением double fault, label_nonzero с исключением page fault. Также будем трактовать ESP как значение регистра нашего вычислителя, умноженное на 4, тогда из последнего на каждой обработке исключения будет вычитаться единица, а не четвёрка.

if (rsrc == 0) {    rdest = rsrc;    goto label_zero;} else {    rdest = rsrc - 1;    goto label_nonzero;}

Последовательность выше не что иное, как расшифровка инструкции movdbz, move-branch-if-zero-or-decrement (перемести-прыгни-если-ноль-или-уменьши). Как известно, инструкция subtract-and-branch-if-negative (вычти-и-прыгни-если-отрицательный-результат), которую несложно реализовать через вышеупомянутую movdbz(это строго показано в оригинальной статье), позволяет построить Тьюринг-полный вычислитель. Дочитавшим до сюда также, наверное, будет интересно узнать про более экзотическое доказательство это компилятор Тьюринг-полного Brainfuck в набор инструкций movdbz-вычислителя.

Итак, наша инструкция movdbz имеет четыре аргумента:

movdbz rdest, rsrc, label_nonzero, label_zero

В качестве упражнения для читателя остаётся написание с использованием одной только этой инструкции игры Жизнь Джона Конвея (коронавирус забирает лучших), использованной в демо авторов оригинальной статьи. На movdbz нужно написать именно логику эволюции ячеек, для вывода содержимого кадрового буфера на экран авторы используют реальные инструкции x86. Поэтому утверждение про недействительное значение EIP выполняется в их демо не во всех TSS.

Сопротивление архитектуры

В real-mode с его A20 и high memory, когда стрельба себе в ногу входила в стандартный арсенал программиста, можно было много всего себе позволить. Но времена изменились, и в защищённом режиме Intel создали некоторые препоны насилию нам своими чипами, которые, в частности, мешают нам развлекаться со своими странными вычислителями. Например, ESP должен указывать внутрь выделенного под стек участка памяти(иначе мы получим исключение #SS stack-segment fault), поэтому нужно пометить первую страницу памяти в каждой таблице страниц как содержащую стек. И так как в ESP хранится значение, в 4 раза большее содержимого "регистра" нашего вычислителя, последний может принимать значения от 0 до 1023, а не от 0 до 4095 (используется 4 КиБ страница). Но подобные мелочи мне кажутся довольно скучными, поэтому далее я поговорю только о тех препятствиях, методы борьбы с которыми мне показались интересными.

Данные отдельно, кодлеты отдельно

Одной из незамеченных в вышеприведённых рассуждениях оказалась следующая проблема: в пункте 4 из списка выше в "destination TSS" записывается не только ESP, но и CR3. Хорошо подумав, можно осознать, что ESP, хранящиеся в разных TSS, -- это регистры нашего вычислителя, и их значения не должны быть привязаны инструкциям. А CR3 как раз отвечает за то, какая инструкция сейчас исполняется, ведь от CR3 зависит содержимое IDT, а значит и то, какие инструкции исполнятся следующими то есть он определяет положение текущей инструкции в "программе". В связи с этим мы хотим разделить TSS на две части отвечающую за инструкции, которые исполнятся следующими; и содержающую регистр, с которым оперирует текущий movdbz. Поэтому мы "рассекаем" TSS границей страницы между регистрами ECX и EDX(сейчас стоит обратиться к картинке с содержимым TSS выше). Таким образом, так как в верхней части TSS из полезных для нашего вычислителя данных остаётся только ESP, основным смыслом трюка с CR3 становится переотбражение в верхнюю часть TSS физической страницы c rdest, куда будет записан результат исполнения инструкции.

Busy bit

Также крайне находчивым мне показался способ обхода помехи busy bit, встроенного в указатель на TSS в GDT и определяющего, находится ли сейчас процессор в состоянии обработки исключения. Если этот бит выставлен, архитетура запрещает переключение задач, что делает невозможным пункт 5 из описания выше. Этот бит автоматически выставляется при срабатывании исключения и, при обычном исполнении, очищается при выходе из обработчика. Но у нас нет обработчика, мы не исполняем инструкций процессора, поэтому нужно найти другой способ очищать этот бит. Будет нелишним заметить, что, по словам автора, на то, чтобы дойти до этой идеи и реализовать её, у него ушло 8 недель из 10, затраченных на весь проект.

Решение этой проблемы следующее: расположим GDT так, чтобы она перекрывалась с TSS! GDT может занимать максимум 16 страниц, последние 8 байт каждой из этих страниц это, как мы помним, вследствие рассечения TSS, регистры EAX и ECX. Хак состоит в том, чтобы заранее в каждой TSS положить на место регистров EAX и ECX этот самый указатель на TSS (строго говоря, дескриптор TSS) с очищенным busy bit-ом. Таким образом, при каждой выгрузке TSS из регистрового файла на 4 шаге busy bit в дескрипторе текущей TSS в GDT будет выставляться в ноль. Осталось только записать в IDT указатели на правильные дескрипторы в GDT те, которые занимают последние 8 байт в каждой из 16 страниц.

Заключение

Каков же смысл во всех этих исхищрениях, в построении такого "странного вычислителя"? Во-первых, у описанных выше идей есть прямое, хотя и не столь актуальное, практическое применение создание red pill, позволяющей отличить голое железо от гипервизора. Во-вторых, такое неортодоксальное применение архитектуры x86, выискивание тёмных уголков и крайних случаев может доставить неподдельное удовольствие любителям эзотерического программирования. В-третьих, данная работа несёт, на мой взгляд, глубокую философскую идею, подчёркивая ценность хакерского склада ума применительно к нахождению "странных вычислителей" там, где они не подразумевались или где их не видят другие. Ведь любой эксплойт по сути плод мастерского программирования "странной виртуальной машины", своеобразным "байткодом" которой являются баги, фичи и различные неожиданные пути исполнения целевой платформы. Кстати, тентакли макаронного монстра на обложке как раз и олицетворяют всевозможные особенности и изъяны, обнажаемые программными и аппаратными системами и с охотой используемые разработчиками эксплойтов для нарушения задумываемого хода работы программы.

Основные источники(в рекомендуемом порядке ознакомления):

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru