Wie sieht die Multicore-Assemblersprache aus?


243

Es war einmal, als Sie zum Schreiben eines x86-Assemblers beispielsweise Anweisungen hatten, die besagten: "Laden Sie das EDX-Register mit dem Wert 5", "Erhöhen Sie das EDX-Register" usw.

Bei modernen CPUs mit 4 Kernen (oder sogar mehr) sieht es auf Maschinencodeebene nur so aus, als gäbe es 4 separate CPUs (dh gibt es nur 4 verschiedene "EDX" -Register)? Wenn ja, wenn Sie "Inkrementieren des EDX-Registers" sagen, was bestimmt, welches EDX-Register der CPU inkrementiert wird? Gibt es jetzt im x86-Assembler ein "CPU-Kontext" - oder "Thread" -Konzept?

Wie funktioniert die Kommunikation / Synchronisation zwischen den Kernen?

Wenn Sie ein Betriebssystem geschrieben haben, welcher Mechanismus wird über Hardware verfügbar gemacht, damit Sie die Ausführung auf verschiedenen Kernen planen können? Handelt es sich um spezielle privilegierte Anweisungen?

Wenn Sie eine optimierende Compiler- / Bytecode-VM für eine Multicore-CPU schreiben würden, was müssten Sie beispielsweise speziell über x86 wissen, damit Code generiert wird, der auf allen Kernen effizient ausgeführt wird?

Welche Änderungen wurden am x86-Maschinencode vorgenommen, um die Multi-Core-Funktionalität zu unterstützen?


2
Es gibt eine ähnliche (wenn auch nicht identische) Frage hier: stackoverflow.com/questions/714905/…
Nathan Fellman

Antworten:


153

Dies ist keine direkte Antwort auf die Frage, sondern eine Antwort auf eine Frage, die in den Kommentaren erscheint. Im Wesentlichen stellt sich die Frage, welche Unterstützung die Hardware für den Multithread-Betrieb bietet.

Nicholas Flynt hatte es richtig gemacht , zumindest in Bezug auf x86. In einer Umgebung mit mehreren Threads (Hyper-Threading, Multi-Core oder Multi-Prozessor ) beginnt der Bootstrap-Thread (normalerweise Thread 0 in Core 0 in Prozessor 0) mit dem Abrufen von Code von der Adresse 0xfffffff0. Alle anderen Threads werden in einem speziellen Ruhezustand namens Wait-for-SIPI gestartet . Im Rahmen seiner Initialisierung sendet der primäre Thread über den APIC einen speziellen Interprozessor-Interrupt (IPI), der als SIPI (Startup IPI) bezeichnet wird, an jeden Thread, der sich in WFS befindet. Das SIPI enthält die Adresse, von der dieser Thread mit dem Abrufen von Code beginnen soll.

Dieser Mechanismus ermöglicht es jedem Thread, Code von einer anderen Adresse auszuführen. Alles, was benötigt wird, ist Software-Unterstützung für jeden Thread, um seine eigenen Tabellen und Messaging-Warteschlangen einzurichten. Das Betriebssystem verwendet diese , um die eigentliche Multithread-Planung durchzuführen.

In Bezug auf die eigentliche Baugruppe gibt es, wie Nicholas schrieb, keinen Unterschied zwischen den Baugruppen für eine Anwendung mit einem oder mehreren Threads. Jeder logische Thread hat einen eigenen Registersatz. Schreiben Sie also:

mov edx, 0

wird nur EDXfür den aktuell ausgeführten Thread aktualisiert . Es gibt keine Möglichkeit, einen EDXanderen Prozessor mit einer einzigen Montageanweisung zu ändern . Sie benötigen eine Art Systemaufruf, um das Betriebssystem aufzufordern, einen anderen Thread anzuweisen, Code auszuführen, der seinen eigenen aktualisiert EDX.


2
Vielen Dank, dass Sie die Lücke in Nicholas 'Antwort geschlossen haben. Habe deine jetzt als akzeptierte Antwort markiert ... gibt die spezifischen Details an, an denen ich interessiert war ... obwohl es besser wäre, wenn es eine einzige Antwort gäbe, die deine Informationen und die von Nicholas zusammen enthält.
Paul Hollingsworth

3
Dies beantwortet nicht die Frage, woher die Threads kommen. Kerne und Prozessoren sind eine Hardware-Sache, aber irgendwie müssen Threads in Software erstellt werden. Woher weiß der primäre Thread, wohin der SIPI gesendet werden soll? Oder erstellt das SIPI selbst einen neuen Thread?
Rich Remer

7
@richremer: Es scheint, als ob Sie HW-Threads und SW-Threads verwechseln. Der HW-Thread ist immer vorhanden. Manchmal schläft es. Das SIPI selbst weckt den HW-Thread und ermöglicht es ihm, SW auszuführen. Es ist Sache des Betriebssystems und des BIOS, zu entscheiden, welche HW-Threads ausgeführt werden und welche Prozesse und SW-Threads auf jedem HW-Thread ausgeführt werden.
Nathan Fellman

2
Viele gute und prägnante Informationen hier, aber dies ist ein großes Thema - so können Fragen verweilen. Es gibt einige Beispiele für vollständige "Bare-Bones" -Kerne in freier Wildbahn, die von USB-Laufwerken oder "Disketten" booten - hier ist eine x86_32-Version, die in Assembler unter Verwendung der alten TSS-Deskriptoren geschrieben wurde, die tatsächlich Multithread-C-Code ( Github) ausführen können. com / duanev / oz-x86-32-asm-003 ), aber es gibt keine Standardbibliotheksunterstützung. Ein bisschen mehr als Sie verlangt haben, aber es kann vielleicht einige dieser verweilenden Fragen beantworten.
Duanev

87

Beispiel für ein minimal lauffähiges Intel x86-Baremetall

Lauffähiges Bare-Metal-Beispiel mit allen erforderlichen Boilerplates . Alle wichtigen Teile werden unten behandelt.

Getestet unter Ubuntu 15.10 QEMU 2.3.0 und Lenovo ThinkPad T400 als echter Hardware-Gast .

