跳转至内容

分布式系统

25% developed
来自维基教科书,开放世界中的开放书籍

内存模型

[编辑 | 编辑源代码]

每个 Unix 进程都有一个地址空间,看起来像这样

global vars—text segment—heap—stack

请记住,低内存位于顶部,高内存位于底部。因此,当项目添加到堆栈中时,它向上增长,每个元素都具有较低的内存地址。

Unix 进程

[编辑 | 编辑源代码]

fork() 是你的朋友

[编辑 | 编辑源代码]

您应该知道它做了什么

main()
{
    int i, cpid;
    cpid = fork();
    if (cpid < 0) exit(1);
    else if (cpid == 0) {
        /* this is the child process. */
        for (i=0; i<1000; i++) printf("child: %d\n", i);
        exit(0);
    }
    else {
        /* this is parent process */
        for (i=0; i<1000; i++) printf("parent: %d\n", i);
        waitpid(cpid);
    }
return 0;
}

调用 fork 创建一个新的进程,该进程具有一个全新的地址空间。这意味着对所有内容都进行了复制:新的全局变量、新的文本代码、新的堆和新的堆栈。即使使用 malloc 动态分配的对象也会被复制。请记住,这些动态分配对象的指针是根据相对寻址设置的,因此即使新复制的内存分配到其他地方,指向堆中的指针仍然有效。

进程状态

[编辑 | 编辑源代码]

四个最流行的 UNIX 进程状态是就绪、等待、运行和僵尸。前三个很容易理解。僵尸进程是已完成的子进程,它们正在等待其父进程清理它们。通常,父进程通过调用 wait 或 waitpid 来清理已完成的(僵尸)子进程。

如果父进程在子进程完成之前死亡,最终根进程将出现并清理它们。

简单示例

[编辑 | 编辑源代码]

因此,在 fork() 之后,如果每个进程都有自己的内存空间,如何设置通信?使用管道!每个进程可能有自己的文件描述符表,但它们仍然引用相同的文件。

示例管道代码,其中子进程读取,父进程写入

注意:随意添加所有这些错误检查,但尽量让重点显而易见

int p[2];
pipe(p); /* call by address. */
cpid = fork();
if (cpid < 0) exit(1);

/* child process */
if (cpid == 0) {
    close(p[1]); /* child won't need to write, so close. */
    read(p[0], buf, len);
    close(p[0]);
    _exit(0); /* read man 3 exit and man _exit to understand the difference. */
}

/* parent */
else {
    close(p[0]); /* parents can be so close-minded! */
    write(p[1], buf, len);
    close(p[1]);
    waitpid(cpid);
}

学习并理解上面的代码。

使用管道实现非常简单的 shell

[编辑 | 编辑源代码]

我们都在命令行中做过类似的事情

$ ls -l | sort | head

但它是如何工作的?在 MS-DOS 中,这个功能的实现有点像这样

ls -l > /tmpfile1
sort /tmpfile1 > /tmpfile2
head /tmpfile2

这三个任务是顺序执行的,而不是同时执行的。如果第一个程序的输出很大,这很糟糕,而且这种设置肯定无法处理无限的数据流。

您可以在 C 中实现 ls -l | sort | head 之类的东西。为了便于管理,以下代码实现了 "ls | wc -l"

int pipes[2];
pipe(pipes);

int pid = fork();
if(pid < 0) /* Check for error */
{
    fprintf(stderr, "fork unsuccessful.");
    exit(1);
}
if(0 == pid) /* We are the child */
{
    /*
     * We don't need the writing end of the pipe.  This closes it.
     */
    close(pipes[1]);
    
    /*
     * Duplicate our output pipe to file descriptor 0.
     * This means that anything that would normally be read from stdin
     * is now read from the pipe.
     */
    dup2(pipes[0], 0);
    
    /*
     * We've duplicated the pipe, so there is no need to leave this copy
     * hanging around.
     */
    close(pipes[0]);
    
    /*
     * Execute the "wc -l" command.  This replaces our process.
     */
    execlp("wc", "wc", "-l", (char*)0);
}
else /* We must be the parent */
{
    /*
     * We don't need the reading end of the pipe.  This closes it.
     */
    close(pipes[0]);
    
    /*
     * This duplicates the pipe into stdout.  This means that standard
     * output is redirected into the pipe.
     */
    dup2(pipes[1], 1);
    
    /*
     * Since we've duplicated this end of the pipe, we won't need it
     * anymore.
     */
    close(pipes[1]);
    
    /*
     * We're done setting up.  This replaces us with the "ls" part of the
     * command.
     */
    execlp("ls", "ls", (char*)0);
}

待办事项:更多地讨论 execv 的工作原理。