Das Intel Manual Volume 3 System Programming Guide - 325384-056US September 2015 behandelt SMP in den Kapiteln 8, 9 und 10.

Tabelle 8-1. "Broadcast INIT-SIPI-SIPI-Sequenz und Auswahl von Timeouts" enthält ein Beispiel, das im Grunde nur funktioniert:

MOV ESI, ICR_LOW    ; Load address of ICR low dword into ESI.
MOV EAX, 000C4500H  ; Load ICR encoding for broadcast INIT IPI
                    ; to all APs into EAX.
MOV [ESI], EAX      ; Broadcast INIT IPI to all APs
; 10-millisecond delay loop.
MOV EAX, 000C46XXH  ; Load ICR encoding for broadcast SIPI IP
                    ; to all APs into EAX, where xx is the vector computed in step 10.
MOV [ESI], EAX      ; Broadcast SIPI IPI to all APs
; 200-microsecond delay loop
MOV [ESI], EAX      ; Broadcast second SIPI IPI to all APs
                    ; Waits for the timer interrupt until the timer expires

Auf diesem Code:

  1. Die meisten Betriebssysteme machen die meisten dieser Vorgänge ab Ring 3 (Benutzerprogramme) unmöglich.

    Sie müssen also Ihren eigenen Kernel schreiben, um frei damit spielen zu können: Ein Userland Linux-Programm funktioniert nicht.

  2. Zunächst wird ein einzelner Prozessor ausgeführt, der als Bootstrap-Prozessor (BSP) bezeichnet wird.

    Es muss die anderen (als Application Processors (AP) bezeichnet) durch spezielle Interrupts, sogenannte Inter Processor Interrupts (IPI), aufwecken .

    Diese Interrupts können durch Programmieren des Advanced Programmable Interrupt Controller (APIC) über das Interrupt-Befehlsregister (ICR) erfolgen.

    Das Format des ICR ist dokumentiert unter: 10.6 "AUSGABE VON INTERPROCESSOR-INTERRUPTS"

    Das IPI erfolgt, sobald wir an das ICR schreiben.

  3. ICR_LOW ist in 8.4.4 "MP-Initialisierungsbeispiel" definiert als:

    ICR_LOW EQU 0FEE00300H
    

    Der magische Wert 0FEE00300ist die Speicheradresse des ICR, wie in Tabelle 10-1 "Local APIC Register Address Map" dokumentiert.

  4. Im Beispiel wird die einfachste Methode verwendet: Sie richtet den ICR so ein, dass Broadcast-IPIs gesendet werden, die an alle anderen Prozessoren außer dem aktuellen geliefert werden.

    Es ist aber auch möglich und von einigen empfohlen , Informationen über die Prozessoren über spezielle Datenstrukturen abzurufen, die vom BIOS eingerichtet wurden, wie z. B. ACPI-Tabellen oder Intels MP-Konfigurationstabelle, und nur diejenigen zu aktivieren, die Sie einzeln benötigen.

  5. XXin 000C46XXHcodiert die Adresse des ersten Befehls, den der Prozessor ausführen wird als:

    CS = XX * 0x100
    IP = 0
    

    Denken Sie daran, dass CS Adressen mit multipliziert0x10 , sodass die tatsächliche Speicheradresse des ersten Befehls wie folgt lautet:

    XX * 0x1000
    

    Wenn zum Beispiel XX == 1der Prozessor bei startet 0x1000.

    Wir müssen dann sicherstellen, dass an diesem Speicherort 16-Bit-Realmoduscode ausgeführt werden kann, z. B.:

    cld
    mov $init_len, %ecx
    mov $init, %esi
    mov 0x1000, %edi
    rep movsb
    
    .code16
    init:
        xor %ax, %ax
        mov %ax, %ds
        /* Do stuff. */
        hlt
    .equ init_len, . - init
    

    Die Verwendung eines Linkerskripts ist eine weitere Möglichkeit.

  6. Die Verzögerungsschleifen sind ein nerviger Teil, um an die Arbeit zu gehen: Es gibt keine supereinfache Möglichkeit, solche Schlafzeiten präzise durchzuführen.

    Mögliche Methoden sind:

    • PIT (in meinem Beispiel verwendet)
    • HPET
    • Kalibrieren Sie die Zeit einer Besetztschleife mit den oben genannten und verwenden Sie sie stattdessen

    Verwandte Themen : Wie kann ich eine Nummer auf dem Bildschirm anzeigen und mit der DOS x86-Assembly eine Sekunde lang schlafen?

  7. Ich denke, der anfängliche Prozessor muss sich im geschützten Modus befinden, damit dies funktioniert, wenn wir an eine Adresse schreiben, 0FEE00300Hdie für 16-Bit zu hoch ist

  8. Um zwischen Prozessoren zu kommunizieren, können wir einen Spinlock für den Hauptprozess verwenden und die Sperre vom zweiten Kern aus ändern.

    Wir sollten sicherstellen, dass das Zurückschreiben des Speichers erfolgt, z wbinvd. B. durch .

Geteilter Zustand zwischen Prozessoren

8.7.1 "Status der logischen Prozessoren" sagt:

Die folgenden Funktionen sind Teil des Architekturstatus logischer Prozessoren in Intel 64- oder IA-32-Prozessoren, die die Intel Hyper-Threading-Technologie unterstützen. Die Funktionen können in drei Gruppen unterteilt werden:

  • Für jeden logischen Prozessor dupliziert
  • Wird von logischen Prozessoren in einem physischen Prozessor gemeinsam genutzt
  • Je nach Implementierung freigegeben oder dupliziert