使用管道实现 I/O 重定向

[编辑 | 编辑源代码]

另一个经典

ls -l > out.txt

你怎么能做到?

待办事项:完成

错误?

维基百科线程文章

线程共享内存,进程不共享。它们都允许程序同时(并发)执行多个操作。在 Linux 上,大多数时候,当人们谈论线程时,他们实际上指的是 POSIX 线程,即 pthreads。

简单的 pthread 示例

[编辑 | 编辑源代码]

示例 pthread 代码

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int i; /* i is global, so it is visible to all functions. */

static void *foo()
{
    int k;
    for (k=0; k<10; k++) {
        sleep(1);
        printf("thread foo: %d.\n", i++);
    }
    return NULL;
}

static void *bar()
{
    int k;
    for (k=0; k<10; k++) {
        sleep(1);
        printf("thread bar: %d.\n", i++);
    }
    return NULL;
}

int main() {
   int x;
   pthread_t t1, t2;
   x = pthread_create(&t1, NULL, foo, NULL);
   if (x != 0) printf("pthread foo failed.\n");
   x = pthread_create(&t2, NULL, bar, NULL);
   if (x != 0) printf("pthread bar failed.\n");
   pthread_join(t1, NULL);
   pthread_join(t2, NULL);
   printf("all pthreads finished.\n");
   return 0;
}

这是输出的一部分

$ ./pthreadfun 
thread foo: 0.
thread bar: 1.
thread foo: 2.
...
thread foo: 18.
thread bar: 19.
all pthreads finished.

希望主要观点能够显而易见。Foo 和 bar 交替递增全局整数 i。

重写上面的代码以使用 fork() 创建两个进程,而不是两个线程。然后找出 i 的最大值并解释与该线程版本的区别。

内核线程与用户线程

[编辑 | 编辑源代码]

pthreads 是内核空间线程,这意味着操作系统调度器知道多个线程。每个线程在操作系统调度表中都有一个条目。

还存在用户级线程,其中内核不知道线程。进程必须自己处理调度。用户级线程需要两级调度

OS scheduler handles all processes
Process handles all threads.

如果程序员不小心,一个用户级线程可以阻塞进程中的所有其他线程。如果一个用户级线程进行了一些阻塞系统调用,那么操作系统将暂停该进程,直到该系统调用返回。

用户线程的优点是允许在进程中比内核级线程更快地在线程之间切换。进程本身执行上下文切换。

Solaris 混合线程

[编辑 | 编辑源代码]

Sun Solaris 2.3 版本提供了混合线程,试图将用户级线程的速度与内核级线程的优势相结合。混合线程最大限度地减少了在内核级别在线程之间切换的操作系统调度成本,但它们也允许线程进行阻塞系统调用而不会阻塞整个进程。

该系统允许线程和轻量级进程。操作系统调度程序只看到轻量级进程。每个线程都附加到一个轻量级进程。线程可以共享一个进程,因此具有多个线程的一个应用程序可能在操作系统调度程序中显示为一个进程。

在两个线程都绑定到一个进程的程序中,如果线程 1 进行阻塞系统调用,则操作系统将阻塞该进程。同时,为了允许线程 2 继续运行,操作系统将创建一个新的进程,将线程 2 从第一个进程中分离,然后将其附加到新进程。

临界区和互斥锁

[编辑 | 编辑源代码]

如果线程要共享变量,你需要小心,不要让它们同时尝试修改内存。

考虑这段经典的 C 代码

i++;

这看起来像是一个操作(将 i 加一),但它在汇编代码中转换为三个操作

lda i;  load the value stored in var i into the eax register.
inc;    increment up the eax register.
sta i;  store the value in the eax register into the var i.

有关如何使用 gcc 将 C 程序转换为汇编程序的更多信息,请参阅本书中的 C 编程附录。

由于线程共享内存,因此一个线程可能开始执行某项操作,然后被操作系统调度程序挂起。

假设存在两个线程,i 是一个全局整数,最初设置为 99。每个线程都希望将 i 增加 1,因此每个线程都会执行上面三个汇编步骤。完成后,我们希望找到 i 设置为 101。

线程 1 首先开始

lda i;    eax register holds 99.
inc i;    now, eax holds 100.

现在,假设在这一点上,操作系统调度程序挂起了线程 1 并启动了线程 2

lda i;    load 99 from i into eax.  This is bad!!!
inc i;    increment eax to 100.
sta i;    store 100 into i.

然后,操作系统允许线程 1 完成

sta i;    store eax into i.  but eax only has 100!

所以现在你看到两个线程访问公共变量是如何有风险的。这个 bug 最糟糕的部分是它将是不一致的,难以重现。i++ 语句称为**临界区**,因为我们需要确保只有一个线程在该部分可以访问变量。