Die folgenden Funktionen werden für jeden logischen Prozessor dupliziert:

  • Allzweckregister (EAX, EBX, ECX, EDX, ESI, EDI, ESP und EBP)
  • Segmentregister (CS, DS, SS, ES, FS und GS)
  • EFLAGS- und EIP-Register. Beachten Sie, dass die CS- und EIP / RIP-Register für jeden logischen Prozessor auf den Befehlsstrom für den vom logischen Prozessor ausgeführten Thread verweisen.
  • x87-FPU-Register (ST0 bis ST7, Statuswort, Steuerwort, Tag-Wort, Datenoperandenzeiger und Befehlszeiger)
  • MMX-Register (MM0 bis MM7)
  • XMM-Register (XMM0 bis XMM7) und das MXCSR-Register
  • Steuerregister und Systemtabellenzeigerregister (GDTR, LDTR, IDTR, Taskregister)
  • Debug-Register (DR0, DR1, DR2, DR3, DR6, DR7) und die Debug-Steuer-MSRs
  • Globaler Status der Maschinenprüfung (IA32_MCG_STATUS) und Fähigkeit zur Maschinenprüfung (IA32_MCG_CAP) MSRs
  • Thermische Taktmodulation und ACPI Power Management steuern MSRs
  • Zeitstempelzähler MSRs
  • Die meisten anderen MSR-Register, einschließlich der Seitenattributtabelle (PAT). Siehe die Ausnahmen unten.
  • Lokale APIC-Register.
  • Zusätzliche Allzweckregister (R8-R15), XMM-Register (XMM8-XMM15), Steuerregister, IA32_EFER auf Intel 64-Prozessoren.

Die folgenden Funktionen werden von logischen Prozessoren gemeinsam genutzt:

  • Speichertyp-Bereichsregister (MTRRs)

Ob die folgenden Funktionen gemeinsam genutzt oder dupliziert werden, ist implementierungsspezifisch:

  • IA32_MISC_ENABLE MSR (MSR-Adresse 1A0H)
  • MCA-MSRs (Machine Check Architecture) (mit Ausnahme der MSRs IA32_MCG_STATUS und IA32_MCG_CAP)
  • Leistungsüberwachungssteuerung und Zähler-MSRs

Die Cache-Freigabe wird unter folgender Adresse erläutert:

Intel-Hyperthreads haben eine größere Cache- und Pipeline-Freigabe als separate Kerne: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858

Linux-Kernel 4.2

Die Hauptinitialisierungsaktion scheint bei zu sein arch/x86/kernel/smpboot.c.

ARM Minimal Runnable Baremetal Beispiel

Hier stelle ich ein minimal lauffähiges ARMv8 aarch64-Beispiel für QEMU bereit:

.global mystart
mystart:
    /* Reset spinlock. */
    mov x0, #0
    ldr x1, =spinlock
    str x0, [x1]

    /* Read cpu id into x1.
     * TODO: cores beyond 4th?
     * Mnemonic: Main Processor ID Register
     */
    mrs x1, mpidr_el1
    ands x1, x1, 3
    beq cpu0_only
cpu1_only:
    /* Only CPU 1 reaches this point and sets the spinlock. */
    mov x0, 1
    ldr x1, =spinlock
    str x0, [x1]
    /* Ensure that CPU 0 sees the write right now.
     * Optional, but could save some useless CPU 1 loops.
     */
    dmb sy
    /* Wake up CPU 0 if it is sleeping on wfe.
     * Optional, but could save power on a real system.
     */
    sev
cpu1_sleep_forever:
    /* Hint CPU 1 to enter low power mode.
     * Optional, but could save power on a real system.
     */
    wfe
    b cpu1_sleep_forever
cpu0_only:
    /* Only CPU 0 reaches this point. */

    /* Wake up CPU 1 from initial sleep!
     * See:https://github.com/cirosantilli/linux-kernel-module-cheat#psci
     */
    /* PCSI function identifier: CPU_ON. */
    ldr w0, =0xc4000003
    /* Argument 1: target_cpu */
    mov x1, 1
    /* Argument 2: entry_point_address */
    ldr x2, =cpu1_only
    /* Argument 3: context_id */
    mov x3, 0
    /* Unused hvc args: the Linux kernel zeroes them,
     * but I don't think it is required.
     */
    hvc 0

spinlock_start:
    ldr x0, spinlock
    /* Hint CPU 0 to enter low power mode. */
    wfe
    cbz x0, spinlock_start

    /* Semihost exit. */
    mov x1, 0x26
    movk x1, 2, lsl 16
    str x1, [sp, 0]
    mov x0, 0
    str x0, [sp, 8]
    mov x1, sp
    mov w0, 0x18
    hlt 0xf000

spinlock:
    .skip 8

GitHub stromaufwärts .

Zusammenbauen und ausführen:

aarch64-linux-gnu-gcc \
  -mcpu=cortex-a57 \
  -nostdlib \
  -nostartfiles \
  -Wl,--section-start=.text=0x40000000 \
  -Wl,-N \
  -o aarch64.elf \
  -T link.ld \
  aarch64.S \
;
qemu-system-aarch64 \
  -machine virt \
  -cpu cortex-a57 \
  -d in_asm \
  -kernel aarch64.elf \
  -nographic \
  -semihosting \
  -smp 2 \
;

In diesem Beispiel setzen wir CPU 0 in eine Spinlock-Schleife und sie wird nur beendet, wenn CPU 1 den Spinlock freigibt.

Nach dem Spinlock führt CPU 0 dann einen Semihost-Exit-Aufruf durch , wodurch QEMU beendet wird.

Wenn Sie QEMU mit nur einer CPU starten -smp 1, hängt die Simulation für immer am Spinlock.

CPU 1 wird mit der PSCI-Schnittstelle aufgeweckt, weitere Details unter: ARM: Start / Wakeup / Bringup die anderen CPU-Kerne / APs und Startadresse für die Ausführung übergeben?

Die Upstream-Version hat auch einige Verbesserungen, damit sie auf gem5 funktioniert, sodass Sie auch mit Leistungsmerkmalen experimentieren können.

Ich habe es nicht auf echter Hardware getestet und bin mir nicht sicher, wie portabel dies ist. Die folgende Raspberry Pi-Bibliographie könnte von Interesse sein:

Dieses Dokument enthält einige Anleitungen zur Verwendung von ARM-Synchronisationsprimitiven, mit denen Sie unterhaltsame Dinge mit mehreren Kernen ausführen können : http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf

Getestet unter Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.

Nächste Schritte für eine bequemere Programmierbarkeit

Die vorherigen Beispiele aktivieren die sekundäre CPU und führen eine grundlegende Speichersynchronisierung mit dedizierten Anweisungen durch. Dies ist ein guter Anfang.

Um die Programmierung von Multicore-Systemen wie POSIX zu vereinfachen pthreads, müssten Sie sich jedoch auch mit den folgenden Themen befassen:

  • Setup unterbricht und führt einen Timer aus, der regelmäßig entscheidet, welcher Thread jetzt ausgeführt wird. Dies wird als präventives Multithreading bezeichnet .

    Ein solches System muss auch Thread-Register speichern und wiederherstellen, wenn sie gestartet und gestoppt werden.

    Es ist auch möglich, nicht präemptive Multitasking-Systeme zu haben. Möglicherweise müssen Sie jedoch Ihren Code so ändern, dass jeder Thread (z. B. bei einer pthread_yieldImplementierung) nachgibt , und es wird schwieriger, die Arbeitslast auszugleichen.

    Hier sind einige vereinfachte Beispiele für Bare-Metal-Timer:

  • mit Gedächtniskonflikten umgehen. Insbesondere benötigt jeder Thread einen eindeutigen Stapel, wenn Sie in C oder anderen Hochsprachen codieren möchten.

    Sie könnten Threads einfach auf eine feste maximale Stapelgröße beschränken, aber der schönere Weg, damit umzugehen, ist das Paging, das effiziente Stapel mit "unbegrenzter Größe" ermöglicht.

    Hier ist ein naives aarch64-Baremetall-Beispiel, das explodieren würde, wenn der Stapel zu tief wächst

Das sind einige gute Gründe, den Linux-Kernel oder ein anderes Betriebssystem zu verwenden :-)

Grundelemente für die Userland-Speichersynchronisation

Obwohl das Starten / Stoppen / Verwalten von Threads im Allgemeinen außerhalb des Bereichs des Benutzerlandes liegt, können Sie Assembly-Anweisungen von Userland-Threads verwenden, um Speicherzugriffe ohne potenziell teurere Systemaufrufe zu synchronisieren.

Sie sollten natürlich lieber Bibliotheken verwenden, die diese Grundelemente auf niedriger Ebene portabel umschließen. Der C ++ - Standard selbst hat große Fortschritte bei den <mutex>und <atomic>-Headern und insbesondere bei gemacht std::memory_order. Ich bin mir nicht sicher, ob es alle möglichen erreichbaren Speichersemantiken abdeckt, aber es könnte sein.

Die subtilere Semantik ist besonders relevant im Zusammenhang mit sperrenfreien Datenstrukturen , die in bestimmten Fällen Leistungsvorteile bieten können. Um diese zu implementieren, müssen Sie wahrscheinlich etwas über die verschiedenen Arten von Speicherbarrieren lernen: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/

Boost bietet beispielsweise einige sperrenfreie Container-Implementierungen unter: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html

Solche Benutzerlandanweisungen scheinen auch verwendet zu werden, um den Linux- futexSystemaufruf zu implementieren , der eines der Hauptsynchronisationsprimitive in Linux ist. man futex4.15 lautet:

Der Systemaufruf futex () bietet eine Methode zum Warten, bis eine bestimmte Bedingung erfüllt ist. Es wird normalerweise als blockierendes Konstrukt im Kontext der Synchronisation mit gemeinsamem Speicher verwendet. Bei Verwendung von Futexen werden die meisten Synchronisationsvorgänge im Benutzerbereich ausgeführt. Ein User-Space-Programm verwendet den Systemaufruf futex () nur dann, wenn es wahrscheinlich ist, dass das Programm länger blockieren muss, bis die Bedingung erfüllt ist. Andere futex () -Operationen können verwendet werden, um Prozesse oder Threads zu aktivieren, die auf eine bestimmte Bedingung warten.

Der Syscall-Name selbst bedeutet "Fast Userspace XXX".

Hier ist ein minimal nutzloses C ++ x86_64 / aarch64-Beispiel mit Inline-Assembly, das die grundlegende Verwendung solcher Anweisungen hauptsächlich zum Spaß veranschaulicht:

main.cpp

#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>

std::atomic_ulong my_atomic_ulong(0);
unsigned long my_non_atomic_ulong = 0;
#if defined(__x86_64__) || defined(__aarch64__)
unsigned long my_arch_atomic_ulong = 0;
unsigned long my_arch_non_atomic_ulong = 0;
#endif
size_t niters;

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
        my_atomic_ulong++;
        my_non_atomic_ulong++;
#if defined(__x86_64__)
        __asm__ __volatile__ (
            "incq %0;"
            : "+m" (my_arch_non_atomic_ulong)
            :
            :
        );
        // https://github.com/cirosantilli/linux-kernel-module-cheat#x86-lock-prefix
        __asm__ __volatile__ (
            "lock;"
            "incq %0;"
            : "+m" (my_arch_atomic_ulong)
            :
            :
        );
#elif defined(__aarch64__)
        __asm__ __volatile__ (
            "add %0, %0, 1;"
            : "+r" (my_arch_non_atomic_ulong)
            :
            :
        );
        // https://github.com/cirosantilli/linux-kernel-module-cheat#arm-lse
        __asm__ __volatile__ (
            "ldadd %[inc], xzr, [%[addr]];"
            : "=m" (my_arch_atomic_ulong)
            : [inc] "r" (1),
              [addr] "r" (&my_arch_atomic_ulong)
            :
        );
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10000;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    assert(my_atomic_ulong.load() == nthreads * niters);
    // We can also use the atomics direclty through `operator T` conversion.
    assert(my_atomic_ulong == my_atomic_ulong.load());
    std::cout << "my_non_atomic_ulong " << my_non_atomic_ulong << std::endl;
#if defined(__x86_64__) || defined(__aarch64__)
    assert(my_arch_atomic_ulong == nthreads * niters);
    std::cout << "my_arch_non_atomic_ulong " << my_arch_non_atomic_ulong << std::endl;
#endif
}

GitHub stromaufwärts .

Mögliche Ausgabe:

my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267