一种解决方案是在每个线程中使用某种锁,如下所示

    lock(ilock);
    i++;
    unlock(ilock);

然后,如果线程 1 被挂起,线程 2 启动,它将在 lock(ilock) 调用上阻塞。为了真正做到这一点,我们可以使用互斥锁。互斥锁就像一个只有 1 或 0 值的信号量。此代码中的线程将在尝试增加 i 之前锁定和解锁互斥锁,因此可以避免上述问题。

#include <stdio.h>
#include <pthread.h>

pthread_mutex_t ilock = PTHREAD_MUTEX_INITIALIZER;
int i;

static void *f1()
{
    pthread_mutex_lock(&ilock);
    i++;
    pthread_mutex_unlock(&ilock);
    return NULL;
}

static void *f2()
{
    pthread_mutex_lock(&ilock);
    i++;
    pthread_mutex_unlock(&ilock);
    return NULL;
}

int main()
{
    pthread_t t1, t2;
    int x;
    i = 99;
    x = pthread_create(&t1, NULL, f1, NULL);
    if (x != 0) printf("pthread foo failed.\n");
    x = pthread_create(&t2, NULL, f2, NULL);
    if (x != 0) printf("pthread bar failed.\n");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("all pthreads finished.\n");
    printf("i set to %d.\n", i);
    return 0;
}

就是这样!

解决此问题的另一种方法是使用真正原子的“增量”指令。(“锁定”和“解锁”函数使用这种原子指令)。此类原子函数(以及如何使用它们来避免数据损坏和死锁)在Wikipedia:无锁和无等待算法中进行了讨论。

生产者和消费者模型

[编辑 | 编辑源代码]

这个小妖精在现实世界中经常出现。厨师为餐厅制作了一碗碗汤。厨师必须等待餐厅要求供应汤,而餐厅必须等待厨师宣布汤已准备就绪。如果你不小心,你可能会遇到**死锁**,这就像它的名字一样糟糕。

死锁涉及两个进程(p1 和 p2)和两个资源(r1 和 r2)。每个进程都需要独占访问这两个资源才能完成。当 p1 持有 r1 并等待 r2 被释放时,p2 持有 r2 并等待 r1 时,就会发生死锁。如你所见,这两个进程都无法继续。

此伪代码说明了如何设置生产者/消费者系统并避免死锁

/*  

static soupbuffer buff;
semaphore moreplease, soupisready;

producerthread()
{
    lock(moreplease);       wait until the kids ask for more.
    cooksoup(buff);         refill the soup pot.
    unlock(soupisready);    announce that soup is ready.
}

consumerthread()
{
    lock(soupisready);      wait until the soup is ready.
    eatsoup(buff);          empty the soup pot.
    unlock(moreplease);     ask for more soup.
}
                                                                                                             
*/

共享内存

[编辑 | 编辑源代码]

System V 提供共享内存。一个进程可以将一些内存分配为共享内存,然后其他进程可以附加到该内存,这些进程可以进行通信。

一个进程可以使用 shmget 分配内存。然后,该进程可以使用 shmat 将指针指向该内存。

另一个进程可以使用 shmat 将指针附加到同一内存。

shmdt 将从共享内存中分离指针。

shmctl 可用于释放分配的内存。

你可以使用ipcs检查任何分配的共享内存,这是一种检查你的程序是否释放了所有分配的内存的好方法。ipcrm将删除分配的共享内存。

信号很酷。当你按下 Ctrl+C 来杀死一个进程时,实际上是向该进程发送 SIGINT。类似地,Ctrl+Z 发送 SIGTSTP,而kill -9 pid发送第 9 个信号 SIGKILL。手册页(man -s 7 signal)的第七节中列出了所有可用的信号。

信号处理程序

[编辑 | 编辑源代码]

当一个进程收到一个信号时,会调用相应的函数。在 SIGKILL 的情况下,该函数会导致代码退出。许多这些信号可以被捕获并重新分配给自定义函数,如下面的代码所示

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sighandler(void)
{
    printf("i am un-SIGINT-able!\n");
}

int main()
{
    signal(SIGINT, sighandler);
    perror("signal");
    while (1) {
        printf("sleeping...\n");
        sleep(1);
    }
    return 0;
}

编译并运行上面的程序,然后尝试使用 Ctrl+c 杀死它。现在,尝试使用kill -s SIGKILL pid杀死该程序。

当按下 Ctrl+c 时,进程会收到 SIGINT 信号;但是,该进程不会终止,而是会调用自定义函数sighandler。由于 SIGKILL 信号未被捕获,因此代码正常退出。

signal() 函数不允许你为 SIGKILL 写入处理程序。

在 HP-UX 中,信号到达后,信号处理程序会被忘记。因此,通常在信号处理程序内部,它会重新注册自身作为处理程序

void sighandler(void)
{
    printf("i am un-SIGINT-able!\n");
    signal(SIGINT, sighandler);
}

在 Linux 中,这没有必要。

SIGALRM 信号可以通过 alarm() 函数触发,因此它是程序内部生成信号的好方法。以下代码说明了这一点

signal(SIGALRM, onintr);
alarm(3);
while (1) ; /* run forever. */

void onintr()
{
    printf("3 second violation.\n");
    alarm(3);
}

该程序将一直运行,直到你杀死它。

信号和 setjmp/longjmp

[编辑 | 编辑源代码]

此代码在进程收到 SIGALARM 信号时调用 SIGALARM 处理程序,然后在处理程序内部,跳出处理程序而不返回。

jmp_buf env;
main()
{
    setjmp(env);
    signal(SIGALRM, onintr);
}
void onintr()
{
    printf("SIGALRM handler...\n");
    longjmp(env);
}

可能存在一个不明显的问题。在进程收到信号后,它会调用信号处理程序。如果在第一个信号仍在处理时又来了另一个信号,则该进程会忽略第二个信号。操作系统在处理程序运行时“阻塞”信号。

当处理程序完成时,作为其返回的一部分,操作系统允许进程接收新信号。

如果我们使用 longjmp 跳出函数,则信号处理程序将永远不会返回,并且进程将永远不会听到传入的任何相同类型的信号。但是,别担心!你可以手动**解除阻塞**信号,然后跳出。

此备用处理程序展示了如何

void handler()
{
    sigset_t set;
    sigempty(&set);
    sigaddset(&set, SIGALRM);
    sigprocmask(SIG_UNBLOCK, &set, NULL);
    printf("3 second\n");
    longjmp(env);
}

你可以使用 sigsetjmp 和 siglongjmp 函数跳出信号处理程序并选择性地重置信号掩码。下面的代码展示了一个例子。你必须使用 Ctrl-C 来结束程序

#include <stdio.h>
#include <signal.h>
#include <setjmp.h>
#include <unistd.h>

sigjmp_buf env;

void sh()
{
    printf("handler received signal.\n");
    siglongjmp(env, 99);
}

int main ()
{
    int k;
    k = sigsetjmp(env, 1);
    if (k == 0) {
        printf("setting alarm for 4 seconds in the future.\n");
        signal(SIGALRM, sh);
        alarm(4);
        while (1) {
            printf("sleeping...\n");
            sleep(1);
        }
    }
    else {
        printf("k: %d.\n", k);
        alarm(3);
        printf("set alarm for another 3 seconds in the future.\n");
        while (1) {
            printf("sleeping...\n");
            sleep(1);
        }
    }
    return 0;
}

信号和暂停

[编辑 | 编辑源代码]

如果你想等待一个事件,但不想使用某种自旋锁,你可以使用pause函数来挂起正在运行的进程,直到发生信号。

示例代码

#include <signal.h>
#include <unistd.h>
#include <stdio.h>

void sh()
{
    printf("Caught SIGALRM\n");
}

int main()
{
    signal(SIGALRM, sh);
    printf("Registered sh as a SIGALRM signal handler.\n"
           "BTW, sh is %d and &sh is %d.\n" , (int) sh, (int) &sh);
    alarm(3);
    printf("Alarm scheduled in three seconds.  Calling pause()...\n");
    printf("RAHUL IS IN WIKIPEDIA WEBSITE AT CHITKARA UNIVERSITY NEAR CHANDIGARH");
    pause();

    printf("pause() must have returned.\n");
    return 0;
}

还要注意,第二个打印语句将函数 sh 转换为整数,并将 sh 的地址也转换为整数。以下是程序的输出

$ ./pausefun
Registered sh as a SIGALRM signal handler.
BTW, sh is 134513700 and &sh is 134513700.
Alarm scheduled in three seconds.  Calling pause()...
Caught SIGALRM
pause() must have returned.

请注意,sh 转换为整数后的值与 sh 的地址相同。134513700 是 sh 函数文本部分的内存地址。尽管乍一看可能有点奇怪,但从操作系统的角度来看,函数实际上只是另一种数据。

消息传递

[编辑 | 编辑源代码]

消息传递不同于通过管道进行通信,因为接收方可以从消息队列中选择特定消息。使用管道时,接收方只是从前面获取一定数量的字节。

type 参数允许优先级调度。如果 msgrcv 的第 4 个参数 > 0,则系统将找到第一个类型等于指定第 4 个参数的类型。

如果类型 < 0,则系统将找到第一个类型低于类型的绝对值的类型。