Daraus sehen wir, dass der x86 LOCK-Präfix / aarch64- LDADDBefehl die Addition atomar gemacht hat: Ohne sie haben wir bei vielen der Adds Race-Bedingungen, und die Gesamtzahl am Ende ist geringer als die synchronisierten 20000.

Siehe auch:

Getestet in Ubuntu 19.04 amd64 und mit QEMU aarch64 Benutzermodus.


Mit welchem ​​Assembler kompilieren Sie Ihr Beispiel? GAS scheint Ihre nicht zu mögen #include(nimmt es als Kommentar), NASM, FASM, YASM kennen die AT & T-Syntax nicht, also können es nicht sie sein ... also was ist das?
Ruslan

@ Ruslan gcc, #includekommt vom C-Präprozessor. Verwenden Sie die Makefileim Abschnitt Erste Schritte erläuterten Informationen : github.com/cirosantilli/x86-bare-metal-examples/blob/… Wenn dies nicht funktioniert, öffnen Sie ein GitHub-Problem.
Ciro Santilli 法轮功 冠状 病 六四 事件 法轮功

Was passiert unter x86, wenn ein Kern feststellt, dass keine Prozesse mehr in der Warteschlange ausgeführt werden können? (was bei einem inaktiven System von Zeit zu Zeit vorkommen kann). Strukturiert der Kern die gemeinsam genutzte Speicherstruktur, bis eine neue Aufgabe vorliegt? (wahrscheinlich nicht gut, es wird viel Strom verbrauchen) Ruft es so etwas wie HLT zum Schlafen auf, bis es einen Interrupt gibt? (In diesem Fall, wer ist dafür verantwortlich, diesen Kern aufzuwecken?)
Tigrou

@tigrou nicht sicher, aber ich finde es äußerst wahrscheinlich, dass die Linux-Implementierung es bis zum nächsten Interrupt (wahrscheinlich Timer) in einen Energiezustand versetzt, insbesondere auf ARM, wo die Stromversorgung der Schlüssel ist. Ich würde schnell versuchen zu sehen, ob dies mit einer Anweisungsspur eines Simulators unter Linux konkret leicht zu beobachten ist. Es könnte sein: github.com/cirosantilli/linux-kernel-module-cheat/tree/…
Ciro Santilli 郝海东 冠状 病六四 事件 法轮功

1
Einige Informationen (spezifisch für x86 / Windows) finden Sie hier (siehe "Leerlauf-Thread"). TL; DR: Wenn auf einer CPU kein ausführbarer Thread vorhanden ist, wird die CPU an einen inaktiven Thread gesendet. Zusammen mit einigen anderen Aufgaben wird letztendlich die Leerlaufroutine des registrierten Energieverwaltungsprozessors aufgerufen (über einen vom CPU-Hersteller bereitgestellten Treiber, z. B. Intel). Dies könnte die CPU in einen tieferen C-Zustand (z. B. C0 -> C3) überführen, um den Stromverbrauch zu verringern.
Tigrou

43

Nach meinem Verständnis ist jeder "Kern" ein vollständiger Prozessor mit einem eigenen Registersatz. Grundsätzlich startet das BIOS Sie mit einem laufenden Kern, und dann kann das Betriebssystem andere Kerne "starten", indem es sie initialisiert und auf den auszuführenden Code usw. zeigt.

Die Synchronisierung erfolgt durch das Betriebssystem. Im Allgemeinen führt jeder Prozessor einen anderen Prozess für das Betriebssystem aus. Daher entscheidet die Multithreading-Funktionalität des Betriebssystems, welcher Prozess welchen Speicher berührt und was im Falle einer Speicherkollision zu tun ist.


28
Was wirft jedoch die Frage auf: Welche Anweisungen stehen dem Betriebssystem zur Verfügung, um dies zu tun?
Paul Hollingsworth

4
Es gibt eine Reihe von privilegierten Anweisungen dafür, aber es ist das Problem des Betriebssystems, nicht der Anwendungscode. Wenn Anwendungscode Multithreading sein soll, muss er Betriebssystemfunktionen aufrufen, um die "Magie" auszuführen.
Scharfzahn

2
Das BIOS ermittelt normalerweise, wie viele Kerne verfügbar sind, und gibt diese Informationen auf Anfrage an das Betriebssystem weiter. Es gibt Standards, denen das BIOS (und die Hardware) entsprechen müssen, damit der Zugriff auf Hardwarespezifikationen (Prozessoren, Kerne, PCI-Bus, PCI-Karten, Maus, Tastatur, Grafik, ISA, PCI-E / X, Speicher usw.) für verschiedene PCs möglich ist sieht aus Sicht des Betriebssystems gleich aus. Wenn das BIOS nicht meldet, dass vier Kerne vorhanden sind, geht das Betriebssystem normalerweise davon aus, dass nur einer vorhanden ist. Möglicherweise gibt es sogar eine BIOS-Einstellung, mit der Sie experimentieren können.
Olof Forshell

1
Das ist cool und alles andere als was, wenn Sie ein Bare-Metal-Programm schreiben?
Alexander Ryan Baggett

3
@ AlexanderRyanBaggett ,? Was ist das überhaupt? Wenn wir noch einmal sagen "Überlassen Sie es dem Betriebssystem", vermeiden wir die Frage, weil die Frage ist, wie das Betriebssystem es dann macht. Welche Montageanleitung verwendet es?
Pacerier

39

Die inoffiziellen SMP-FAQ Stapelüberlauf-Logo


Es war einmal, als Sie zum Schreiben eines x86-Assemblers Anweisungen hatten, die besagten: "Laden Sie das EDX-Register mit dem Wert 5", "Erhöhen Sie das EDX-Register" usw. Bei modernen CPUs mit 4 Kernen (oder sogar mehr) sieht es auf der Ebene des Maschinencodes nur so aus, als gäbe es 4 separate CPUs (dh gibt es nur 4 verschiedene "EDX" -Register)?

Genau. Es gibt 4 Registersätze, einschließlich 4 separater Befehlszeiger.

Wenn ja, wenn Sie "Inkrementieren des EDX-Registers" sagen, was bestimmt, welches EDX-Register der CPU inkrementiert wird?