如果我们有这些消息

Name:Type
A:400
B:200
C:300
D:200
E:100

那么类型 -250 将返回 B。

以下两个程序展示了消息传递。

mpi1.c

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define KEY1 9870
#define KEY2 7890
#define PERM 0666

typedef struct mymsgbuf
{
    int type;
    char msg[64];
} mtype;

int main()
{
    int msg1, msg2, i;
    mtype m1, m2;

    msg1 = msgget(KEY1, PERM | IPC_CREAT);
    msg2 = msgget(KEY2, PERM | IPC_CREAT);

    msgrcv(msg2, &m2, 64, 0, 0);

    printf("mpi1 received msg: \"%s\" from message queue %d.\n", m2.msg, msg2);

    m1.type = 20;
    strcpy(m1.msg, "I doubt");
    printf("mpi1 about to send \"%s\" to message queue %d.\n", m1.msg, msg1);
    i = msgsnd(msg1, &m1, 64, 0);
    printf("mpi1 sent \"%s\" to message queue %d with return code %d.\n", m1.msg, msg1, i);

    sleep(1);

    msgctl(msg1, IPC_RMID, (struct msqid_ds *) 0);
    msgctl(msg2, IPC_RMID, (struct msqid_ds *) 0);

    return 0;
}

mpi2.c

#define KEY1 9870
#define KEY2 7890
#define PERM 0666

typedef struct mymsgbuf
{
    int type;
    char msg[64];
} mtype;

int main()
{
    int msg1, msg2, i;
    mtype m1, m2;

    m1.type = 10;
    strcpy(m1.msg, "Indians will win...");

    /* attach to message queues. */
    msg1 = msgget(KEY1, PERM);
    msg2 = msgget(KEY2, PERM);
    if (msg1 == -1 || msg2 == -1) perror("mpi2 msgget");

    /* send message to mpi1. */
    printf("mpi2 about to send message \"%s\" to message queue %d.\n", m1.msg, msg2);
    msgsnd(msg2, &m1, 64, 0);

    /* send message to mpi2. */
    strcpy(m2.msg, "...");
    /*
    msgrcv(msg2, &m2, 64, 0, 0);
    */
    i = msgrcv(msg1, &m2, 64, 0, 0);
    if (i == -1) perror("mpi2 msgrcv");
    printf("mpi2 received msg: \"%s\" from message queue %d with return code %d.\n", m2.msg, msg1, i);

    /* cleanup. */
    msgctl(msg1, IPC_RMID, (struct msqid_ds *) 0);
    msgctl(msg2, IPC_RMID, (struct msqid_ds *) 0);

    return 0;
}

现在将 mpi1.c 编译为 mpi1,并将 mpi2.c 编译为 mpi2

$ gcc -o mpi1 mpi1.c
$ gcc -o mpi2 mpi2.c

现在在后台运行 mpi1,然后运行 mpi2

$ ./mpi1 &
[1] 29151
$ ./mpi2
mpi2 about to send message "Indians will win..." to message queue 2195457.
mpi1 received msg: "Indians will win..." from message queue 2195457.
mpi1 about to send "I doubt" to message queue 2162688.
mpi1 sent "I doubt" to message queue 2162688 with return code 0.
mpi2 received msg: "I doubt" from message queue 2162688 with return code 64.
[1]+  Done                    ./mpi1

关于堆栈的一切

[编辑 | 编辑源代码]

本节描述当一个函数调用另一个函数并传递几个参数时,堆栈和寄存器中发生了什么。

假设我们有以下 C 代码

void foo(x, y, z)
{
    int i, j;
}
int a, b, c;
a = 4;
b = 5;
c = 6;
foo(a, b, c);


在幕后,首先将 c 推入堆栈,然后是 b,然后是 a,最后是调用函数的返回地址。这就是堆栈的样子(最上面的内容是最新的推入对象)

 -8    j
 -4    i
  0  ebp
 +4   return address
 +8    a
+12    b
+16    c

段错误揭示了!

[编辑 | 编辑源代码]

我们都见过这个可爱的错误

$ ./try
Segmentation fault

大多数情况下,你会发现类似于这样的问题

char ss[3];
strcpy(ss, "this is a way too long string!\n");

在执行完以下命令后:char ss[3];堆栈可以将三个字符放入一个 4 字节的字中。

 -4          ; this is the space (4 bytes, 1 word) allocated for ss[3].
  0     ebp
 +4     r.a.

现在观察当 strcpy() 命令填充堆栈时会发生什么

-4      't', 'h', 'i' ,'s'   ; we can fit the first 4 chars here, but the rest spill over.
 0      ' ', 'i', 's', ' a'  ; ACK!  we just overwrote the old base pointer!