Die CPU, die diese Anweisung ausgeführt hat, natürlich. Stellen Sie sich 4 völlig unterschiedliche Mikroprozessoren vor, die sich einfach den gleichen Speicher teilen.

Gibt es jetzt im x86-Assembler ein "CPU-Kontext" - oder "Thread" -Konzept?

Nein. Der Assembler übersetzt nur Anweisungen wie immer. Keine Änderungen dort.

Wie funktioniert die Kommunikation / Synchronisation zwischen den Kernen?

Da sie denselben Speicher gemeinsam nutzen, ist dies hauptsächlich eine Frage der Programmlogik. Obwohl es jetzt einen Interprozessor-Interrupt- Mechanismus gibt, ist dieser nicht erforderlich und war ursprünglich in den ersten Dual-CPU-x86-Systemen nicht vorhanden.

Wenn Sie ein Betriebssystem geschrieben haben, welcher Mechanismus wird über Hardware verfügbar gemacht, damit Sie die Ausführung auf verschiedenen Kernen planen können?

Der Scheduler ändert sich tatsächlich nicht, außer dass er kritische Abschnitte und die Arten der verwendeten Sperren etwas sorgfältiger behandelt. Vor SMP würde der Kernelcode schließlich den Scheduler aufrufen, der die Ausführungswarteschlange überprüft und einen Prozess auswählt, der als nächster Thread ausgeführt werden soll. (Prozesse für den Kernel ähneln Threads.) Der SMP-Kernel führt exakt denselben Code aus, einen Thread nach dem anderen. Jetzt muss die Sperrung kritischer Abschnitte SMP-sicher sein, um sicherzustellen, dass zwei Kerne nicht versehentlich ausgewählt werden können die gleiche PID.

Handelt es sich um besonders privilegierte Anweisungen?

Nein. Die Kerne laufen nur alle im selben Speicher mit denselben alten Anweisungen.

Wenn Sie eine optimierende Compiler- / Bytecode-VM für eine Multicore-CPU schreiben würden, was müssten Sie beispielsweise speziell über x86 wissen, damit Code generiert wird, der auf allen Kernen effizient ausgeführt wird?

Sie führen den gleichen Code wie zuvor aus. Es ist der Unix- oder Windows-Kernel, der geändert werden musste.

Sie können meine Frage wie folgt zusammenfassen: "Welche Änderungen wurden am x86-Maschinencode vorgenommen, um die Multi-Core-Funktionalität zu unterstützen?"

Nichts war notwendig. Die ersten SMP-Systeme verwendeten genau den gleichen Befehlssatz wie Uniprozessoren. Jetzt gab es eine große Entwicklung der x86-Architektur und unzählige neue Anweisungen, um die Dinge schneller zu machen, aber für SMP waren keine erforderlich .

Weitere Informationen finden Sie in der Intel Multiprocessor-Spezifikation .


Update: Alle nachfolgenden Fragen können beantwortet werden, indem einfach vollständig akzeptiert wird, dass eine n- Wege-Multicore-CPU fast 1 genau dasselbe ist wie n separate Prozessoren, die sich nur denselben Speicher teilen. 2 Es wurde eine wichtige Frage nicht gestellt: Wie wird ein Programm geschrieben, das auf mehr als einem Kern ausgeführt wird, um mehr Leistung zu erzielen? Und die Antwort lautet: Es wird mit einer Thread-Bibliothek wie Pthreads geschrieben. Einige Thread-Bibliotheken verwenden "grüne Threads", die für das Betriebssystem nicht sichtbar sind, und diese erhalten keine separaten Kerne. Solange die Thread-Bibliothek Kernel-Thread-Funktionen verwendet, ist Ihr Thread-Programm automatisch mehrkernig.
1. Aus Gründen der Abwärtskompatibilität wird beim Zurücksetzen nur der erste Kern gestartet, und es müssen einige Dinge vom Typ Treiber ausgeführt werden, um die verbleibenden zu starten.
2. Sie teilen sich natürlich auch alle Peripheriegeräte.


3
Ich denke immer, dass "Thread" ein Softwarekonzept ist, das es mir schwer macht, Multi-Core-Prozessoren zu verstehen. Das Problem ist, wie können Codes einem Core sagen, dass ich einen Thread erstellen werde, der in Core 2 ausgeführt wird. Gibt es dafür einen speziellen Assembler-Code?
Demonguy

2
@demonguy: Nein, es gibt keine spezielle Anweisung für so etwas. Sie fordern das Betriebssystem auf, Ihren Thread auf einem bestimmten Kern auszuführen, indem Sie eine Affinitätsmaske festlegen (die besagt, dass "dieser Thread auf diesem Satz logischer Kerne ausgeführt werden kann"). Es ist komplett ein Softwareproblem. Auf jedem CPU-Kern (Hardware-Thread) wird unabhängig Linux (oder Windows) ausgeführt. Um mit den anderen Hardware-Threads zusammenzuarbeiten, verwenden sie gemeinsam genutzte Datenstrukturen. Sie starten jedoch niemals "direkt" einen Thread auf einer anderen CPU. Sie teilen dem Betriebssystem mit, dass Sie einen neuen Thread haben möchten, und es macht eine Notiz in einer Datenstruktur, die das Betriebssystem auf einem anderen Kern sieht.
Peter Cordes

2
Ich kann es sagen, aber wie kann man Codes auf einen bestimmten Kern setzen?
Demonguy

4
@demonguy ... (vereinfacht) ... jeder Kern teilt das Betriebssystem-Image und startet es an derselben Stelle. Für 8 Kerne sind das also 8 "Hardwareprozesse", die im Kernel ausgeführt werden. Jeder ruft dieselbe Scheduler-Funktion auf, die die Prozesstabelle auf einen ausführbaren Prozess oder Thread überprüft. (Das ist die Ausführungswarteschlange. ) In der Zwischenzeit funktionieren Programme mit Threads ohne Kenntnis der zugrunde liegenden SMP-Natur. Sie gabeln einfach (2) oder so und lassen den Kernel wissen, dass sie laufen wollen. Im Wesentlichen findet der Kern den Prozess und nicht der Prozess, der den Kern findet.
DigitalRoss