+4      ' ', 'w', 'a', 'y',  ; now we're doomed; when this function calls it will try to hop to
                             ; the return address stored here, but that has been overwritten.

因此,希望现在对段错误的含义更加清晰。你的程序试图跳转到进程内存之外的内存地址,而操作系统会阻止它。

OSI 参考模型

[编辑 | 编辑源代码]

7 层

  • 应用层
  • 表示层
  • 会话层
  • 传输层
  • 网络层
  • 数据链路层
  • 物理层

在 Unix 中,前三层(应用层、表示层和会话层)通常捆绑在一起。

假设网络看起来像这样

A---Router1---Router2---B.

如果 A 上的进程(应用程序)想要与 B 上的进程通信,就会发生以下情况

传输层添加了 A 的端口和 B 的 RPC 服务器端口。

网络层添加了 A 的 IP 地址和 B 的 IP 地址。

来自 A 的任何通信都必须先通过两个路由器。

数据链路层添加了 A 的 MAC 地址和路由器 1 的 MAC 地址。

物理层将帧转换为物理信号。

当消息被路由器 1 接收时,帧被读取并发送到 OSI 模型的底部。路由器 1 的 MAC 地址被删除并替换为路由器 2 的 MAC 地址。

UNIX 文件系统

[编辑 | 编辑源代码]

索引节点

[编辑 | 编辑源代码]

每个目录实际上都是一个文件。该文件列出了该目录中文件和子目录的名称。它将名称与索引节点关联起来。

每个索引节点包含以下数据

  • 用户 ID
  • 组 ID
  • 模式
  • 时间戳(分别跟踪访问时间、创建时间、修改时间)
  • 链接计数
  • 文件大小
  • 指向 10 个数据块的直接指针,这些数据块包含文件内容
  • 指向数据块的单级间接指针,该数据块指向包含文件内容的数据块
  • 指向数据块的双级间接指针...

由于每个索引节点有 10 个直接地址指针,如果文件足够小,可以在 10 个数据块或更少的块中容纳,那么索引节点可以使用直接寻址。

如果我们假设使用 4 个字节来指定磁盘地址,那么我们可以有 232 个不同的地址。如果我们将数据块大小设置为 4 kb,那么一个数据块可以容纳 1000 个地址。因此,使用单级间接寻址,我们可以指向使用 1000 个数据块的数据的文件。

因此,单级间接寻址允许的最大文件大小(假设 4kb 数据块和 4 字节地址)为 4 兆字节(1000 个数据块 * 每个数据块 4 kb)。

简单公式:数据块大小 / 磁盘地址 = 最大文件大小。

当然,在双级或三级(或四级、五级等)寻址中,你只需要将分析扩展到更远。

文件大小 使用的块
1 块 通过直接寻址使用 1 块。
11 块 使用 12 块:通过直接寻址使用 10 块数据,
1 块用于间接寻址,1 块用于数据。
150 块 通过直接寻址使用 10 块,
通过单级寻址使用 1 + 128 块,
1 + 1 + 12 1 双级间接 + 间接 + 12 个数据块

数据块大小(小 vs. 大)各有优缺点。许多小文件和大数据块会导致低利用率。例如,你的 .vimrc 文件可能只有 300 字节的数据,但它必须至少使用一个数据块,所以如果你的数据块大小为 4kb,那么就会浪费很多空间。

但是,小块会导致大文件散布在许多数据块中,因此硬盘读取器必须在所有地方跳跃来收集数据。大数据块可以减少碎片。

随着每个文件块数的增加,磁盘 IO 成本也会增加。

每个目录都有一个表,该表将文件名映射到索引节点号。一个文件可以有任意数量的与之关联的名称。每个索引节点都跟踪其链接计数。命令 rm foo.txt 编辑目录并删除将 foo.txt 映射到特定索引节点的条目。如果该索引节点没有链接,那么它将被删除。

此时,你可能想阅读 ln 的手册页以了解软链接和硬链接之间的区别。

与单体系统相比,分布式系统在今天发生故障的可能性要低得多,即使分布式系统在今天某一部分发生故障的可能性要高得多。高可用性分布式系统使用受 RAID(廉价磁盘冗余阵列)启发的技术,可以容忍任何一个部分的故障,而不会丢失数据或功能。

更多详细信息,请参阅

设置用户 ID 位

[编辑 | 编辑源代码]

设置用户 ID 位(有时简称为 setuidsuid)会改变操作系统处理权限的方式。例如,chsh 命令允许用户更改其登录 shell,例如从 bash 更改为 tcsh。每个用户的登录 shell 都存储在 /etc/passwd 中,但用户没有写入权限。那么用户如何运行 chsh 并更改该文件呢?答案是使用 suid 位。查看 chsh 的权限

$ ls -l `which chsh`
-rwsr-xr-x    1 root     root        28088 2004-11-02 16:51 /usr/bin/chsh

那么,其中的 s 是什么意思呢?我的朋友,这就是设置用户 ID 位,这意味着该程序运行时就像由 root 执行一样。

设置了 setuid 位(或类似的 setgid 位)的程序将使用程序拥有者的用户或组的权限运行。

远程过程调用

[编辑 | 编辑源代码]

远程过程调用 (RPC) 是一种隐藏在远程机器上运行进程的所有细节的方法。

如果 A 想要对主机 B 发出远程过程调用,那么 A 需要 B 的 RPC 服务器端口。

RPC 帧包括

  • 函数编号
  • 参数

命令 rpcinfo 将列出机器上运行的 RPC 服务。

通常,开发 RPC 程序涉及八个步骤

  • 构建和测试传统应用程序。
  • 通过选择要移到远程机器的一组过程来划分程序。将选定的过程放入单独的文件中。
  • 为远程程序编写 rpcgen 规范文件。选择一个程序号和版本号。规范文件应该以 .x 后缀命名。例如,远程数据库程序的规范文件可以是 rdb.x.
  • 运行 rpcgen 命令以检查规范文件。如果文件有效,rpcgen 将生成用于构建客户端和服务器的源代码。例如
rpcgen -C rdb.x  

将生成 4 个输出文件:rdb.h rdb_clnt.c rdb_svc.c rdb_xdr.c

  • 为客户端和服务器端编写存根接口例程。你可以使用步骤 1 中开发的代码。例如,你可以编写一个 rdb.c 文件来处理客户端接口,以及一个 rdb_svc_proc.c 来处理服务器端的工作。
  • 编译和链接客户端程序。
gcc -o rdb rdb.c rdb_clnt.c rdb_xdr.c
  • 编译和链接服务器程序。
gcc -o rdb_svc rdb_svc_proc.c rdb_svc.c rdb_xdr.c
  • 启动服务器并调用客户端。

分布式算法

[编辑 | 编辑源代码]

分布式算法的特性

  • 信息可能散布在多台机器上。
  • 每个进程根据本地信息做出决策。
  • 机器可以共享信息。
  • 单个故障点不应导致整个系统崩溃。
  • 机器不能依赖单个公共时钟或全局时间。

一个关于非全局时钟导致问题的简单例子

  1. 你在机器 A 上编译 foo.c(通过 make)生成 foo.o。
  2. 你登录到机器 B,它的时钟比 B 慢 15 分钟。
  3. 你再次编辑 foo.c,然后运行 make。make 会比较 foo.o 和 foo.c 的时间戳,而 foo.o 仍然较新,因此 make 不会重新编译 foo.o。

物理时钟与“实际时间”一致,例如协调世界时。

逻辑时钟不关心与某个全局标准的准确性。相反,系统中的所有机器都试图彼此达成一致。

保持本地物理时钟同步很困难,因为即使机器可以联系权威时间服务器,也存在一定的传播延迟。

机器需要多久向时间服务器请求一次更新?假设机器有一个时钟 ,以 的速率递增。

UTC 时钟 的速率递增。

如果 ,则本地物理时钟 C 完美无缺,不需要更新。

如果 ,则本地物理时钟 C 运行速度更快。

如果 ,则本地物理时钟 C 的运行速度比时间服务器慢。

代表制造商指定的最大漂移率。

代表最大容许误差。每隔 周期,机器必须请求更新。

随着容许误差 增大,更新频率降低。随着漂移率 上升,更新频率也随之增加。

附录 A:C 语言概述

[编辑 | 编辑源代码]

setjmp 和 longjump

[编辑 | 编辑源代码]

使用它们来做 C++、python、Java 等语言中抛出异常的操作。首先调用 setjmp 设置要跳转到的位置。然后其他函数可以调用 longjump 退出当前函数,并跳转到 setjmp 指定的位置。

带可变长度参数的函数

[编辑 | 编辑源代码]

换句话说,printf(...) 是如何工作的?

条件编译

[编辑 | 编辑源代码]

一个例子

main()
{
    #ifdef DEBUG
        printf("this is a debug statement!\n");
    #endif
}
$ gcc -DDEBUG prog.c; ./a.out
this is a debug statement!
$gcc prog.c; ./a.out
$

在第二次编译中,printf 代码块没有被包含。

另一个类似的技巧用于防止重复包含同一个头文件。一个 C 程序通常有多个 .c 文件,这些文件包含多个 .h 文件。你可以在 .h 文件的开头添加这段代码,以防止同一个 .h 文件被重复包含。