1
Sie müssen nicht wirklich einen Kern von einem anderen unterbrechen. Stellen Sie sich das so vor: Alles, was Sie vorher für die Kommunikation brauchten, wurde mit Softwaremechanismen einwandfrei kommuniziert. Die gleichen Softwaremechanismen funktionieren weiterhin. Also, Pipes, Kernelaufrufe, Sleep / Wakeup, all das Zeug ... sie funktionieren immer noch wie zuvor. Nicht jeder Prozess läuft auf derselben CPU, aber sie haben dieselben Datenstrukturen für die Kommunikation wie zuvor. Der Aufwand für SMP beschränkt sich hauptsächlich darauf, dass die alten Sperren in einer paralleleren Umgebung funktionieren.
DigitalRoss

10

Wenn Sie eine optimierende Compiler- / Bytecode-VM für eine Multicore-CPU schreiben würden, was müssten Sie beispielsweise speziell über x86 wissen, damit Code generiert wird, der auf allen Kernen effizient ausgeführt wird?

Als jemand, der optimierende Compiler- / Bytecode-VMs schreibt, kann ich Ihnen hier möglicherweise helfen.

Sie müssen nichts spezielles über x86 wissen, damit Code generiert wird, der auf allen Kernen effizient ausgeführt wird.

Möglicherweise müssen Sie jedoch über cmpxchg und Freunde Bescheid wissen, um Code zu schreiben, der auf allen Kernen korrekt ausgeführt wird . Multicore-Programmierung erfordert die Verwendung von Synchronisation und Kommunikation zwischen Ausführungsthreads.

Möglicherweise müssen Sie etwas über x86 wissen, damit Code generiert wird, der auf x86 im Allgemeinen effizient ausgeführt wird.

Es gibt noch andere Dinge, die Sie lernen sollten:

Sie sollten sich mit den Funktionen des Betriebssystems (Linux oder Windows oder OSX) vertraut machen, mit denen Sie mehrere Threads ausführen können. Sie sollten sich mit Parallelisierungs-APIs wie OpenMP und Threading Building Blocks oder OSX 10.6 "Snow Leopard", dem kommenden "Grand Central", vertraut machen.

Sie sollten überlegen, ob Ihr Compiler automatisch parallelisiert werden soll oder ob der Autor der von Ihrem Compiler kompilierten Anwendungen seinem Programm spezielle Syntax- oder API-Aufrufe hinzufügen muss, um die mehreren Kerne nutzen zu können.


Haben nicht mehrere beliebte VMs wie .NET und Java das Problem, dass ihr Haupt-GC-Prozess in Sperren und grundsätzlich Singlethreads behandelt wird?
Marco van de Voort

9

Jeder Core wird aus einem anderen Speicherbereich ausgeführt. Ihr Betriebssystem zeigt einen Kern auf Ihr Programm und der Kern führt Ihr Programm aus. Ihr Programm wird nicht wissen, dass es mehr als einen Kern gibt oder auf welchem ​​Kern es ausgeführt wird.

Es gibt auch keine zusätzlichen Anweisungen, die nur dem Betriebssystem zur Verfügung stehen. Diese Kerne sind identisch mit Single-Core-Chips. Auf jedem Core wird ein Teil des Betriebssystems ausgeführt, der die Kommunikation mit gemeinsamen Speicherbereichen übernimmt, die für den Informationsaustausch verwendet werden, um den nächsten auszuführenden Speicherbereich zu finden.

Dies ist eine Vereinfachung, gibt Ihnen jedoch eine grundlegende Vorstellung davon, wie es gemacht wird. Mehr über Multicores und Multiprozessoren auf Embedded.com bietet viele Informationen zu diesem Thema ... Dieses Thema wird sehr schnell kompliziert!


Ich denke, man sollte hier etwas genauer unterscheiden, wie Multicore im Allgemeinen funktioniert und wie stark das Betriebssystem beeinflusst. "Jeder Kern wird aus einem anderen Speicherbereich ausgeführt" ist meiner Meinung nach zu irreführend. In erster Linie erfordert die Verwendung mehrerer Kerne in Prinzipien dies nicht, und Sie können leicht erkennen, dass Sie für ein Thread-Programm zwei Kerne WOLLEN, zwei arbeiten an demselben Text- und Datensegment (während jeder Kern auch individuelle Ressourcen wie Stapel benötigt). .
Volker Stolz

@ShiDoiSi Deshalb enthält meine Antwort den Text "Dies ist eine Vereinfachung" .
Gerhard

5

Der Assemblycode wird in Maschinencode übersetzt, der auf einem Kern ausgeführt wird. Wenn Sie möchten, dass es Multithread-fähig ist, müssen Sie Betriebssystemprimitive verwenden, um diesen Code auf verschiedenen Prozessoren mehrmals oder verschiedene Codeteile auf verschiedenen Kernen zu starten. Jeder Kern führt einen separaten Thread aus. Jeder Thread sieht nur einen Kern, auf dem er gerade ausgeführt wird.


4
Ich wollte so etwas sagen, aber wie ordnet das Betriebssystem dann Kernen Threads zu? Ich stelle mir vor, dass es einige privilegierte Montageanweisungen gibt, die dies erreichen. Wenn ja, denke ich, ist dies die Antwort, nach der der Autor sucht.
A. Levy

Dafür gibt es keine Anweisung, das ist die Pflicht des Betriebssystem-Schedulers. Es gibt Betriebssystemfunktionen wie SetThreadAffinityMask in Win32, und der Code kann sie aufrufen, aber es handelt sich um Betriebssystemfunktionen, die sich auf den Scheduler auswirken. Es handelt sich nicht um eine Prozessoranweisung.
Scharfzahn

2
Es muss einen OpCode geben, sonst kann das Betriebssystem dies auch nicht.
Matthew Whited

1
Nicht wirklich ein Opcode für die Planung - es ist eher so, als würden Sie eine Kopie des Betriebssystems pro Prozessor erhalten und sich einen Speicherplatz teilen. Wenn ein Kern erneut in den Kernel eintritt (Syscall oder Interrupt), überprüft er dieselben Datenstrukturen im Speicher, um zu entscheiden, welcher Thread als Nächstes ausgeführt werden soll.
pjc50

1
@ A.Levy: Wenn Sie einen Thread mit einer Affinität starten, die ihn nur auf einem anderen Kern ausführen lässt, wird er nicht sofort auf den anderen Kern verschoben. Der Kontext wird wie bei einem normalen Kontextwechsel im Speicher gespeichert. Die anderen Hardware-Threads sehen ihren Eintrag in den Scheduler-Datenstrukturen, und einer von ihnen entscheidet schließlich, dass der Thread ausgeführt wird. Aus der Sicht des ersten Kerns: Sie schreiben in eine gemeinsam genutzte Datenstruktur, und schließlich wird der Betriebssystemcode auf einem anderen Kern (Hardware-Thread) dies bemerken und ausführen.
Peter Cordes

3

Es wird überhaupt nicht in Maschinenanweisungen gemacht; Die Kerne geben vor, unterschiedliche CPUs zu sein, und haben keine besonderen Funktionen, um miteinander zu kommunizieren. Sie kommunizieren auf zwei Arten:

  • Sie teilen sich den physischen Adressraum. Die Hardware übernimmt die Cache-Kohärenz, sodass eine CPU in eine Speicheradresse schreibt, die eine andere liest.

  • Sie teilen sich einen APIC (Programmable Interrupt Controller). Dies ist ein Speicher, der dem physischen Adressraum zugeordnet ist und von einem Prozessor verwendet werden kann, um die anderen zu steuern, sie ein- oder auszuschalten, Interrupts zu senden usw.

http://www.cheesecake.org/sac/smp.html ist eine gute Referenz mit einer dummen URL.


2
Sie teilen tatsächlich keinen APIC. Jede logische CPU hat eine eigene. Die APICs kommunizieren untereinander, sind jedoch getrennt.
Nathan Fellman

Sie synchronisieren (anstatt zu kommunizieren) auf eine grundlegende Weise, und zwar über das LOCK-Präfix (der Befehl "xchg mem, reg" enthält eine implizite Sperranforderung), das zum Sperrstift läuft, der zu allen Bussen läuft und ihnen effektiv mitteilt, dass die CPU (eigentlich jedes Bus-Mastering-Gerät) möchte exklusiven Zugriff auf den Bus. Schließlich kehrt ein Signal zum LOCKA-Pin (Acknowledge) zurück und teilt der CPU mit, dass sie jetzt exklusiven Zugriff auf den Bus hat. Da externe Geräte viel langsamer sind als die internen Funktionen der CPU, kann eine LOCK / LOCKA-Sequenz viele hundert CPU-Zyklen erfordern.
Olof Forshell

1

Der Hauptunterschied zwischen einer Single- und einer Multithread-Anwendung besteht darin, dass die erstere einen Stapel und die letztere einen für jeden Thread hat. Code wird etwas anders generiert, da der Compiler davon ausgeht, dass die Daten- und Stapelsegmentregister (ds und ss) nicht gleich sind. Dies bedeutet, dass die Indirektion durch die ebp- und esp-Register, die standardmäßig das ss-Register verwenden, nicht auch standardmäßig ds ist (weil ds! = SS). Umgekehrt wird die Indirektion durch die anderen Register, die standardmäßig ds verwenden, nicht standardmäßig ss.

Die Threads teilen alles andere, einschließlich Daten- und Codebereiche. Sie teilen auch lib-Routinen, stellen Sie also sicher, dass sie threadsicher sind. Eine Prozedur, die einen Bereich im RAM sortiert, kann mit mehreren Threads versehen werden, um die Arbeit zu beschleunigen. Die Threads greifen dann auf Daten in demselben physischen Speicherbereich zu, vergleichen sie und ordnen sie an und führen denselben Code aus, verwenden jedoch unterschiedliche lokale Variablen, um ihren jeweiligen Teil der Sortierung zu steuern. Dies liegt natürlich daran, dass die Threads unterschiedliche Stapel haben, in denen die lokalen Variablen enthalten sind. Diese Art der Programmierung erfordert eine sorgfältige Abstimmung des Codes, damit die Kollisionen zwischen den Kerndaten (in Caches und RAM) reduziert werden, was wiederum zu einem Code führt, der mit zwei oder mehr Threads schneller ist als mit nur einem. Natürlich ist ein nicht abgestimmter Code mit einem Prozessor oft schneller als mit zwei oder mehr. Das Debuggen ist schwieriger, da der Standard-Haltepunkt "int 3" nicht anwendbar ist, da Sie einen bestimmten Thread und nicht alle unterbrechen möchten. Debug-Register-Haltepunkte lösen dieses Problem auch nicht, es sei denn, Sie können sie auf dem bestimmten Prozessor festlegen, der den bestimmten Thread ausführt, den Sie unterbrechen möchten.

Bei anderen Multithread-Codes können unterschiedliche Threads in verschiedenen Teilen des Programms ausgeführt werden. Diese Art der Programmierung erfordert nicht die gleiche Art der Abstimmung und ist daher viel einfacher zu erlernen.


0

Was zu jeder Multiprozessor-fähigen Architektur im Vergleich zu den vorangegangenen Einzelprozessor-Varianten hinzugefügt wurde, sind Anweisungen zum Synchronisieren zwischen Kernen. Außerdem haben Sie Anweisungen zum Umgang mit Cache-Kohärenz, Leeren von Puffern und ähnlichen Operationen auf niedriger Ebene, mit denen sich ein Betriebssystem befassen muss. Bei gleichzeitigen Multithread-Architekturen wie IBM POWER6, IBM Cell, Sun Niagara und Intel "Hyperthreading" werden häufig neue Anweisungen zum Priorisieren zwischen Threads angezeigt (z. B. Festlegen von Prioritäten und explizites Ausgeben des Prozessors, wenn nichts zu tun ist). .

Die grundlegende Single-Thread-Semantik ist jedoch dieselbe. Sie fügen lediglich zusätzliche Funktionen für die Synchronisierung und Kommunikation mit anderen Kernen hinzu.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.