#ifndef _FOO_H
#define _FOO_H 1

/* function foo is a worn-out cliche. */
int foo();

#endif

make 和 makefile

[编辑 | 编辑源代码]

Makefile 中允许四种类型的语句:宏、依赖规则、命令和注释。以下是一个简单的 Makefile 例子。

CC = /usr/bin/gcc
CFLAGS = -Wall -pedantic -g

#saves me the trouble of typing all those flags.

proxy: proxy.c proxy.h
   $(CC) $(CFLAGS) proxy proxy.c

debug-proxy: proxy.c proxy.h
   $(CC) $(CFLAGS) -DDEBUG -o proxy proxy.c
 
clean:
   /bin/rm -f *~ httpd a.out httpd.log proxy proxy.log CACHE.*

我可以通过键入 make 和名称来运行任何语句。或者默认情况下,如果 make 没有收到参数,它会运行遇到的第一个规则。

$ make           # runs make proxy
$ make clean     #runs /bin/rm -f ....

宏在声明后可以通过 $(CFLAGS) 或 ${CFLAGS} 访问。$CFLAGS 无效。如果宏是一个字母,比如

A = a.out

那么 $A 是有效的。但无论如何,你应该避免这样做,因为许多单字符宏已经具有含义。

依赖规则下方的每个制表符都会启动一个新的 shell,所以如果你想更改 shell 内容,你必须在一行中完成所有操作。

bogus:
    echo $$PWD; cd ..; echo $$PWD;
    echo $$PWD;

输出

$ make bogus 
echo $PWD; cd ..; echo $PWD
/home/student/wiwilson/cis620
/home/student/wiwilson
echo $pwd

顺便说一下,如果你想抑制对命令的默认回显,在开头加上一个 @。

bored:
    echo "meh"
    @echo "snah"

这里是输出

$ make bored
echo "meh"
meh
snah

一个 make 命令如何递归地调用子目录中的 make

SRCDIR = src1 src2
OBJ    = src1/f1.o src2/f2.o

snake: ${SRCDIR} ${OBJ}
    ${CC} -o snake ${OBJ}

${SRCDIR}: /tmp
    cd $@; make

这最初让我感到困惑。$@ 符号指的是规则的目标,在本例中是 ${SRCDIR}。

你可以在 Makefile 中使用 makedepend 来自动解决所有那些复杂的头文件问题。

CFILES = main.c foo.c bar.c

depend:
    makedepend $(CFILES)
# don't edit below here.

待办事项:显示 Makefile 之前和之后的示例。

如果你有许多文件,并且你想将它们都编译成目标文件,这个规则就可以工作。

.c.o:
    $(CC) -c $<

将 C 转换为汇编

[edit | edit source]
gcc -S try.c

这会将你的程序转换为汇编代码,使用 GNU 语法。gcc 会创建一个名为 try.s 的文件。

gcc -S -masm=intel try.c

这会将你的代码转换为使用 intel 语法的汇编代码。这可能只在 intel CPU 上有效。

你可以像这样将 try.s 转换为可执行文件

gcc try.s

你甚至可以编译可执行文件以供 gdb 使用

gcc -g try.s

然后你可以在 gdb 中逐步执行底层的汇编代码。万岁!

附录 B:习题解答

[edit | edit source]

4.1.1 POSIX 线程

[edit | edit source]

此练习的任务是重写示例代码以使用 fork() 而不是线程。你应该得到类似以下内容的东西

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    /*
     * The name of our process.  This lets us tell the difference when we
     * do output.
     */
    const char* name;

    /*
     * Our counter variable.  Because this is all in one function, it need not
     * be global.
     */
    int i;
    i = 0;
    
    pid_t pid = fork();
    if(pid == 0)
    {
        name = "Child";
    }
    else
    {
        name = "Parent";
    }

    /* Our output loop. */
    int j;
    for(j = 0; j < 10; j++)
    {
        sleep(1);
        printf("%s process: %d\n", name, i);
        i++;
    }
    
    return 0;
}

你还被问及输出在哪些方面不同以及原因。在使用 POSIX 线程的版本中,计数器每秒递增两次:一次由每个线程递增。当使用 fork() 时,它只递增了一次。原因是,当调用 fork() 时,子进程会写入它自己的计数器副本。然而,当使用线程时,两个线程共享它们的内存空间。这意味着它们都在写入计数器的同一个副本,并且它递增了两次。

贡献者

[edit | edit source]

如果你有任何贡献,请给自己记功!

Matthew Wilson 在 2004 年秋季开始了这本教科书,当时他正在克利夫兰州立大学修读研究生比较操作系统接口课程。

Lachlan Gunn

进一步阅读

[edit | edit source]
华夏公益教科书