UIUC-CS341-系统编程中文讲义-全-

UIUC CS341 系统编程中文讲义(全)

原文:github.com/cs341-illinois/coursebook

译者:飞龙

协议:CC BY-NC-SA 4.0

简介

在伊利诺伊大学厄巴纳-香槟分校,我们坚信我们有权利让大学对所有未来的学生都变得更好。这是刻在我们母校上的信息,构成了我们课程工作人员的 DNA。因此,我们创建了课程书。课程书是一本免费和开放的系统编程教科书,任何人都可以阅读、贡献和修改,现在和永远。我们不认为信息应该被隐藏在围墙花园之后,我们真正相信复杂的概念可以被简单而全面地解释,任何人都可以理解。这本书的目标是教你基础知识,并让你对系统编程的复杂性有一些直观的认识。

就像任何一本好书一样,它并不完整。我们还有很多例子、想法、错别字和章节需要完善。如果你发现任何问题,请提交一个问题或者将错别字列表发送给CS 341 课程工作人员,我们将很乐意对其进行修改。我们一直在努力使这本书对学生来说一年后和十年后都更好。

这项工作基于位于这个网址的原始课程书。下面所有这些人的辛勤工作都包含在内。

哦,还有那只鸭子吗?继续阅读直到同步 😃.

再次感谢,祝您阅读愉快!

– Bhuvy

作者

Bhuvan Venkatesh <bhuvan.venkatesh21@gmail.com>
Lawrence Angrave <angrave@illinois.edu>
joebenassi <joebenassi@gmail.com>
jakebailey <zikaeroh@gmail.com>
Ebrahim Byagowi <ebrahim@gnu.org>
Alex Kizer <the.alex.kizer@gmail.com>
dimyr7 <dimyr7.puma@gmail.com>
Ed K <ed.karrels@gmail.com>
ace-n <nassri2@illinois.edu>
josephmilla <jjtmilla@gmail.com>
Thomas Liu <thomasliu02@gmail.com>
Johnny Chang <johnny@johnnychang.com>
goldcase <johnny@johnnychang.com>
vassimladenov <vassi1995@icloud.com>
SurtaiHan <surtai.han@gmail.com>
Brandon Chong <bchong95@users.noreply.github.com>
Ben Kurtovic <ben.kurtovic@gmail.com>
dprorok2 <dprorok2@illinois.edu>
anchal-agrawal <aagrawa4@illinois.edu>
Lawrence Angrave <angrave@illinois.eduuutoomanyu>
daeyun <daeyunshin@gmail.com>
bchong95 <bschong2@illinois.edu>
rushingseas8 <georgealeks@hotmail.com>
lukspdev <lllluuukke@gmail.com>
hilalh <habashi2@illinois.edu>
dimyr7 <dimyr7@hotmail.com>
Azrakal <genxswordsman@hotmail.com>
G. Carl Evans <gcevans@gmail.com>
Cornel Punga <cornel.punga@gmail.com>
vikasagartha <vikasagartha@gmail.com>
dyarbrough93 <dyarbrough93@yahoo.com>
berwin7996 <berwin7996@gmail.com>
Sudarshan Govindaprasad <SudarshanGp@users.noreply.github.com>
NMyren <ntmyren@gmail.com>
Ankit Gohel <ankitgohel1996@gmail.com>
vha-weh-shh <bhaweshchhetri1@gmail.com>
sasankc <sasank.chundi@gmail.com>
rishabhjain2795 <rishabhjain2795@gmail.com>
nickgarfield <nickgarfield@icloud.com>
by700git <aaabox@yeah.net>
bw-vbnm <bwang19@illinois.edu>
Navneeth Jayendran <jayndrn2@illinois.edu>
Joe Benassi <joebenassi@gmail.com>
Harpreet Singh <hshssingh4@gmail.com>
FenixFeather <thomasliu02@gmail.com>
EntangledLight <bdelapor@illinois.edu>
Bliss Chapman <bliss.chapman@gmail.com>
zikaeroh <zikaeroh@gmail.com>
time bandit <radicalrafi@gmail.com>
paultgibbons <paultgibbons@gmail.com>
kevinwang <kevin@kevinwang.com>
cPolaris <cPolaris@users.noreply.github.com>
Zecheng (張澤成) <zzhan147@illinois.edu>
Wieschie <supernova190@gmail.com>
WeiL <z920631580@gmail.com>
Graham Dyer <gdyer2@illinois.edu>
Arun Prakash Jana <engineerarun@gmail.com>
Ankit Goel <ankitgoel616@gmail.com>
Allen Kleiner <akleiner24@gmail.com>
Abhishek Deep Nigam <adn5327@users.noreply.github.com>
zmmille2 <zmmille2@gmail.com>
sidewallme <sidewallme@gmail.com>
raych05 <raymondcheng05@gmail.com>
mmahes <malinixmahes@gmail.com>
mass <amass1212@gmail.com>
kovaka <jakelagrou@gmail.com>
gmag23 <gmag23@gmail.com>
ejian2 <ejian2@illinois.edu>
cerutii <marc.ceruti@gmail.com>
briantruong777 <briantruong777@gmail.com>
adevar <adevar2@illinois.edu>
Yuxuan Zou (Sean) <yzouac@connect.ust.hk>
Xikun Zhang <xikunz2@illinois.edu>
Vishal Disawar <disawar2@illinois.edu>
Taemin Shin <cprayer@naver.com>
Sujay Patwardhan <sujay.patwardhan@gmail.com>
SufeiZ <sufeizhang92@gmail.com>
Sufei Zhang <sufeizhang92@gmail.com>
Steven Shang <sstevenshang@users.noreply.github.com>
Steve Zhu <st.zhu1@gmail.com>
Sibo Wang <sibowsb@gmail.com>
Shane Ryan <shane1027@users.noreply.github.com>
Scott Bigelow <epheph@gmail.com>
Riyad Shauk <riyadshauk@users.noreply.github.com>
Nathan Somers <nsomers2@illinois.edu>
LieutenantChips <vkaraku2@illinois.edu>
Jacob K LaGrou <jakelagrou@gmail.com>
George <ruan3@illinois.edu>
David Levering <dmlevering@gmail.com>
Bernard Lim <bernlim93@users.noreply.github.com>
zwang180 <zshwang0809@gmail.com>
xuanwang91 <LilyBiology2010@gmail.com>
xin-0 <xintong2@illinois.edu>
wchill <wchill1337@gmail.com>
vishnui <vishnui@gmail.com>
tvarun2013 <tvarun2013@gmail.com>
sstevenshang <sstevenshang@users.noreply.github.com>
ssquirrel <lxl_zhang@Hotmail.com>
smeenai <shoaib.meenai@gmail.com>
shrujancheruku <shrujancheruku@gmail.com>
ruiqili2 <ruiqili2@users.noreply.github.com>
rchwlsk2 <rchwlsk2@illinois.edu>
ralphchung <ralphchung2005@gmail.com>
nikioftime <ncwells2@illinois.edu>
mosaic0123 <truffer@live.com>
majiasheng <jiasheng.ma@yahoo.com>
m <cheonghiuwaa@gmail.com>
li820970 <li820970@gmail.com>
kuck1 <kuck1@illinois.edu>
kkgomez2 <kkgomez2@users.noreply.github.com>
jjames34 <James_Jerry1@yahoo.com>
jargals2 <jargals2@ilinois.edu>
hzding621 <hzding621@users.noreply.github.com>
hzding621 <hzding621@gmail.com>
hsingh23 <hisingh1@gmail.com>
denisdemaisbr <denis@roo.com.br>
daishengliang <daishengliang@gmail.com>
cucumbur <bomblolism@gmail.com>
codechao999 <brianweis@comcast.net>
chrisshroba <chrisshroba@gmail.com>
cesarcastmore <cesar.cast.more@gmail.com>
briantruong777 <briantruong777@users.noreply.github.com>
botengHY <tengbo1992@gmail.com>
blapalp <pzkmmmh@gmail.com>
bchhetri1 <bhaweshchhetri1@gmail.com>
anadella96 <aisha.nadella@gmail.com>
akleiner2 <akleiner24@gmail.com>
aRatnam12 <ansh.ratnam@gmail.com>
Yash Sharma <yashosharma@gmail.com>
Xiangbin Hu <xhu27@illinois.edu>
WininWin <ezoneid@gmail.com>
William Klock <william.klock@gmail.com>
WenhanZ <marinebluee@hotmail.com>
Vivek Pandya <vivekvpandya@gmail.com>
Vineeth Puli <vpuli98@gmail.com>
Vangelis Tsiatsianas <vangelists@users.noreply.github.com>
Vadiml1024 <vadim@mbdsys.com>
Utsav2 <ukshah2@illinois.edu>
Thirumal Venkat <zapstar@users.noreply.github.com>
TheEntangledLight <bdelapor@illinois.edu>
SudarshanGp <SudarshanGp@users.noreply.github.com>
Sudarshan Konge <6025419+sudk1896@users.noreply.github.com>
Slix <slixpk@gmail.com>
Sasank Chundi <sasank.chundi@gmail.com>
SachinRaghunathan <srghnth2@illinois.edu>
Rémy Léone <remy.leone@gmail.com>
RusselLuo <russelluo@gmail.com>
Roman Vaivod <littlewhywhat@gmail.com>
Rohit Sarathy <rohit@sarathy.org>
Rick Sheahan <bomblolism@gmail.com>
Rakhim Davletkaliyev <freetonik@gmail.com>
Punitvara <punitvara@gmail.com>
Phillip Quy Le <pitlv2109@gmail.com>
Pavle Simonovic <simonov2@illinois.edu>
Paul Hindt <phindt@gmail.com>
Nishant Maniam <nishant.maniam@gmail.com>
Mustafa Altun <gmail@mustafaaltun.com>
Mohammed Sadik P. K <sadiqpkp@gmail.com>
Mingchao Zhang <43462732+mingchao-zhang@users.noreply.github.com>
Michael Vanderwater <vndrwtr2@users.noreply.github.com>
Maxiwell Luo <maxluoXIII@gmail.com>
LunaMystic <suxianghan@outlook.com>
Liam Monahan <liam@liammonahan.com>
Joshua Wertheim <joshwertheim@gmail.com>
John Pham <newhope11134@gmail.com>
Johannes Scheuermann <johscheuer@users.noreply.github.com>
Joey Bloom <15joeybloom@users.noreply.github.com>
Jimmy Zhang <midnight.vivian@gmail.com>
Jeffrey Foster <jmfoste2@illinois.edu>
James Daniel <james-daniel@users.noreply.github.com>
Jake Bailey <zikaeroh@gmail.com>
JACKHAHA363 <luyuchen.paul@gmail.com>
Hydrosis <badda2k@gmail.com>
Hong <plantvsbird@gmail.com>
Grant Wu <grantwu2@gmail.com>
EvanFabry <Evan.Fabry@gmail.com>
EddieVilla <EddieVilla@users.noreply.github.com>
Deepak Nagaraj <n.deepak@gmail.com>
Daniel Meir Doron <danielmeirdoron@gmail.com>
Daniel Le <GreenRecycleBin@gmail.com>
Daniel Jamrozik <djamro2@illinois.edu>
Daniel Carballal <danielenriquecarballal@gmail.com>
Daniel <DTV96Calibre@users.noreply.github.com>
Daeyun Shin <daeyun@daeyunshin.com>
Creyslz <creyslz@gmail.com>
Christian Cygnus <gamer00@att.net>
CharlieMartell <charliecmartell@gmail.com>
Caleb Bassi <calebjbassi@gmail.com>
Brian Kurek <brkurek@gmail.com>
Brendan Wilson <brendan.x.wilson@gmail.com>
Bo Liu <boliu1@illinois.edu>
Ayush Ranjan <ayushr2@illinois.edu>
Atul kumar Agrawal <ms.atul1303@gmail.com>
Artur Sak <artursak1981@gmail.com>
Ankush Agarwal <ankushagarwal@users.noreply.github.com>
Angelino <angelino_m@outlook.com>
Andrey Zaytsev <andzaytsev@gmail.com>
Alex Yang <alyx.yang@gmail.com>
Alex Cusack <cusackalex@gmail.com>
Aidan Epstein <aidan@jmad.org>
Ace Nassri <ace.nassri@gmail.com>
Abdullahi Abdalla <abdalla6@illinois.edu>
Aneesh Durg <durg2@illinois.edu>
Assassin Eclipse <hungwoei96@hotmail.com>
Eric Cao <eric7252000@gmail.com>
Raphael Long <rafilong42@gmail.com>
WeiL <z920631580@gmail.com>
williamsentosa95 <38774380+williamsentosa95@users.noreply.github.com>
Pradyumna Shome <pradyumna.shome@gmail.com>
Benjamin West Pollak <benjaminwpollak@gmail.com>
姜芃越 Pengyue Jiang <pengyue3@illinois.edu>
Andrew Orals <aorals2@illinois.edu>
Elijah Mock <emock3@illinois.edu>

背景

系统架构

本节是对系统架构主题的简要回顾,这些主题对于系统编程是必需的。

汇编

什么是汇编?汇编是您在不直接编写 1 和 0 的情况下所能达到的最低级的机器语言。每台计算机都有一个架构,并且该架构有一个相关的汇编语言。每个汇编指令都与一组 1 和 0 一一对应,这些 1 和 0 告诉计算机确切要做什么。例如,以下是在广泛使用的 x86 汇编语言中向内存地址 20 加一的指令(维基图书 2018)——您也可以在(指南 2011)第 2A 节下查找加法指令,尽管它更为冗长。

add BYTE PTR [0x20], 1

为什么我们要提到这一点?因为虽然你将在大多数课程中使用 C 语言,但代码实际上会被翻译成这种形式。对于竞态条件和原子操作,这会产生严重的后果。

原子操作

如果没有其他处理器应该中断它,则操作是原子的。以上述汇编代码向寄存器加一为例。在架构中,它实际上可能在电路上有几个不同的步骤。操作可能首先从 ram 的 stick 中获取内存值,然后将其存储在缓存或寄存器中,最后写回(Schweizer, Besta, and Hoefler 2015)——在fetch-and-add的描述下,尽管你的微架构可能不同。或者根据性能操作,它可能将那个值保持在缓存或该进程的本地寄存器中——尝试导出增加变量的优化汇编。问题在于如果两个处理器同时尝试这样做。两个处理器可能同时复制内存地址的值,加一,并将相同的结果写回,导致值只增加一次。这就是为什么我们在现代系统中有一组特殊的指令称为原子操作。如果一个指令是原子的,它确保一次只有一个处理器或线程执行任何中间步骤。在 x86 中,这是通过前缀(指南 2011,1120)完成的。

lock add BYTE PTR [0x20], 1

为什么我们不把这一切都这样做?这会使命令变慢!如果每次计算机做某事时都必须确保其他核心或处理器没有在做任何事情,它将会慢得多。大多数时候我们会用特殊考虑来区分这些。这意味着,当我们使用类似的东西时,我们会告诉你。大多数时候,你可以假设指令是未锁定的。

缓存

哎,是的,缓存。计算机科学中最大的问题之一。我们所说的缓存是指处理器缓存。如果在读取或写入时特定的地址已经在缓存中,处理器将对该缓存执行操作,例如添加和更新实际内存,稍后因为更新内存是慢的(英特尔 2015 第 3.4 节)。如果没有,处理器将从内存芯片请求一块内存并将其存储在缓存中,淘汰最不常使用的页面——这取决于缓存策略,但英特尔确实使用了这种方法。这样做是因为 l3 处理器缓存在时间上比内存快大约三倍(Levinthal 2009,22),但确切的速度会根据时钟速度和架构而变化。自然地,这会导致问题,因为有两个相同值的副本,在所引用的论文中这指的是未共享的行。这不是一个关于缓存的课程,但你应该知道这可能会如何影响你的代码。一个简短但不完整的列表可能是

  1. 竞态条件!如果一个值存储在两个不同的处理器缓存中,那么这个值应该由单个线程访问。

  2. 速度。有了缓存,你的程序可能会神秘地看起来更快。只需假设最近发生或内存中相邻的读取和写入操作是快速的。

  3. 副作用。每次读取或写入都会影响缓存状态。虽然大多数时候这不会有所帮助或造成伤害,但了解这一点很重要。请查阅英特尔程序员指南中有关锁前缀的更多信息。

中断

中断是系统编程的重要组成部分。中断在内部是一个电信号,当发生某些事情时传递给处理器——这是一个硬件中断(“第三章 硬件中断”,n.d.)。然后硬件决定这是否是它应该处理的事情(例如,处理旧键盘和鼠标的键盘或鼠标输入)或者应该传递给操作系统。操作系统随后决定这是否是它应该处理的事情(例如,从磁盘分页内存表)或者应用程序应该处理的事情(例如,SEGFAULT)。如果操作系统决定这是进程或程序应该处理的事情,它会发送一个软件故障,然后这个软件故障就会传播。应用程序随后决定这是否是一个错误(SEGFAULT)或者不是(例如 SIGPIPE)并向用户报告。应用程序还可以向内核和硬件发送信号。这是一个过于简化的说法,因为有一些硬件故障不能被忽略或屏蔽,但这个课程不是教你如何构建操作系统。

这的一个重要应用就是系统调用的服务方式!有一套经过良好建立的寄存器,根据内核的规则,参数会放入其中,以及一个由内核再次定义的系统调用“编号”。然后操作系统触发一个中断,内核捕获并服务系统调用(Garg 2006)。

操作系统开发者和指令集开发者都不喜欢在系统调用时引起中断的开销。现在,系统使用了一种更干净的方式来安全地将控制权转移到内核,并安全地返回。安全意味着什么在这个课程的范围之外,但它持续存在。

可选:超线程

超线程是一种新技术,它根本不是多线程。超线程允许一个物理核心在操作系统中表现为多个虚拟核心(Guide 2011,第 51 页)。操作系统可以在这这些虚拟核心上调度进程,一个核心将执行它们。每个核心交错执行进程或线程。当核心等待一个内存访问完成时,它可能执行另一个进程线程的几个指令。整体结果是更短的时间内执行了更多的指令。这潜在地意味着你可以减少为小型设备供电所需的核心数量。

但是这里有一些风险。在使用超线程时,你必须小心优化。一个著名的超线程错误导致程序在至少有两个进程被调度到物理核心、使用特定寄存器并在紧密循环中时崩溃。这个问题实际上通过架构的角度解释得更好。但是,这个实际的应用是通过在 OCaml 主线工作的系统程序员发现的(Leroy 2017)。

调试和环境

我要告诉你这个课程的秘密:它是关于更聪明地工作,而不是更努力地工作。这个课程可能会很耗时,但很多人认为它就是这样(以及为什么很多学生不这样认为)的原因是人们对他们的工具相对熟悉。让我们回顾一下你将需要熟悉的一些常见工具。

ssh

ssh 是 Secure Shell(“Ssh(1),” n.d.)的缩写。它是一种网络协议,允许你在远程机器上启动一个 shell。在这个课程的大部分时间里,你将需要像这样 ssh 到你的虚拟机

$ ssh netid@sem-cs341-VM.cs.illinois.edu

如果你不想每次都输入密码,你可以生成一个唯一标识你的机器的 ssh 密钥。如果你已经有了密钥对,你可以跳到复制 id 阶段。

> ssh-keygen -t rsa -b 4096
# Do whatever keygen tells you
# Don't feel like you need a passcode if your login password is secure
> ssh-copy-id netid@sem-cs341-VM.cs.illinois.edu
# Enter your password for maybe the final time
> ssh  netid@sem-cs341-VM.cs.illinois.edu

如果你仍然觉得输入太多,你总是可以给主机起别名。你可能需要重新启动你的虚拟机或重新加载 sshd 才能使这个更改生效。配置文件在 Linux 和 Mac 发行版上可用。对于 Windows,你必须使用 Windows Linux 子系统或配置 PuTTY 中的任何别名

> cat ~/.ssh/config
Host vm
 User          netid
 HostName      sem-cs341-VM.cs.illinois.edu
> ssh vm

git

什么是‘git’?Git 是一个版本控制系统。这意味着 git 存储了一个目录的整个历史。我们将该目录称为仓库。所以你需要知道的是一些事情。首先,使用仓库创建器创建你的仓库。如果你还没有登录企业 GitHub,请确保这样做,否则你的仓库将不会为你创建。之后,你的仓库在服务器上创建。Git 是一个分布式版本控制系统,这意味着你需要将一个仓库放到你的虚拟机(VM)上。我们可以通过克隆来实现这一点。无论你做什么,都不要通过 README.md 教程进行

$ git clone https://github.com/illinois-cs-coursework/fa23_cs341_<netid>

这将创建一个本地仓库。工作流程是这样的:你在本地仓库上进行更改,将更改添加到当前提交中,实际上提交,并将更改推送到服务器。

$ # edit the file, maybe using vim
$ git add <file>
$ git commit -m "Committing my file"
$ git push origin master

现在要很好地解释 git,你需要理解,就我们的目的而言,git 将看起来像是一个链表。你将始终位于 master 的头部,并且你会进行编辑-添加-提交-推送的循环。我们在 GitHub 上有一个单独的分支,我们将反馈推送到特定的分支,你可以在 GitHub 网站上查看。Markdown 文件将包含测试用例和结果(如标准输出)。

有时 git 可能会出错。以下是一些你可能不需要修复你的仓库的命令列表

  1. git-cherry-pick

  2. git-pack

  3. git-gc

  4. git-clean

  5. git-rebase

  6. git-stash/git-apply/git-pop

  7. git-branch

如果你目前在一个分支上,你既看不到

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean

也没有

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
 (use "git add <file>..." to update what will be committed)
 (use "git checkout -- <file>..." to discard changes in working directory)

 modified:   <FILE>
 ...

no changes added to commit (use "git add" and/or "git commit -a")

以及类似的内容

$ git status
HEAD detached at 4bc4426
nothing to commit, working directory clean

不要慌张,但你的仓库可能处于不可工作的状态。如果你没有接近截止日期,请来办公室时间或在你 Edstem 上提问,我们会很乐意帮助你。在紧急情况下,删除你的仓库并重新克隆(你将不得不添加如上所述的内容)。这将丢失任何本地未提交的更改。请确保复制任何你在目录外工作的文件,移除并重新复制它们

如果你想了解更多关于 git 的信息,网上有无数个教程和资源可以帮助你。以下是一些可以帮助你的链接

  1. git-scm.com/docs/gittutorial

  2. www.atlassian.com/git/tutorials/what-is-version-control

  3. thenewstack.io/tutorial-git-for-absolutely-everyone/

编辑器

有些人把这当作学习新编辑器的机会,而有些人则不然。第一部分是那些想要学习新编辑器的人。在跨越数十年的编辑器战争中,我们来到了 vim 与 emacs 的战斗。

Vim 是一个文本编辑器和类 Unix 实用工具。你可以通过输入 , 来进入 vim,这会带你进入编辑器。最常用的三种模式是:正常模式、插入模式和命令模式。你从正常模式开始。在这个模式下,你可以使用许多键来移动,最常见的键是(分别对应左、下、上和右)。在 vim 中运行命令时,你可以在输入后输入一个命令。例如,要退出 vim,只需输入 (q 代表退出)。如果你有任何未保存的编辑,你必须保存它们、保存并退出,或者退出并丢弃更改。要编辑,你可以输入以进入插入模式,或者输入以在光标后进入插入模式 a。这是 vim 的基本操作。除了互联网上无数的优质资源外,vim 还为初学者提供了自己的内置教程。要访问交互式教程,请在命令行中输入(不在 vim 内部),然后你就准备好了!

Emacs 更像是一种生活方式,我并不是在比喻意义上说。很多人说 emacs 是一个功能强大的操作系统,缺少一个不错的文本编辑器。这意味着 emacs 可以容纳终端、gdb 会话、ssh 会话、代码等等。介绍 GNU-emacs 没有比通过 GNU 文档 www.gnu.org/software/emacs/tour/ 更合适的方式了。只需注意,emacs 极其 强大。你可以用它做几乎所有的事情。有不少学生喜欢其他编程语言的 IDE 特性。要知道你可以设置 emacs 成为一个 IDE,但你必须学习一点 Lisp martinsosic.com/development/emacs/2017/12/09/emacs-cpp-ide.html

然后,有些人喜欢使用自己的编辑器。这完全没问题。为此,我们需要 sshfs,它可以在许多不同的机器上运行。

  1. Windows github.com/billziss-gh/sshfs-win

  2. Mac github.com/osxfuse/osxfuse/wiki/SSHFS

  3. Linux help.ubuntu.com/community/SSHFS

在那个时刻,你的虚拟机上的文件与你的机器上的文件同步,你可以进行编辑,并且这些编辑将会同步。

在撰写本文时,作者喜欢使用 spacemacs spacemacs.org/,它结合了 vim 和 emacs 的优点,并且克服了它们各自的困难。我将分享我喜欢它的原因,但警告一下,如果你完全没有 vim 或 emacs 的经验,学习曲线加上这门课程可能会过于陡峭。

  1. 可扩展。Spacemacs 有一个干净的设计,用 lisp 编写。有 100 多个包可以通过编辑你的 spacemacs 配置并重新加载来安装,从语法检查、自动静态分析等一切。

  2. 大部分来自 vim 和 emacs 的优点。Emacs 作为一个快速的编辑器,擅长做任何事情。Vim 擅长快速编辑和移动。Spacemacs 是两者的最佳结合,允许使用 vim 键盘绑定访问所有 emacs 的优点。

  3. 完成了大量的预配置。与全新的 emacs 安装相比,许多语言和项目的配置已经为你完成,如 neotree、helm、各种语言层。你所要做的就是使用 neotree 导航到你的项目基础目录,emacs 将变成该编程语言的 IDE。

但显然各有所好。许多人会争论编辑大师们花更多的时间在编辑他们的编辑器上,而不是真正地编辑。

Clean Code

使用辅助函数使你的代码模块化。如果有重复的任务(例如,在 malloc MP 中获取连续块的指针),将它们做成辅助函数。并确保每个函数都做得很好,这样你就不必调试两次。假设我们正在通过每次迭代找到最小元素来进行选择排序,

void selection_sort(int *a, long len){
 for(long i = len-1; i > 0; --i){
 long max_index = i;
 for(long j = len-1; j >= 0; --j){
 if(a[max_index] < a[j]){
 max_index = j;
 }
 }
 int temp = a[i];
 a[i] = a[max_index];
 a[max_index] = temp;
 }

}

许多人可以看到代码中的错误,但将上述方法重构可能有所帮助。

long max_index(int *a, long start, long end);
void swap(int *a, long idx1, long idx2);
void selection_sort(int *a, long len);

错误特别在一个函数中。最终,这个课程是关于编写系统程序,而不是关于重构/调试你的代码的课程。实际上,大多数内核代码非常糟糕,你不想阅读它——那里的辩护是它需要这样。但为了调试,从长远来看,采用一些这些做法可能对你有益。

断言

使用断言来确保你的代码在某个点上正常工作——并且重要的是,确保你以后不会破坏它。例如,如果你的数据结构是双向链表,你可以做些类似的事情来断言下一个节点有一个指向当前节点的指针。你还可以检查指针是否指向一个预期的内存地址范围,非空,->size 是合理的等。宏将禁用所有断言,所以一旦你完成调试,别忘了设置它(“断言”,n.d.)。

这里有一个使用断言的快速示例。假设我们正在使用 memcpy 编写代码。我们想在它之前放一个断言来检查我的两个内存区域是否重叠。如果它们重叠,memcpy 将遇到未定义的行为,所以我们想尽早发现这个问题。

assert( src+n < dest || src >= dest + n); // source should finish before the destination or the source starts after the end of destination
memcpy(dest, src, n);

这个检查可以在编译时关闭,但将为你节省大量的调试麻烦!

Valgrind

Valgrind 是一套工具集,旨在提供调试和性能分析工具,使你的程序更加正确,并检测一些运行时问题(“4. Memcheck:内存错误检测器”,n.d.)。其中最常用的工具是 Memcheck,它可以检测许多在 C 和 C++ 程序中常见的内存相关错误,这些错误可能导致崩溃和不可预测的行为(例如,未释放的内存缓冲区)。要在你的程序上运行 Valgrind:

valgrind --leak-check=full --show-leak-kinds=all myprogram arg1 arg2

参数是可选的,默认运行的工具是 Memcheck。输出将以以下形式呈现:分配、释放和错误的数量。假设我们有一个像这样的简单程序:

#include <stdlib.h>

void dummy_function() {
 int* x = malloc(10 * sizeof(int));
 x[10] = 0;        // error 1: Out of bounds write, as you can see here we write to an out of bound memory address.
}                    // error 2: Memory Leak, x is allocated at function exit.

int main(void) {
 dummy_function();
 return 0;
}

这个程序编译并运行没有错误。让我们看看 Valgrind 会输出什么。

==29515== Memcheck, a memory error detector
==29515== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==29515== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==29515== Command: ./a
==29515==
==29515== Invalid write of size 4
==29515==    at 0x400544: dummy_function (in /home/rafi/projects/exocpp/a)
==29515==    by 0x40055A: main (in /home/rafi/projects/exocpp/a)
==29515==  Address 0x5203068 is 0 bytes after a block of size 40 alloc'd
==29515==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==29515==    by 0x400537: dummy_function (in /home/rafi/projects/exocpp/a)
==29515==    by 0x40055A: main (in /home/rafi/projects/exocpp/a)
==29515==
==29515==
==29515== HEAP SUMMARY:
==29515==     in use at exit: 40 bytes in 1 blocks
==29515==   total heap usage: 1 allocs, 0 frees, 40 bytes allocated
==29515==
==29515== LEAK SUMMARY:
==29515==    definitely lost: 40 bytes in 1 blocks
==29515==    indirectly lost: 0 bytes in 0 blocks
==29515==      possibly lost: 0 bytes in 0 blocks
==29515==    still reachable: 0 bytes in 0 blocks
==29515==         suppressed: 0 bytes in 0 blocks
==29515== Rerun with --leak-check=full to see details of leaked memory
==29515==
==29515== For counts of detected and suppressed errors, rerun with: -v
==29515== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

无效写入:它检测到我们的堆块越界,写入分配块之外。

肯定丢失:内存泄漏——你可能忘记释放一个内存块。

Valgrind 是一个有效的工具,用于在运行时检查错误。当涉及到这种行为时,C 语言是特殊的,因此编译你的程序后,你可以使用 Valgrind 来修复编译器可能遗漏的错误,这通常发生在程序运行时。

更多信息,请参阅手册(“4. Memcheck: A Memory Error Detector,” n.d.)

TSAN

ThreadSanitizer 是 Google 的一项工具,内置在 clang 和 gcc 中,用于帮助你在代码中检测竞态条件(“ThreadSanitizerCppManual” 2018)。注意,使用 tsan 会使你的代码运行速度稍微慢一些。考虑以下代码。

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

int global;

void *Thread1(void *x) {
 global++;
 return NULL;
}

int main() {
 pthread_t t[2];
 pthread_create(&t[0], NULL, Thread1, NULL);
 global = 100;
 pthread_join(t[0], NULL);
}
// compile with gcc -fsanitize=thread -pie -fPIC -ltsan -g simple_race.c

我们可以看到变量上存在竞态条件。主线程和创建的线程都会尝试同时更改该值。但是,ThreadSanitizer 能捕捉到它吗?

$ ./a.out
==================
WARNING: ThreadSanitizer: data race (pid=28888)
 Read of size 4 at 0x7f73ed91c078 by thread T1:
 #0 Thread1 /home/zmick2/simple_race.c:7 (exe+0x000000000a50)
 #1  :0 (libtsan.so.0+0x00000001b459)

 Previous write of size 4 at 0x7f73ed91c078 by main thread:
 #0 main /home/zmick2/simple_race.c:14 (exe+0x000000000ac8)

 Thread T1 (tid=28889, running) created by main thread at:
 #0  :0 (libtsan.so.0+0x00000001f6ab)
 #1 main /home/zmick2/simple_race.c:13 (exe+0x000000000ab8)

SUMMARY: ThreadSanitizer: data race /home/zmick2/simple_race.c:7 Thread1
==================
ThreadSanitizer: reported 1 warnings

如果我们使用调试标志进行编译,那么它还会给出变量名。

GDB

GDB 是 GNU 调试器的缩写。GDB 是一个程序,它通过交互式调试帮助你追踪错误(“GDB: The Gnu Project Debugger” 2019)。它可以启动和停止你的程序,四处查看,并设置临时的约束和检查。以下是一些示例。

通过编程方式设置断点

断点是指你希望程序停止执行并将控制权交回调试器的代码行。在用 GDB 调试复杂的 C 程序时,设置源代码中的断点是一个有用的技巧。

int main() {
 int val = 1;
 val = 42;
 asm("int $3"); // set a breakpoint here
 val = 7;
}
$ gcc main.c -g -o main
$ gdb --args ./main
(gdb) r
[...]
Program received signal SIGTRAP, Trace/breakpoint trap.
main () at main.c:6
6     val = 7;
(gdb) p val
$1 = 42

你也可以通过编程方式设置断点。假设我们没有优化,行号如下

1. int main() {
2.     int val = 1;
3.     val = 42;
4.     val = 7;
5. }

我们现在可以在程序开始之前设置断点。

$ gcc main.c -g -o main
$ gdb --args ./main
(gdb) break main.c:4
[...]
(gdb) p val
$1 = 42
检查内存内容

我们还可以使用 GDB 来检查不同内存块的内容。例如,

int main() {
 char bad_string[3] = {'C', 'a', 't'};
 printf("%s", bad_string);
}

编译后得到

$ gcc main.c -g -o main && ./main
$ Cat ZVQ� $

我们现在可以使用 GDB 来查看字符串的特定字节,并推断程序应该在何时停止运行。

(gdb) l
1 #include <stdio.h>
2 int main() {
3     char bad_string[3] = {'C', 'a', 't'};
4     printf("%s", bad_string);
5 }
(gdb) b 4
Breakpoint 1 at 0x100000f57: file main.c, line 4.
(gdb) r
[...]
Breakpoint 1, main () at main.c:4
4     printf("%s", bad_string);
(gdb) x/16xb bad_string
0x7fff5fbff9cd: 0x63  0x61  0x74  0xe0  0xf9  0xbf  0x5f  0xff
0x7fff5fbff9d5: 0x7f  0x00  0x00  0xfd  0xb5  0x23  0x89  0xff
(gdb)

这里,通过使用带有参数的命令,我们可以看到从内存地址(值为)开始,实际上会看到以下字节序列作为字符串,因为我们提供了一个没有空终止符的格式错误的字符串。

涉及到的 GDB 示例

这就是你的助教如何通过调试一个出错的简单程序。首先,程序的源代码。如果你能立即看到错误,请耐心等待。

#include <stdio.h>

double convert_to_radians(int deg);

int main(){
 for (int deg = 0; deg > 360; ++deg){
 double radians = convert_to_radians(deg);
 printf("%d. %f\n", deg, radians);
 }
 return 0;
}

double convert_to_radians(int deg){
 return ( 31415 / 1000 ) * deg / 180;
}

我们如何使用 GDB 进行调试?首先,我们应该加载 GDB。

$ gdb --args ./main
(gdb) layout src; # If you want a GUI type
(gdb) run
(gdb)

想要查看源代码吗?

(gdb) l
1	#include <stdio.h>
2 
3	double convert_to_radians(int deg);
4 
5	int main(){
6		for (int deg = 0; deg > 360; ++deg){
7			double radians = convert_to_radians(deg);
8			printf("%d. %f\n", deg, radians);
9		}
10	    return 0;
(gdb) break 7 # break <file>:line or break <file>:function
(gdb) run
(gdb)

从运行代码来看,断点甚至没有触发,这意味着代码从未到达那个点。那是因为比较!好吧,翻转符号,现在应该可以工作了,对吧?

(gdb) run
350. 60.000000
351. 60.000000
352. 60.000000
353. 60.000000
354. 60.000000
355. 61.000000
356. 61.000000
357. 61.000000
358. 61.000000
359. 61.000000
(gdb) break 14 if deg == 359 # Let's check the last iteration only
(gdb) run
...
(gdb) print/x deg # print the hex value of degree
$1 = 0x167
(gdb) print (31415/1000)
$2 = 0x31
(gdb) print (31415/1000.0)
$3 = 201.749
(gdb) print (31415.0/10000.0)
$4 = 3.1414999999999999

这只是最基本的,尽管你们大多数人都能用这个过得去。网上有大量的资源,这里有一些具体的资源可以帮助你开始。

  1. gdb 简介

  2. GDB 手册

  3. CppCon 2015: Greg Law “给我 15 分钟,我会改变你对 GDB 的看法”

Shell

你实际上是用什么来运行你的程序的?是 shell!shell 是一种运行在终端内的编程语言。终端只是一个输入命令的窗口。在 POSIX 系统中,我们通常有一个名为的 shell,它与一个符合 POSIX 规范的 shell 链接。大多数时候,你使用的是一个称为的 shell,它某种程度上符合 POSIX 规范,但有一些实用的内置功能。如果你想更高级,它有一些更强大的功能,比如程序上的 tab 补全和模糊模式。

未定义行为清理器

未定义行为清理器是 llvm 项目提供的一个奇妙工具。它允许你使用运行时检查器编译代码,以确保你不会对各种类别执行未定义行为。我们将尝试将其纳入我们的项目,但这需要所有外部库的支持,所以我们可能无法全部完成。未定义行为清理器文档

未定义行为 - 为什么我们通常无法解决它

也请务必阅读 Chris Lattner 的关于未定义行为的 3 部分博客文章。它可以帮助你了解调试构建和编译器优化的神秘之处。

每个 C 程序员都应该知道的事情

Clang 静态构建工具

Clang 提供了编译程序的出色替代工具。如果你想检查可能引起竞态条件、类型转换错误等错误,你只需要做以下操作。

$ scan-build make

除了 make 输出之外,你还会得到静态构建警告。

strace 和 ltrace

strace 和 ltrace 是两个程序,分别跟踪运行程序或命令的系统调用和库调用。这些可能在你的系统上缺失,所以为了安装,请运行以下命令:

$ sudo apt install strace ltrace

使用 ltrace 进行调试可能就像找出最后一个失败的库调用的返回调用一样简单。

int main() {
 FILE *fp = fopen("I don't exist", "r");
 fprintf(fp, "a");
 fclose(fp);
 return 0;
}
> ltrace ./a.out
 __libc_start_main(0x8048454, 1, 0xbfc19db4, 0x80484c0, 0x8048530 <unfinished ...>
 fopen("I don't exist", "r")                          = 0x0
 fwrite("Invalid Write\n", 1, 14, 0x0 <unfinished ...>
 --- SIGSEGV (Segmentation fault) ---
 +++ killed by SIGSEGV +++

ltrace 输出可以让你了解程序在运行时所做的奇怪事情。不幸的是,ltrace 不能用来注入故障,这意味着 ltrace 可以告诉你正在发生什么,但它不能干扰已经发生的事情。

另一方面,strace可以修改你的程序。使用strace进行调试非常神奇。基本用法是运行带有程序的strace,它会给你一个完整的系统调用参数列表。

$ strace head README.md
execve("/usr/bin/head", ["head", "README.md"], 0x7ffff28c8fa8 /* 60 vars */) = 0
brk(NULL)                               = 0x7ffff5719000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=32804, ...}) = 0
...

如果输出太冗长,你可以使用trace=与以命令分隔的 syscalls 列表来过滤除了那些调用之外的所有调用。

$ strace -e trace=read,write head README.md
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\34\2\0\0\0\0\0"..., 832) = 832
read(3, "# Locale name alias data base.\n#"..., 4096) = 2995
read(3, "", 4096)                       = 0
read(3, "# C Datastructures\n\n[![Build Sta"..., 8192) = 1250
write(1, "# C Datastructures\n", 19# C Datastructures

你也可以跟踪文件或目标。

$ strace -e trace=read,write -P README.md head README.md
strace: Requested path 'README.md' resolved into '/mnt/c/Users/user/personal/libds/README.md'
read(3, "# C Datastructures\n\n[![Build Sta"..., 8192) = 1250

更新版本的strace实际上可以注入故障到你的程序中。当你想偶尔让读取和写入失败时,例如在网络应用程序中,你的程序应该处理这种情况,这很有用。问题是截至 2019 年初,这个版本在 Ubuntu 仓库中缺失。这意味着你将不得不从源代码安装它。

printfs

当所有其他方法都失败时,打印!每个函数都应该有一个关于它将要做什么的想法。你想要测试每个函数是否真的做了它打算做的事情,并确切地看到你的代码在哪里出错。在竞争条件的情况下,tsan 可能有所帮助,但每个线程在特定时间打印数据可以帮助你识别竞争条件。

为了使printf函数更有用,尝试创建一个宏,通过它填充调用printf时的上下文——即日志语句。一个简单但未经验证的日志语句可能如下所示。尝试进行测试,找出出错的原因,然后记录变量的状态。

 #include <execinfo.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <stdarg.h>
 #include <unistd.h>

 // bt is print backtrace
 const int num_stack = 10;
 int __log(int line, const char *file, int bt, const char *fmt, ...) {
 if (bt) {
 void *raw_trace[num_stack];
 size_t size = backtrace(raw_trace, sizeof(raw_trace) / sizeof(raw_trace[0]));
 char **syms = backtrace_symbols(raw_trace, size);

 for(ssize_t i = 0; i < size; i++) {
 fprintf(stderr, "|%s:%d| %s\n", file, line, syms[i]);
 }
 free(syms);
 }
 int ret = fprintf(stderr, "|%s:%d| ", file, line);
 va_list args;
 va_start(args, fmt);
 ret += vfprintf(stderr, fmt, args);
 va_end(args);
 ret += fprintf(stderr, "\n");
 return ret;
 }

 #ifdef DEBUG
 #define log(...) __log(__LINE__, __FILE__, 0, __VA_ARGS__)
 #define bt(...) __log(__LINE__, __FILE__, 1, __VA_ARGS__)
 #else
 #define log(...)
 #define bt(...)
 #endif

 //Use as log(args like printf) or bt(args like printf) to either log or get backtrace

 int main() {
 log("Hello Log");
 bt("Hello Backtrace");
 }

然后根据需要使用。如果你对如何将 C 程序转换为机器代码有任何疑问,请查看附录中的编译和链接部分。

作业 0

// First, can you guess which lyrics have been transformed into this C-like system code?
char q[] = "Do you wanna build a C99 program?";
#define or "go debugging with gdb?"
static unsigned int i = sizeof(or) != strlen(or);
char* ptr = "lathe";
size_t come = fprintf(stdout,"%s door", ptr+2);
int away = ! (int) * "";

int* shared = mmap(NULL, sizeof(int*), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
munmap(shared,sizeof(int*));

if(!fork()) {
 execlp("man","man","-3","ftell", (char*)0); perror("failed");
}

if(!fork()) {
 execlp("make","make", "snowman", (char*)0); execlp("make","make", (char*)0));
}

exit(0);

所以你想掌握系统编程?并且得到比 B 更好的成绩?

int main(int argc, char** argv) {
 puts("Great! We have plenty of useful resources for you, but it's up to you to");
 puts(" be an active learner and learn how to solve problems and debug code.");
 puts("Bring your near-completed answers the problems below");
 puts(" to the first lab to show that you've been working on this.");
 printf("A few \"don't knows\" or \"unsure\" is fine for lab 1.\n");
 puts("Warning: you and your peers will work hard in this class.");
 puts("This is not CS225; you will be pushed much harder to");
 puts(" work things out on your own.");
 fprintf(stdout,"This homework is a stepping stone to all future assignments.\n");
 char p[] = "So, you will want to clear up any confusions or misconceptions.\n";
 write(1, p, strlen(p) );
 char buffer[1024];
 sprintf(buffer,"For grading purposes, this homework 0 will be graded as part of your lab %d work.\n", 1);
 write(1, buffer, strlen(buffer));
 printf("Press Return to continue\n");
 read(0, buffer, sizeof(buffer));
 return 0;
}

观看视频并回答以下问题

重要!

浏览器中的虚拟机和 HW0 所需的视频在此处:

cs-education.github.io/sys/

有问题?评论?请使用本学期的 CS341 Edstem:edstem.org/us/courses/61021/discussion/

在浏览器中的虚拟机完全运行在 JavaScript 上,在 Chrome 中运行速度最快。请注意,当你重新加载页面时,VM 和任何你编写的代码都会被重置,所以请将你的代码复制到单独的文档中。视频后的挑战不是作业 0 的一部分,但通过实践而不是被动观看,你可以学到更多。每个视频结束时的挑战都很有趣。

HW0 问题如下。将你的答案复制到文本文档中,因为你稍后需要在课程中提交它们。

第一章

在其中,我们的英勇英雄与标准输出、标准错误、文件描述符以及写入文件进行斗争

  1. Hello, World! (系统调用风格) 编写一个程序,使用它打印出 "Hi! My name is "。

  2. Hello, Standard Error Stream! 编写一个函数,打印出高度为n的三角形到标准错误。你的函数应该具有以下签名,并使用printf。当n = 3时,三角形应如下所示:

    *
    **
    ***
    
  3. 写入文件 将你的程序从“Hello, World!”修改为写入一个名为的文件。确保使用正确的标志和正确的模式(是你的朋友)。

  4. 并非所有内容都是系统调用 将你的程序从“写入文件”改为,并用. 替换。确保打印到文件而不是标准输出!

  5. 和之间有哪些区别?

第二章

C 类型的大小和它们的限制、数组和指针递增

  1. 一个字节中有多少位?

  2. 一个中有多少字节?

  3. 在你的机器上,以下有多少字节?, , , , 和

  4. 在一个 8 字节整数的机器上,变量的声明为。如果数据地址为,那么的地址是什么?

  5. 在 C 中,什么与等价?提示:C 在解引用地址之前将其转换为什么?记住,字符串常量的类型是一个数组。

  6. 为什么会出现 SEGFAULT?

    char *ptr = "hello";
    *ptr = 'J';
    
  7. 变量的值是多少?

    ssize_t str_size = sizeof("Hello\0World")
    
  8. 变量的值是多少?

    ssize_t str_len = strlen("Hello\0World")
    
  9. 给出一个例子,使得是 3。

  10. 给出一个例子,使得在机器上可能是 4 或 8。

第三章

程序参数、环境变量以及与字符数组(字符串)一起工作

  1. 至少有两种方法可以找到的长度?

  2. 代表什么?

  3. 环境变量指针存储在哪里(在栈上、在堆上、其他地方)?

  4. 在指针为 8 字节的机器上,以下代码:

    char *ptr = "Hello";
    char array[] = "Hello";
    

    什么是和的值?为什么?

  5. 管理自动变量生命周期的数据结构是什么?

第四章

堆内存、栈内存以及与结构体一起工作

  1. 如果我想在创建它的函数的生命周期结束后使用数据,我应该把它放在哪里?我该如何放置它?

  2. 堆内存和栈内存之间的区别是什么?

  3. 进程中还有其他类型的内存吗?

  4. 填空:在好的 C 程序中,对于每个 malloc,都有一个 ___。

  5. 为什么一个可能会失败?一个原因是什么?

  6. 和之间有哪些区别?

  7. 这段代码片段有什么问题?

    free(ptr);
    free(ptr);
    
  8. 这段代码片段有什么问题?

    free(ptr);
    printf("%s\n", ptr);
    
  9. 如何避免之前的两个错误?

  10. 创建一个表示人的,然后创建一个,以便可以将替换为一个单词。一个人应包含以下信息:他们的名字(一个字符串)、他们的年龄(一个整数)以及他们的朋友列表(存储为指向 s 的指针数组的指针)。

  11. 现在,在堆上创建两个人,分别是 128 岁和 256 岁的“Agent Smith”和“Sonny Moore”,他们互为朋友。创建创建和销毁 Person(Person 及其名称应位于堆上)的函数。

  12. 应该接受一个名字和一个年龄。名字应该复制到堆上。使用 malloc 为每个人保留最多十个朋友的足够内存。确保初始化所有字段(为什么?)。

  13. 应该释放人结构体的内存以及存储在堆上的所有属性。销毁一个人不会影响其他人。

第五章

使用 , , 和 进行文本输入输出和解析。

  1. 可以使用哪些函数从和写入字符?

  2. 提出一个与的问题。

  3. 编写代码解析字符串"Hello 5 World",并将 3 个变量初始化为"Hello",5 和"World"。

  4. 在包含之前,需要定义什么?

  5. 编写一个 C 程序,使用逐行打印文件的内容。

C 开发

这些是使用编译器和 git 编译和开发的一般性提示。这里的一些网络搜索会有所帮助。

  1. 用于生成调试构建的编译器标志是什么?

  2. 你修复了 Makefile 中的问题,并再次输入。解释为什么这不足以生成新的构建。

  3. 在 Makefile 中的规则之后,使用制表符还是空格来缩进命令?

  4. 什么是 do?在 git 的上下文中,a 是什么意思?

  5. show 你什么?

  6. tell 你什么?如果更改其内容,会如何改变其输出?

  7. 什么是 do?为什么仅使用 commit 是不够的?

  8. 非快进错误拒绝意味着什么?处理这种错误最常见的方法是什么?

可选:仅为了乐趣

  • 将歌曲歌词转换为 System Programming 和本 wiki 书籍中涵盖的 C 代码,并在课程论坛上分享。

  • 在你看来,网上最好的和最差的 C 代码是什么?请在课程论坛上发布链接。

  • 编写一个包含故意细微 C 错误的短 C 程序,并在课程论坛上发布,看看其他人是否能发现你的错误。

  • 你有没有听说过任何酷的/灾难性的系统编程错误?请随时与你的同学和课程工作人员在课程论坛上分享。

伊利诺伊大学具体指南

课程论坛

助教和学生助理会收到大量问题。有些是经过充分研究的,有些则不是。这是一份有用的指南,可以帮助你从后者转向前者。哦,我是否提到这是一个向实习经理得分的好方法?问问自己...

  1. 我是否在我的虚拟机上运行?

  2. 我是否检查了手册页面?

  3. 我是否在课程论坛上搜索了类似的问题/后续问题?

  4. 我是否完全阅读了 MP/实验室规范?

  5. 我是否观看了所有的视频?

  6. 如果需要,我是否搜索了错误消息及其变体?StackOverflow 呢?

  7. 我是否尝试逐步注释、打印和/或逐步执行代码的一部分,以找出错误的确切位置?

  8. 我是否将代码提交到 git,以防助教需要更多上下文?

  9. 我是否在我的课程论坛帖子中包含了控制台/GDB/Valgrind 输出以及围绕错误的代码?

  10. 我是否修复了与我遇到的问题无关的其他段错误?

  11. 我是否遵循了良好的编程实践?(例如封装、限制重复的函数等)

当你在课程论坛上提问并希望快速得到答案时,我们可以给你提供的最大提示是像尝试回答问题一样提问。就像在提问之前,先尝试自己回答。如果你在考虑发布

嗨,我的代码得了 50 分

听起来很好也很礼貌,但课程工作人员更希望看到以下类似的帖子

嗨,我最近在测试 X、Y、Z 中失败了,这些测试大约占当前作业的一半。我注意到它们都与网络和 epoll 有关,但我无法弄清楚它们是如何相互关联的,或者我可能完全偏离了轨道。所以为了测试我的想法,我尝试用各种 get 和 put 请求启动了 1000 个客户端,并验证文件与它们的原始文件匹配。我在正常运行、调试构建、valgrind 或 tsan 时都无法让它失败。我没有收到任何警告,也没有任何预语法检查显示给我任何信息。你能告诉我我对失败的理解是否正确,以及我如何修改我的测试以更好地反映 X、Y、Z 吗?netid: bvenkat2

你不必表现得那么有礼貌,尽管我们会感激,但这会更快地得到回应。如果你试图回答这个问题,你会在问题正文中找到你需要的一切。

C 编程语言

注意:本章内容较长,涉及许多细节。如果您对某些部分已有经验,可以自由地略过。

C 语言是进行严肃系统编程的事实上的编程语言。为什么?大多数内核都通过 C 语言提供 API。Linux 内核(Love 2010)和基于 C 的 XNU 内核(Inc. 2017),MacOS 就是基于这个内核的,都是用 C 语言编写的,并且提供了 C API - 应用程序编程接口。Windows 内核使用 C++,但在 Windows 上进行系统编程比 UNIX 对新手系统程序员来说要困难得多。C 语言没有类和资源获取初始化(RAII)这样的抽象,这有助于清理内存。C 语言也给你提供了更多机会“自食其果”,但它让你能够在更精细的层面上做事。

C 语言的历史

C 语言是由 Dennis Ritchie 和 Ken Thompson 在 1973 年于贝尔实验室开发的(Ritchie 1993)。当时,我们已经有像 Fortran、ALGOL 和 LISP 这样的编程语言瑰宝。C 语言的目的是双重的。首先,它被设计用来针对当时最流行的计算机,例如 PDP-7。其次,它试图去除一些低级构造(如管理寄存器和编写汇编代码进行跳转),并创建一种能够以可读的代码形式程序化地表达程序(与 LISP 中的数学化表达相对)的语言。同时,它仍然具有与操作系统接口的能力。这听起来像是一项艰巨的任务。最初,它仅在贝尔实验室内部与 UNIX 操作系统一起使用。

第一次“真正”的标准化是由 Brian Kernighan 和 Dennis Ritchie 的书籍(Kernighan and Ritchie 1988)实现的。这本书至今仍被广泛认为是唯一的可移植 C 指令集。K&R 书籍被称为学习 C 的实际标准。虽然从 ANSI 到 ISO 存在着不同的 C 标准,但 ISO 作为一种语言规范在很大程度上取得了胜利。我们将主要关注的是 POSIX C 库,它扩展了 ISO 标准。现在,为了把大象从房间里请出来,Linux 内核未能符合 POSIX 标准。这主要是因为 Linux 开发者不愿意支付合规费用。这也是因为他们不想完全符合众多不同的标准,因为这意味着维护合规性会增加开发成本。

我们将致力于使用 C99 标准,因为它是大多数计算机都认可的,但有时也会使用一些新的 C11 功能。我们还将讨论一些附带功能,因为它们与 GNU C 库广泛使用。我们将首先提供一个相当全面的关于语言及其功能的概述。如果您已经使用过基于 C 的语言,可以自由地略过。

功能

  • 速度。程序和系统之间几乎没有区别。

  • 简单性。C 语言及其标准库包含一组简单的可移植函数。

  • 手动内存管理。C 语言赋予程序管理其内存的能力。然而,如果程序有内存错误,这可能会成为一个缺点。

  • 通用性。通过外函数接口(FFI)和各种类型的语言绑定,大多数其他语言都可以调用 C 函数,反之亦然。标准库无处不在。C 语言作为一门流行的语言,经受了时间的考验,看起来它似乎不会走向何方。

C 语言快速入门

学习 C 的经典方式是从“hello world”程序开始。Kernighan 和 Ritchie 很久以前提出的原始示例并没有改变。

#include <stdio.h>
int main(void) {
 printf("Hello World\n");
 return 0;
}
  1. 指令取自操作系统中的文件(代表标准输入和输出),复制文本,并将其替换到相应的位置。

  2. 是一个函数声明。第一个词告诉编译器函数的返回类型。括号()之前的部分是函数名。在 C 语言中,单个编译程序中不能有两个具有相同名称的函数,尽管共享库可能可以。然后是参数列表。当我们为常规函数提供参数列表时,意味着编译器如果函数以非零参数数量被调用,则应产生错误。对于具有类似声明的常规函数,意味着函数可以像这样调用,因为没有分隔符。是一个特殊函数。声明的方式有很多,但标准的方式是 , , 和 。

  3. 是一个函数调用。定义为的一部分。该函数已被编译,并位于我们的机器上的其他位置 - C 标准库的位置。只需记住包含头文件,并使用适当的参数(字符串字面量)调用函数。如果不包含换行符,缓冲区将不会被刷新(即写入不会立即完成)。

  4. 必须返回一个整数。按照惯例,0 表示成功,其他任何值都表示失败。以下是一些具有特殊意义的退出代码/状态:tldp.org/LDP/abs/html/exitcodes.html。一般来说,假设 0 表示成功。

$ gcc main.c -o main
$ ./main
Hello World
$
  1. 是 GNU 编译器集合的缩写,它包含一系列可供使用的编译器。编译器根据扩展名推断你正在尝试编译一个 .c 文件。

  2. 告诉你的 shell 在当前目录下执行名为 main 的程序。然后程序会打印出 "hello world"。

如果系统编程像编写“hello world”一样简单,我们的工作就会容易得多。

预处理器

预处理器是什么?预处理是编译器在实际编译程序之前执行的一种复制和粘贴操作。以下是一个替换示例

// Before preprocessing
#define MAX_LENGTH 10
char buffer[MAX_LENGTH]

// After preprocessing
char buffer[10]

虽然预处理程序有一些副作用。一个问题是要能够正确地进行标记化,这意味着尝试使用预处理程序重新定义 C 语言的内部结构可能是不可行的。另一个问题是它们不能无限嵌套 - 存在一个有限的深度,它们需要停止。宏也是简单的文本替换,没有语义。例如,看看如果宏尝试执行内联修改会发生什么。

#define min(a,b) a < b ? a : b
int main() {
 int x = 4;
 if(min(x++, 5)) printf("%d is six", x);
 return 0;
}

宏是简单的文本替换,所以上面的例子展开为

x++ < 5 ? x++ : 5

在这种情况下,打印出的内容是透明的,但将会是 6。你能试着找出原因吗?还要考虑当运算符优先级起作用时的边缘情况。

int x = 99;
int r = 10 + min(99, 100); // r is 100!
// This is what it is expanded to
int r = 10 + 99 < 100 ? 99 : 100
// Which means
int r = (10 + 99) < 100 ? 99 : 100

某些参数的灵活性也存在逻辑问题。一个常见的混淆来源是与静态数组和运算符。

#define ARRAY_LENGTH(A) (sizeof((A)) / sizeof((A)[0]))
int static_array[10]; // ARRAY_LENGTH(static_array) = 10
int* dynamic_array = malloc(10); // ARRAY_LENGTH(dynamic_array) = 2 or 1 consistently

宏有什么问题?嗯,如果传递了一个静态数组,它就会工作,因为静态数组返回数组占用的字节数,除以它就会给出条目数。但如果传递一个内存块的指针,取指针的 sizeof 并除以第一个条目的大小,并不总是给出数组的大小。

语言设施

关键字

C 有一系列的关键字。以下是您应该简要了解的 C99 中的某些构造。

  1. 是在 case 语句或循环语句中使用的关键字。当在 case 语句中使用时,程序会跳转到块的末尾。

    switch(1) {
     case 1: /* Goes to this switch */
     puts("1");
     break; /* Jumps to the end of the block */
     case 2: /* Ignores this program */
     puts("2");
     break;
    } /* Continues here */
    

    在循环的上下文中,使用它会跳出最内层的循环。循环可以是 for、while 或 do-while 构造。

    while(1) {
     while(2) {
     break; /* Breaks out of while(2) */
     } /* Jumps here */
     break; /* Breaks out of while(1) */
    } /* Continues here */
    
  2. 是一个语言级构造,告诉编译器该数据应该保持不变。如果尝试更改 const 变量,程序将无法编译。当放在类型之前时,编译器会重新排序第一个类型和 const。然后编译器使用左结合规则。这意味着指针左侧的内容是常量。这被称为 const-correctness。

    const int i = 0; // Same as "int const i = 0"
    char *str = ...; // Mutable pointer to a mutable string
    const char *const_str = ...; // Mutable pointer to a constant string
    char const *const_str2 = ...; // Same as above
    const char *const const_ptr_str = ...;
    // Constant pointer to a constant string
    

    但是,重要的是要知道这是一个编译器强加的限制。有绕过这个限制的方法,并且程序将以定义良好的行为运行。在系统编程中,唯一不能写入的内存类型是系统写保护的内存。

    const int i = 0; // Same as "int const i = 0"
    (*((int *)&i)) = 1; // i == 1 now
    const char *ptr = "hi";
    *ptr = '\0'; // Will cause a Segmentation Violation
    
  3. 是仅存在于循环构造中的控制流语句。Continue 会跳过循环主体的其余部分,并将程序计数器设置回循环之前的开始位置。

    int i = 10;
    while(i--) {
     if(1) continue; /* This gets triggered */
     *((int *)NULL) = 0;
    } /* Then reaches the end of the while loop */
    
  4. 是另一个循环构造。这些循环执行主体并在循环底部检查条件。如果条件为零,则执行下一个语句 - 程序计数器设置为循环之后的第一个指令。否则,循环主体将被执行。

    int i = 1;
    do {
     printf("%d\n", i--);
    } while (i > 10) /* Only executed once */
    
  5. 是用来声明枚举的关键字。枚举是一种可以取许多有限值的数据类型。如果你有一个枚举并且没有指定任何数字,C 编译器将为该枚举生成一个唯一的数字(在当前枚举的上下文中),并用于比较。声明枚举实例的语法是 。这个附加的好处是编译器可以对这些表达式进行类型检查,以确保你只比较相同类型的值。

    enum day{ monday, tuesday, wednesday,
     thursday, friday, saturday, sunday};
    
    void process_day(enum day foo) {
     switch(foo) {
     case monday:
     printf("Go home!\n"); break;
     // ...
     }
    }
    

    完全可以将枚举值分配为不同或相同的。如果你分配数字,不建议依赖于编译器的一致编号。如果你打算使用这个抽象,尽量别打破它。

    enum day{
     monday = 0,
     tuesday = 0,
     wednesday = 0,
     thursday = 1,
     friday = 10,
     saturday = 10,
     sunday = 0};
    
    void process_day(enum day foo) {
     switch(foo) {
     case monday:
     printf("Go home!\n"); break;
     // ...
     }
    }
    
  6. 是一个特殊的关键字,告诉编译器该变量可能定义在另一个对象文件或库中,因此程序在缺少变量时也能编译,因为程序将引用系统或另一个文件中的变量。

    // file1.c
    extern int panic;
    
    void foo() {
     if (panic) {
     printf("NONONONONO");
     } else {
     printf("This is fine");
     }
    }
    
    //file2.c
    
    int panic = 1;
    
  7. 是一个允许你使用初始化条件、循环不变式和更新条件进行迭代的关键字。这旨在与 while 循环等价,但语法不同。

    for (initialization; check; update) {
     //...
    }
    
    // Typically
    int i;
    for (i = 0; i < 10; i++) {
     //...
    }
    

    根据 C89 标准,不能在循环初始化块中声明变量。这是因为关于在循环中定义的变量的作用域规则在标准中存在分歧。随着更近期的标准的出现,这个问题已经得到了解决,因此人们现在可以使用他们今天所熟悉和喜爱的 for 循环。

    for(int i = 0; i < 10; ++i) {
    

    循环的评估顺序如下

    1. 执行初始化语句。

    2. 检查不变式。如果为假,则终止循环并执行下一个语句。如果为真,则继续到循环体。

    3. 执行循环体。

    4. 执行更新语句。

    5. 跳转到检查不变式步骤。

  8. 是一个允许你进行条件跳转的关键字。不要在你的程序中使用它。原因是当与多个链串在一起时,它会使你的代码难以理解,这被称为意大利面代码。尽管如此,在某些情况下是可以接受的,例如 Linux 内核中的错误检查代码。当添加另一个堆栈帧进行清理不是一个好主意时,通常在内核上下文中使用该关键字。内核清理的典型例子如下。

    void setup(void) {
    Doe *deer;
    Ray *drop;
    Mi *myself;
    
    if (!setupdoe(deer)) {
     goto finish;
    }
    
    if (!setupray(drop)) {
     goto cleanupdoe;
    }
    
    if (!setupmi(myself)) {
     goto cleanupray;
    }
    
    perform_action(deer, drop, myself);
    
    cleanupray:
    cleanup(drop);
    cleanupdoe:
    cleanup(deer);
    finish:
    return;
    }
    
  9. 是控制流关键字。有几种使用这些关键字的方法(1)裸 if(2)带有 else 的 if(3)带有 else-if 的 if(4)带有 else if 和 else 的 if。注意,else 与最近的 if 匹配。与不匹配的 if 和 else 语句相关的一个微妙错误是悬空 else 问题。语句总是从 if 执行到 else。如果任何中间条件为真,if 块执行该操作并转到该块的末尾。

    // (1)
    
    if (connect(...))
     return -1;
    
    // (2)
    if (connect(...)) {
     exit(-1);
    } else {
     printf("Connected!");
    }
    
    // (3)
    if (connect(...)) {
     exit(-1);
    } else if (bind(..)) {
     exit(-2);
    }
    
    // (1)
    if (connect(...)) {
     exit(-1);
    } else if (bind(..)) {
     exit(-2);
    } else {
     printf("Successfully bound!");
    }
    
  10. 是一个编译器关键字,告诉编译器可以省略 C 函数调用过程并将代码“粘贴”到被调用函数中。相反,编译器被提示直接将函数体替换到调用函数中。这并不总是建议明确这样做,因为编译器通常足够智能,知道何时为你生成函数。

    inline int max(int a, int b) {
     return a < b ? a : b;
    }
    
    int main() {
     printf("Max %d", max(a, b));
     // printf("Max %d", a < b ? a : b);
    }
    
  11. 是一个关键字,告诉编译器这个特定的内存区域不应与其他所有内存区域重叠。这种用例是告诉程序的用户,如果内存区域重叠,则这是未定义的行为。请注意,当内存区域重叠时,memcpy 有未定义的行为。如果这种情况可能出现在你的程序中,考虑使用 memmove。

    memcpy(void * restrict dest, const void* restrict src, size_t bytes);
    
    void add_array(int *a, int * restrict c) {
     *a += *c;
    }
    int *a = malloc(3*sizeof(*a));
    *a = 1; *a = 2; *a = 3;
    add_array(a + 1, a) // Well defined
    add_array(a, a) // Undefined
    
  12. 是一个控制流运算符,用于退出当前函数。如果函数是空的,它就简单地退出函数。否则,另一个参数作为返回值跟随。

    void process() {
     if (connect(...)) {
     return -1;
     } else if (bind(...)) {
     return -2
     }
     return 0;
    }
    
  13. 是一个很少使用的修饰符,它强制类型被定义为有符号而不是无符号。这个修饰符之所以很少使用,是因为类型默认是有符号的,需要使用修饰符来将其定义为无符号,但在某些情况下可能很有用,例如当你想让编译器默认使用有符号类型,如下所示。

    int count_bits_and_sign(signed representation) {
     //...
    }
    
  14. 是一个在编译时评估的运算符,其结果为表达式包含的字节数。当编译器推断类型时,以下代码会发生变化。

    char a = 0;
    printf("%zu", sizeof(a++));
    
    char a = 0;
    printf("%zu", 1);
    

    然后,编译器可以进一步操作。编译器必须在编译时(而不是链接时)有一个类型的完整定义,否则你可能会得到一个奇怪的错误。考虑以下情况

    // file.c
    struct person;
    
    printf("%zu", sizeof(person));
    
    // file2.c
    
    struct person {
     // Declarations
    }
    

    这段代码无法编译,因为 sizeof 无法在没有知道结构完整声明的情况下编译。这就是为什么程序员要么在头文件中放置完整的声明,要么抽象创建和交互,以便用户无法访问我们结构内部的原因。此外,如果编译器知道数组对象的完整长度,它将使用该长度而不是将其退化成指针。

    char str1[] = "will be 11";
    char* str2 = "will be 8";
    sizeof(str1) //11 because it is an array
    sizeof(str2) //8 because it is a pointer
    

    小心,使用 sizeof 来获取字符串长度!

  15. 是一个具有三种含义的类型说明符。

    1. 当与全局变量或函数声明一起使用时,表示变量或函数的作用域仅限于文件。

    2. 当与函数变量一起使用时,表示该变量具有静态分配,意味着变量在程序启动时只分配一次,而不是每次程序运行时都分配,其生命周期延长到程序的生命周期。

    // visible to this file only
    static int i = 0;
    
    static int _perform_calculation(void) {
     // ...
    }
    
    char *print_time(void) {
     static char buffer[200]; // Shared every time a function is called
     // ...
    }
    
  16. 是一个关键字,允许你将多个类型配对组合成一个新的结构。C-struct 是连续的内存区域,可以像访问独立的变量一样访问每个内存中的特定元素。请注意,元素之间可能存在填充,使得每个变量都是内存对齐的(从其大小的倍数开始的内存地址开始)。

    struct hostname {
     const char *port;
     const char *name;
     const char *resource;
    }; // You need the semicolon at the end
    // Assign each individually
    struct hostname facebook;
    facebook.port = "80";
    facebook.name = "www.google.com";
    facebook.resource = "/";
    
    // You can use static initialization in later versions of c
    struct hostname google = {"80", "www.google.com", "/"};
    
  17. 开关语句本质上是一种被美化的跳转语句。这意味着你取一个字节或一个整数,程序的流程控制就会跳转到那个位置。注意,switch 语句的各个 case 会连续执行。这意味着如果执行从一个 case 开始,控制流将继续到所有后续的 case,直到遇到 break 语句。

    switch(/* char or int */) {
     case INT1: puts("1");
     case INT2: puts("2");
     case INT3: puts("3");
    }
    

    如果我们给一个值为 2,那么

    switch(2) {
     case 1: puts("1"); /* Doesn't run this */
     case 2: puts("2"); /* Runs this */
     case 3: puts("3"); /* Also runs this */
    }
    

    这其中更有名的例子是 Duff 的设备,它允许循环展开。你不需要为了这门课程理解这段代码,但看看它很有趣(Duff,n.d.)。

    send(to, from, count)
    register short *to, *from;
    register count;
    {
     register n=(count+7)/8;
     switch(count%8){
     case 0:	do{	*to = *from++;
     case 7:		*to = *from++;
     case 6:		*to = *from++;
     case 5:		*to = *from++;
     case 4:		*to = *from++;
     case 3:		*to = *from++;
     case 2:		*to = *from++;
     case 1:		*to = *from++;
     }while(--n>0);
     }
    }
    

    这段代码突出了 switch 语句是 goto 语句,你可以在 switch case 的另一端放置任何代码。大多数时候这没有意义,有时这太过有意义。

  18. 声明了一个类型的别名。通常与结构体一起使用,以减少在类型中写“struct”时的视觉混乱。

    typedef float real;
    real gravity = 10;
    // Also typedef gives us an abstraction over the underlying type used.
    // In the future, we only need to change this typedef if we
    // wanted our physics library to use doubles instead of floats.
    
    typedef struct link link_t;
    //With structs, include the keyword 'struct' as part of the original types
    

    在这个课程中,我们经常使用 typedef 来定义函数。例如,一个函数的 typedef 可以是这样的

    typedef int (*comparator)(void*,void*);
    
    int greater_than(void* a, void* b){
     return a > b;
    }
    comparator gt = greater_than;
    

    这声明了一个函数类型比较器,它接受两个参数并返回一个整数。

  19. 是一个新的类型说明符。联合体是一块内存,许多变量都占用这块内存。它用于在保持一致性的同时,具有在类型之间切换的灵活性,而无需维护跟踪位的函数。考虑一个例子,我们有不同的像素值。

    union pixel {
     struct values {
     char red;
     char blue;
     char green;
     char alpha;
     } values;
     uint32_t encoded;
    }; // Ending semicolon needed
    union pixel a;
    // When modifying or reading
    a.values.red;
    a.values.blue = 0x0;
    
    // When writing to a file
    fprintf(picture, "%d", a.encoded);
    
  20. 是一个类型修饰符,它强制修改它们的变量具有特定行为。无符号类型只能与原始的 int 类型(如和)一起使用。与无符号算术相关联有很多行为。在大多数情况下,除非你的代码涉及位移动,否则了解无符号和有符号算术之间的行为差异不是必需的。

  21. 是一个双关语关键字。在函数或参数定义的术语中使用时,它表示函数明确返回没有值或接受没有参数。以下声明了一个不接受参数且不返回任何值的函数。

    void foo(void);
    

    另一个使用指针的场景是当你定义一个类型别名时。指针只是一个内存地址。它被指定为一个不完整的类型,这意味着你不能取消引用它,但它可以被提升为任何其他类型。使用此指针进行指针算术是未定义的行为。

    int *array = void_ptr; // No cast needed
    
  22. 是一个编译器关键字。这意味着编译器不应该优化其值。考虑以下简单的函数。

    int flag = 1;
    pass_flag(&flag);
    while(flag) {
     // Do things unrelated to flag
    }
    

    编译器可能会这样做,因为 while 循环的内部与标志无关,可以优化为以下形式,即使函数可能会改变数据。

    while(1) {
     // Do things unrelated to flag
    }
    

    如果你使用 volatile 关键字,编译器被迫将变量保留在内存中并执行该检查。这在多进程或多线程程序中很有用,这样我们就可以用另一个序列的执行来影响一个序列的运行。

  23. 代表传统的循环。循环顶部有一个条件,在每次执行循环体之前都会检查这个条件。如果条件评估为非零值,则循环体会被执行。

C 数据类型

C 中有许多数据类型。正如你可能意识到的,它们要么是整数,要么是浮点数,其他类型是这些类型的变体。

  1. 表示正好一个字节数据。字节中的位数可能不同,但总是相同的大小,这对于所有数据类型的所有版本都是正确的。这必须在边界上对齐(这意味着你无法在两个地址之间使用位)。其余类型将假设一个字节中有 8 位。

  2. 至少需要两个字节。这是在两个字节边界上对齐的,这意味着地址必须是 2 的倍数。

  3. 至少需要两个字节。再次对齐到两个字节边界(“ISO C 标准” 2005 第 34 页)。在大多数机器上这将是对齐到 4 个字节。

  4. 至少需要四个字节,对齐到四个字节边界。在某些机器上这可以是 8 个字节。

  5. 至少需要八个字节,对齐到八个字节边界。

  6. 代表由 IEEE(“IEEE 标准浮点算术” 2008)紧密指定的 IEEE-754 单精度浮点数。在大多数机器上,这将是对齐到四个字节边界的四个字节。

  7. 代表由同一标准指定的 IEEE-754 双精度浮点数,对齐到最近的八个字节边界。

如果你需要一个固定宽度的整数类型,为了更可移植的代码,你可以使用在 stdint.h 中定义的类型,其形式为[u]intwidth_t,其中 u(这是可选的)表示有符号性,而 width 是 8、16、32 和 64 中的任何一个。

操作符

操作符是 C 语言中作为语言语法一部分定义的语言构造。这些操作符按照优先级顺序列出。

  • 是下标操作符。其中是数字类型,是指针类型。

  • 是结构解引用(或箭头)操作符。如果你有一个指向结构的指针,你可以使用这个操作符来访问其元素之一。

  • 是结构引用操作符。如果你有一个对象,你可以访问一个元素。

  • 是一元加法和减法操作符。它们分别保留或否定整数或浮点类型的符号。

  • 是解引用操作符。如果你有一个指针,你可以使用这个操作符来访问位于这个内存地址的元素。如果你正在读取,返回值将是底层类型的大小。如果你正在写入,值将以偏移量写入。

  • 是取地址操作符。它接受一个元素并返回其地址。

  • 是增量操作符。你可以将其用作前缀或后缀,这意味着正在增加的变量可以在操作符之前或之后。和。

  • 是减量操作符。这与增量操作符具有相同的语义,除了它将变量的值减少一个。

  • 是 sizeof 运算符,它在编译时评估。这也在关键字部分提到。

  • 其中是算术二进制运算符。如果操作数都是数字类型,那么操作分别是加、减、乘、取模和除。如果左操作数是指针而右操作数是整数类型,那么只能使用加或减,并调用指针算术的规则。

  • 是位移运算符。右边的操作数必须是一个整数类型,其符号会被忽略,除非它是负数,在这种情况下,行为是未定义的。左边的运算符决定了大量的语义。如果我们进行左移,则右侧总会引入零。如果我们进行右移,则有一些不同的情况

    • 如果左边的操作数是有符号的,则整数会被符号扩展。这意味着如果数字设置了符号位,则任何右移都会在左侧引入 1。如果数字没有设置符号位,任何右移都会在左侧引入零。

    • 如果操作数是无符号的,无论哪种方式,都会在左侧引入零。

    unsigned short uns = -127; // 1111111110000001
    short sig = 1; // 0000000000000001
    uns << 2; // 1111111000000100
    sig << 2; // 0000000000000100
    uns >> 2; // 0011111111100000
    sig >> 2; // 0000000000000000
    

    注意,以字大小(例如,在 64 位架构中以 64 位)进行位移会导致未定义的行为。

  • 是大于等于/小于等于关系运算符。它们按照名称所暗示的那样工作。

  • 是大于/小于关系运算符。它们再次按照名称所暗示的那样执行。

  • 是等于/不等于关系运算符。它们再次按照名称所暗示的那样执行。

  • 是逻辑与运算符。如果第一个操作数是零,则第二个操作数不会被评估,表达式将评估为 0。否则,它产生第二个操作数的 1-0 值。

  • 是逻辑或运算符。如果第一个操作数不是零,则第二个操作数不会被评估,表达式将评估为 1。否则,它产生第二个操作数的 1-0 值。

  • 是逻辑非运算符。如果操作数是零,则返回 1。否则,返回 0。

  • 是按位与运算符。如果两个操作数中都设置了位,则输出中也会设置。否则,它不会设置。

  • 是按位或运算符。如果任一操作数中设置了位,则输出中也会设置。否则,它不会设置。

  • 是按位非运算符。如果输入中设置了位,则输出中不会设置,反之亦然。

  • 是三元/条件运算符。你将布尔条件放在冒号之前,如果它评估为非零,则返回冒号之前的元素,否则返回冒号之后的元素。

  • 是逗号运算符。先评估,然后评估,并返回。在由逗号分隔的多个语句序列中,从左到右评估所有语句,并返回最右边的表达式。

C 和 Linux

到目前为止,我们已经涵盖了 C 的语言基础。现在,我们将关注 C 和可与我们交互的 POSIX 类型的函数。我们将讨论可移植函数,例如。我们将评估它们的内部结构,并在 POSIX 模型和更具体的 GNU/Linux 下仔细审查。有几个关于这种哲学的东西使得了解其余部分更容易,所以我们将把这些东西放在这里。

万物皆文件

一个 POSIX 口诀是“万物皆文件”。尽管这最近已经过时,而且更加错误,但我们今天仍然使用这个约定。这个声明意味着一切都是文件描述符,它是一个整数。例如,这里有一个文件对象,一个网络套接字,和一个内核对象。这些都是对内核文件描述符表中的记录的引用。

int file_fd = open(...);
int network_fd = socket(...);
int kernel_fd = epoll_create1(...);

对这些对象的操作是通过系统调用完成的。在我们继续之前,最后要注意的一点是,文件描述符仅仅是指针。想象一下,示例中的每个文件描述符实际上都指向操作系统从中选择和选择的对象表中的一个条目(即文件描述符表)。对象可以被分配和释放,关闭和打开等。程序通过使用通过系统调用指定的 API 和库函数与这些对象交互。

系统调用

在我们深入探讨常见的 C 函数之前,我们需要知道什么是系统调用。如果你是一名学生并且已经完成了 HW0,你可以自由地跳过这一节。

系统调用是内核执行的操作。首先,操作系统准备一个系统调用。接下来,内核尽其所能地在内核空间执行系统调用,这是一个特权操作。在先前的例子中,我们获得了文件描述符对象的访问权限。现在我们也可以向代表文件的文件描述符对象写入一些字节,操作系统将尽力将这些字节写入磁盘。

write(file_fd, "Hello!", 6);

当我们说内核尽力而为时,这包括操作可能因多种原因失败的可能性。其中一些原因是:文件不再有效,硬盘故障,系统中断等。程序员与外部系统通信的方式是通过系统调用。需要注意的是,系统调用是昂贵的。它们在时间和 CPU 周期上的成本最近已经降低,但尽可能少地使用它们。

C 系统调用

在下一节中将要讨论的许多 C 函数都是抽象,它们根据当前平台调用正确的底层系统调用。例如,它们的 Windows 实现可能与其他操作系统完全不同。尽管如此,我们将从它们的 Linux 实现的角度来研究这些函数。

常见 C 函数

要获取有关任何函数的更多信息,请使用手册页。注意,手册页组织成几个部分。第二部分是系统调用。第三部分是 C 库。在网上,使用 Google。在 shell 中,或

错误处理

在我们深入所有函数的细节之前,要知道在 C 中,大多数处理错误返回的函数与像 C++或 Java 这样的编程语言中用异常处理错误的方法相矛盾。有多个反对异常的论点。

  1. 异常使控制流程更难以理解。

  2. 异常导向的语言需要保留堆栈跟踪和维护跳转表。

  3. 异常可能是复杂对象。

关于异常也有一些论点

  1. 异常可能来自多层深处。

  2. 异常有助于减少全局状态。

  3. 异常区分业务逻辑和正常流程。

无论优点/缺点如何,我们使用前者是因为与像 FORTRAN 这样的语言向后兼容(“FORTRAN IV PROGRAMMER’S REFERENCE MANUAL” 1972 P. 84)。每个线程都会得到一个副本,因为它存储在每个线程堆栈的顶部——关于线程的更多内容将在后面讨论。当调用一个可能返回错误的函数时,如果该函数根据手册页返回错误,则程序员需要检查 errno。

#include <errno.h>

FILE *f = fopen("/does/not/exist.txt", "r");
if (NULL == f) {
 fprintf(stderr, "Errno is %d\n", errno);
 fprintf(stderr, "Description is %s\n", strerror(errno));
}

有一个快捷函数可以打印 errno 的英文描述。此外,一个函数可能将其错误代码作为返回值本身返回。

int s = getnameinfo(...);
if (0 != s) {
 fprintf(stderr, "getnameinfo: %s\n", gai_strerror(s));
}

一定要检查手册页以了解返回代码的特征。

输入/输出

在本节中,我们将涵盖标准库中的所有基本输入和输出函数,并参考系统调用。每个进程在开始执行时都有三个数据流:标准输入(用于程序输入)、标准输出(用于程序输出)和标准错误(用于错误和调试消息)。通常,标准输入来自运行程序的终端,而标准输出是相同的终端。然而,程序员可以使用重定向,使他们的程序能够将输出和/或输入发送到文件或其他程序。

它们分别由文件描述符 0 和 1 指定。2 保留用于标准错误,按照库的惯例是不缓存的(即 IO 操作立即执行)。

以 stdout 为导向的流

标准输出或 stdout 导向的流是只有写入 stdout 选项的流。这是大多数人熟悉的此类函数。第一个参数是一个包含要打印的数据占位符的格式字符串。常见的格式说明符如下

  1. 将参数视为 C 字符串指针,持续打印所有字符,直到遇到 NULL 字符。

  2. 将参数打印为整数。

  3. 将参数打印为内存地址。

为了性能,缓冲数据直到其缓存满或打印换行符。以下是一个打印内容的示例。

char *name = ... ; int score = ...;
printf("Hello %s, your result is %d\n", name, score);
printf("Debug: The string and int are stored at: %p and %p\n", name, &score );
// name already is a char pointer and points to the start of the array.
// We need "&" to get the address of the int variable

从上一节中,调用系统调用。是 C 库函数,而则是系统调用 system。

printf 的缓冲语义稍微复杂一些。ISO 定义了三种类型的流(“ISO C 标准” 2005 第 278 页)

  • 无缓冲,其中流的内容尽可能快地到达目的地。

  • 行缓冲,其中流的内容一旦提供换行符就会到达目的地。

  • 完全缓冲,其中流的内容一旦缓冲区满就会到达目的地。

标准错误被定义为“非完全缓冲”(“ISO C 标准” 2005 第 279 页)。标准输出和输入仅当流目的地不是交互式设备时才被定义为完全缓冲。通常,标准错误将不被缓冲,如果输出是终端,则标准输入和输出将是行缓冲,否则是完全缓冲。这与 printf 有关,因为 printf 仅使用 FILE 接口提供的抽象,并使用上述语义来确定何时写入。可以通过在流上调用 fflush()来强制写入。

要打印字符串和单个字符,使用和

puts("Current selection: ");
putchar('1');

其他流

要向其他文件流打印,使用,其中 file 是预定义的(‘stdout’或‘stderr’)或由或返回的 FILE 指针。有一个与文件描述符一起工作的 printf 等效函数,称为 dprintf。只需使用。

要将数据打印到 C 字符串中,使用或更好。返回写入的字符数,不包括终止字节。我们会使用打印的字符串大小小于提供的缓冲区大小 – 考虑到打印整数,它将永远不会超过 11 个字符加上空字节。如果 printf 处理可变参数输入,则使用前面所示的前一个函数更安全。

// Fixed
char int_string[20];
sprintf(int_string, "%d", integer);

// Variable length
char result[200];
int len = snprintf(result, sizeof(result), "%s:%d", name, score);

以 stdin 为方向的函数

标准输入或 stdin 方向的函数直接从 stdin 读取。大多数这些函数由于设计不佳而被弃用。这些函数将 stdin 视为一个我们可以从中读取字节的文件。最臭名昭著的违规者是。它在 C99 标准中已被弃用,并被从最新的 C 标准(C11)中删除。它被弃用的原因是无法控制读取的长度,因此缓冲区容易被溢出。当这种操作被恶意用于劫持程序控制流时,这被称为缓冲区溢出。

程序应使用或代替。以下是一个从标准输入读取最多 10 个字符的快速示例。

char *fgets (char *str, int num, FILE *stream);

ssize_t getline(char **lineptr, size_t *n, FILE *stream);

// Example, the following will not read more than 9 chars
char buffer[10];
char *result = fgets(buffer, sizeof(buffer), stdin);

注意,与不同,它会将换行符复制到缓冲区中。另一方面,的其中一个优点是,会自动在堆上分配和重新分配足够大小的缓冲区。

// ssize_t getline(char **lineptr, size_t *n, FILE *stream);

/* set buffer and size to 0; they will be changed by getline */
char *buffer = NULL;
size_t size = 0;

ssize_t chars = getline(&buffer, &size, stdin);

// Discard newline character if it is present,
if (chars > 0 && buffer[chars-1] == '\n')
buffer[chars-1] = '\0';

// Read another line.
// The existing buffer will be re-used, or, if necessary,
// It will be `free`'d and a new larger buffer will `malloc`'d
chars = getline(&buffer, &size, stdin);

// Later... don't forget to free the buffer!
free(buffer);

除了那些功能之外,我们还有一个具有双重含义的函数。比如说,如果函数调用失败,按照 errno 约定,将会将错误的英文版本打印到 stderr。

int main(){
 int ret = open("IDoNotExist.txt", O_RDONLY);
 if(ret < 0){
 perror("Opening IDoNotExist:");
 }
 //...
 return 0;
}

要使用库函数解析输入,除了读取输入外,可以使用(或或)分别从默认输入流、任意文件流或 C 字符串中获取输入。所有这些函数都将返回解析的项目数量。检查这个数字是否等于预期的数量是个好主意。此外,像这样的函数自然需要有效的指针。它们不仅需要指向有效的内存,还需要可写。传递错误的指针值是一个常见的错误来源。例如,

int *data = malloc(sizeof(int));
char *line = "v 10";
char type;
// Good practice: Check scanf parsed the line and read two values:
int ok = 2 == sscanf(line, "%c %d", &type, &data); // pointer error

我们本想将字符值写入 c,将整数值写入 malloc 分配的内存中。然而,我们传递的是数据指针的地址,而不是指针所指向的内容!所以我们将改变指针本身。现在指针将指向地址 10,因此当调用 free(data)时,这段代码将随后失败。

现在,scanf 将一直读取字符,直到字符串结束。为了防止 scanf 导致缓冲区溢出,使用格式说明符。确保传递的值比缓冲区大小少一个。

char buffer[10];
scanf("%9s", buffer); // reads up to 9 characters from input (leave room for the 10th byte to be the terminating byte)

最后要注意的一点是,如果系统调用很昂贵,由于兼容性的原因,这个系列调用更昂贵。因为它需要能够正确处理所有的 printf 说明符,所以代码效率不高 TODO:需要引用。对于高性能程序,应该自己编写解析代码。如果是一个一次性程序或脚本,可以自由使用 scanf。

string.h

String.h 函数是一系列处理如何操作和检查内存片段的函数。大多数函数都处理 C 字符串。C 字符串是一系列以 NUL 字符(等于字节 0x00)分隔的字节。有关所有这些函数的更多信息(https://linux.die.net/man/3/string)。文档中未提及的行为,如的结果,被认为是未定义的行为。

  • 返回字符串的长度。

  • 返回一个整数,确定字符串的字典序。如果 s1 在字典中排在 s2 之前,则返回-1。如果两个字符串相等,则返回 0。否则,返回 1。

  • 将字符串复制到。此函数假定 dest 有足够的空间容纳 src,否则行为未定义

  • 将字符串连接到目标字符串的末尾。此函数假定在目标字符串的末尾有足够的空间容纳,包括 NUL 字节

  • 返回字符串的’d 副本。

  • 返回在中的第一次出现处的指针。如果没有找到,则返回。

  • 与上面相同,但这次是一个字符串!

  • 一个危险但有用的函数 strtok 接受一个字符串并将其标记化。这意味着它将字符串转换为单独的字符串。此函数有很多规范,所以请阅读手册页面,以下是一个虚构的例子。

     #include <stdio.h>
     #include <string.h>
    
     int main(){
     char* upped = strdup("strtok,is,tricky,!!");
     char* start = strtok(upped, ",");
     do{
     printf("%s\n", start);
     }while((start = strtok(NULL, ",")));
     return 0;
     }
    

    输出

    strtok
    is
    tricky
    !!
    

    为什么这很棘手?好吧,当 upped 改为以下内容时会发生什么?

     char* upped = strdup("strtok,is,tricky,,,!!");
    
  • 对于整数解析,使用或。

    这些函数所做的就是取你的字符串指针和一个(即二进制、八进制、十进制、十六进制等)以及可选的指针,并返回一个解析值。

     int main(){
     const char *nptr = "1A2436";
     char* endptr;
     long int result = strtol(nptr, &endptr, 16);
     return 0;
     }
    

    但是要小心!错误处理很棘手,因为函数不会返回错误代码。如果传递了一个无效的数字字符串,它将返回 0。调用者必须小心区分有效的 0 和错误。这通常涉及到下面的 errno trampoline。

     int main(){
     const char *input = "0"; // or "!##@" or ""
     char* endptr;
     int saved_errno = errno;
     errno = 0
     long int parsed = strtol(input, &endptr, 10);
     if(parsed == 0 && errno != 0){
     // Definitely an error
     }
     errno = saved_errno;
     return 0;
     }
    
  • 从开始移动字节到。小心,当内存区域重叠时会有未定义的行为。这是经典的“在我的机器上它工作!”例子之一,因为很多时候 Valgrind 无法检测到它,因为它看起来在你的机器上工作。考虑更安全的版本。

  • 与上面做的是同一件事,但如果内存区域重叠,则可以保证所有字节都将正确复制。并且都在?

C 内存模型

C 内存模型可能与你之前见过的不同。我们不是用类型安全来分配对象,而是使用自动变量或请求一个字节序列,或者使用另一个家族成员,然后稍后我们再使用它。

结构体

在底层术语中,结构体是一块连续的内存,没有更多。就像数组一样,结构体有足够的空间来存储其所有成员。但与数组不同,它可以存储不同类型。考虑上面声明的 contact 结构体。

struct contact {
 char firstname[20];
 char lastname[20];
 unsigned int phone;
};

struct contact person;

我们经常会使用以下 typedef,这样我们就可以使用结构体名称作为完整的类型。

typedef struct contact contact;
contact person;

typedef struct optional_name {
 ...
} contact;

如果你没有进行任何优化和重新排序就编译代码,你可以期望每个变量的地址看起来像这样。

&person           // 0x100
&person.firstname // 0x100 = 0x100+0x00
&person.lastname  // 0x114 = 0x100+0x14
&person.phone     // 0x128 = 0x100+0x28

你的编译器所做的只是说“保留这么多空间”。每当代码中发生读取或写入操作时,编译器将计算变量的偏移量。偏移量是变量开始的位置。在这个编译器中,电话变量从第几个字节开始,并继续占用 sizeof(int)个字节。但是偏移量并不决定变量结束的位置。考虑以下在许多内核代码中看到的黑客技巧。

 typedef struct {
 int length;
 char c_str[0];
} string;

const char* to_convert = "person";
int length = strlen(to_convert);

// Let's convert to a c string
string* person;
person = malloc(sizeof(string) + length+1);

目前,我们的内存看起来像以下图像。那些盒子中没有任何东西

指向 11 个空盒子的结构体

指向 11 个空盒子的结构体

那么当我们分配长度时会发生什么?前四个盒子填充了长度变量的值。其余的空间保持不变。我们将假设我们的机器是大端字节序。这意味着最低有效字节是最后一个字节。

person->length = length;

指向 11 个盒子,其中 4 个填充了 0006,7 个垃圾数据的结构体

指向 11 个盒子,其中 4 个填充了 0006,7 个垃圾

现在,我们可以使用以下调用将字符串写入我们结构体的末尾。

strcpy(person->c_str, to_convert);

指向 11 个盒子,其中 4 个填充了 0006,7 个字符串“person”的结构体

指向 11 个盒子,其中 4 个填充了 0006,7 个字符串“person”

我们甚至可以进行一个合理性检查,以确保字符串相等。

strcmp(person->c_str, "person") == 0 //The strings are equal!

那个零长度数组所做的是指向结构体的末尾,这意味着编译器将为操作系统(ints,chars 等)计算的所有元素留出空间。零长度数组将占用零字节的空间。由于结构体是连续的内存块,我们可以分配更多的空间,并将额外的空间用作存储额外字节的场所。虽然这看起来像是一种花招,但它是一种重要的优化,因为以任何其他方式实现可变长度字符串,都需要进行两次不同的内存分配调用。这对于编程中如此常见的字符串操作来说效率非常低。

C 语言中的字符串

由于历史原因,在 C 语言中,我们使用空终止字符串而不是长度前缀字符串。对于日常程序员来说,记住要为你的字符串添加 NUL 终止符!在 C 语言中,字符串被定义为以空字符或 NUL 字节结束的一组字节。

字符串的位置

每次定义一个字符串字面量——形式为——该字符串就会存储在数据段中。根据你的架构,它是只读的,这意味着任何尝试修改字符串都会导致 SEGFAULT。也可以声明字符串位于可写数据段或栈中。要做到这一点,指定字符串的长度或将括号放在指针位置而不是使用指针,并将数据段或栈的相应全局作用域或函数作用域。然而,如果需要改变字符串的空间,可以将其更改为任何想要的。忘记为字符串添加 NUL 终止符会对字符串产生重大影响!边界检查很重要。书中提到的 heartbleed 漏洞部分原因就是这一点。

在 C 语言中,字符串以内存中的字符形式表示。字符串的结尾包含一个 NUL(0)字节。所以"ABC"需要四个(4)字节。找出 C 字符串长度的唯一方法就是不断读取内存,直到找到 NUL 字节。C 字符总是恰好一个字节。

字符串字面量是常量

字符串字面量自然是常量。任何写入都会导致操作系统产生 SEGFAULT。

char array[] = "Hi!"; // array contains a mutable copy
strcpy(array, "OK");

char *ptr = "Can't change me"; // ptr points to some immutable memory
strcpy(ptr, "Will not work");

字符串字面量是存储在程序只读数据段中的字符数组,它是不可变的。两个字符串字面量可能在内存中共享相同的空间。以下是一个例子。

char *str1 = "Mark Twain likes books";
char *str2 = "Mark Twain likes books";

指向和的字符串实际上可能位于内存中的相同位置。

Char 数组,然而,包含从代码段复制到栈或静态内存中的字面值。以下这些 char 数组位于不同的内存位置。

char arr1[] = "Mark Twain also likes to write";
char arr2[] = "Mark Twain also likes to write";

这里有一些常见的初始化字符串的方法。它们在内存中位于何处?

char *str = "ABC";
char str[] = "ABC";
char str[]={'A','B','C','\0'};
char ary[] = "Hello";
char *ptr = "Hello";

我们也可以轻松地打印出指针和 C 字符串的内容。以下是一些示例代码来说明这一点。

char ary[] = "Hello";
char *ptr = "Hello";
// Print out address and contents
printf("%p : %s\n", ary, ary);
printf("%p : %s\n", ptr, ptr);

如前所述,字符数组是可变的,因此我们可以更改其内容。注意在数组的范围内写入。C 语言在编译时不会进行边界检查,但无效的读取/写入可能导致程序崩溃。

strcpy(ary, "World"); // OK
strcpy(ptr, "World"); // NOT OK - Segmentation fault (crashes by default; unless SIGSEGV is blocked)

然而,与数组不同,我们可以将其更改为指向另一块内存,

ptr = "World"; // OK!
ptr = ary; // OK!
ary = "World"; // NO won't compile
// ary is doomed to always refer to the original array.
printf("%p : %s\n", ptr, ptr);
strcpy(ptr, "World"); // OK because now ptr is pointing to mutable memory (the array)

与指针不同,指针持有堆或栈上变量的地址,而字符数组(字符串字面量)指向程序数据部分中的只读内存。这意味着指针比数组更灵活,尽管数组的名称是其起始地址的指针。

在更常见的情况下,指针将指向堆内存,在这种情况下,指针所引用的内存可以被修改。

指针

指针是存储地址的变量。这些地址有一个数值,但通常程序员对内存地址处的值更感兴趣。在本节中,我们将尝试为您介绍指针的基本概念。

指针基础

声明指针

指针指的是内存地址。指针的类型很有用——它告诉编译器需要读取/写入多少字节,并定义了指针算术(加法和减法)的语义。

int *ptr1;
char *ptr2;

由于 C 语言的语法,或任何指针实际上并不是它自己的类型。你必须在每个指针变量前加上一个星号。作为一个常见的陷阱,以下

int* ptr3, ptr4;

只会将声明为指针。实际上将是一个普通的 int 变量。为了修复这个声明,确保星号在指针之前。

int *ptr3, *ptr4;

对于结构体也要记住这一点。如果没有使用 typedef 声明,那么指针将跟在类型后面。

struct person *ptr3;

使用指针进行读取/写入

假设已经声明。为了讨论的需要,让我们假设包含内存地址。要写入指针,必须取消引用并分配一个值。

*ptr = 0; // Writes some memory.

C 语言所做的是获取指针的类型,这是一个与操作符,并从指针的起始位置写入字节,这意味着字节、、、都将为零。写入的字节数取决于指针类型。对于所有原始类型来说都是如此,但结构体略有不同。

读取的方式大致相同,只是将变量放在它需要值的位置。

int doubled = *ptr * 2;

读取和写入非原始类型变得复杂。编译单元——通常是文件或头文件——需要能够快速获取数据结构的大小。这意味着不可见的数据结构不能被复制。以下是一个分配结构体指针的示例:

#include <stdio.h>

typedef struct {
 int a1;
 int a2;
} pair;

int main() {
 pair obj;
 pair zeros;
 zeros.a1 = 0;
 zeros.a2 = 0;
 pair *ptr = &obj;
 obj.a1 = 1;
 obj.a2 = 2;
 *ptr = zeros;
 printf("a1: %d, a2: %d\n", ptr->a1, ptr->a2);
 return 0;
}

对于读取结构体指针,不要直接进行。相反,程序员创建抽象来创建、复制和销毁结构体。如果这听起来很熟悉,这就是 C++最初打算在标准委员会走偏之前要做的。

指针算术

除了添加到整数外,指针还可以添加到。然而,指针类型用于确定要增加指针多少。指针通过添加的值的倍数乘以底层类型的大小来移动。对于 char 指针,这很简单,因为字符始终是一个字节。

char *ptr = "Hello"; // ptr holds the memory location of 'H'
ptr += 2; // ptr now points to the first 'l''

如果 int 是 4 个字节,那么 ptr+1 将指向 ptr 指向的地址之后的 4 个字节。

char *ptr = "ABCDEFGH";
int *bna = (int *) ptr;
bna +=1; // Would cause iterate by one integer space (i.e 4 bytes on some systems)
ptr = (char *) bna;
printf("%s", ptr);

注意到只有’EFGH’被打印出来。这是为什么?嗯,如上所述,当我们执行’bna+=1’时,我们是在增加整数指针的值 1,(在大多数系统中相当于 4 个字节)这相当于 4 个字符(每个字符只有 1 个字节)。因为 C 中的指针算术总是自动按所指向的类型的大小进行缩放,POSIX 标准禁止对 void 指针进行算术运算。话虽如此,编译器通常会将其底层类型视为。这里是一个机器翻译。以下两个指针算术操作是相等的

int *ptr1 = ...;

// 1
int *offset = ptr1 + 4;

// 2
char *temp_ptr1 = (char*) ptr1;
int *offset = (int*)(temp_ptr1 + sizeof(int)*4);

每次进行指针算术时,都要深呼吸并确保你正在移动你认为要移动的字节数。

那么什么是 void 指针?

void 指针是一个没有类型的指针。当数据类型未知或当 C 代码与其他没有 API 的其他编程语言进行接口时使用 void 指针。你可以将其视为一个原始指针,或一个内存地址。默认情况下返回一个 void 指针,可以安全地提升为任何其他类型。

void *give_me_space = malloc(10);
char *string = give_me_space;

C 自动提升到其适当类型。和不是完全 ISO C 兼容的,这意味着它们将允许对 void 指针进行算术运算。它们将其视为指针。不要这样做,因为它不可移植 - 它不能保证与所有编译器一起工作!

常见错误

空字节

这段代码有什么问题?

void mystrcpy(char*dest, char* src) {
 // void means no return value
 while( *src ) { dest = src; src ++; dest++; }
}

在上面的代码中,它只是将 dest 指针更改为指向源字符串。此外,NUL 字节没有被复制。这里是一个更好的版本 -

while( *src ) { *dest = *src; src ++; dest++; }
*dest = *src;

注意,也常见到以下类型的实现,它在表达式测试中做所有事情,包括复制 NUL 字节。然而,这很糟糕,因为它在同一行中执行多个操作。

while( (*dest++ = *src++ )) {};

双重释放

双重释放错误是指程序意外地尝试两次释放相同的分配。

int *p = malloc(sizeof(int));
free(p);

*p = 123; // Oops! - Dangling pointer! Writing to memory we don't own anymore

free(p); // Oops! - Double free!

解决方法是首先编写正确的程序!其次,养成将指针设置为 NULL 的好习惯,一旦内存被释放。这确保了指针不能在不崩溃程序的情况下被错误地使用。

p = NULL; // No dangling pointers

返回自动变量的指针

int *f() {
 int result = 42;
 static int imok;
 return &imok; // OK - static variables are not on the stack
 return &result; // Not OK
}

自动变量仅在函数的生命周期内绑定到堆栈内存。函数返回后,继续使用该内存是错误的。

内存分配不足

struct User {
 char name[100];
};
typedef struct User user_t;

user_t *user = (user_t *) malloc(sizeof(user));

在上面的例子中,我们需要为结构体分配足够的字节。相反,我们分配了足够的字节来容纳一个指针。一旦我们开始使用用户指针,我们就会破坏内存。正确的代码如下所示。

struct User {
 char name[100];
};
typedef struct User user_t;

user_t * user = (user_t *) malloc(sizeof(user_t));

缓冲区溢出/下溢

一个著名的例子:Heart Bleed 在大小不足的缓冲区中执行了 memcpy 操作。一个简单的例子:实现 strcpy 并忘记在确定所需内存大小时加一。

#define N (10)
int i = N, array[N];
for( ; i >= 0; i--) array[i] = i;

C 无法检查指针是否有效。上述示例写入的内存位置超出了数组界限。这可能导致内存损坏,因为该内存位置可能被用于其他目的。在实践中,这可能更难被发现,因为溢出/下溢可能发生在库调用中。这里是我们的老朋友 gets。

gets(array); // Let's hope the input is shorter than my array!

字符串需要 strlen(s)+1 个字节

每个字符串必须在最后一个字符之后有一个空字节。要存储字符串“Hi”,需要 3 个字节:[H] [i] [\0]。

char *strdup(const char *input) {  /* return a copy of 'input' */
 char *copy;
 copy = malloc(sizeof(char*));     /* nope! this allocates space for a pointer, not a string */
 copy = malloc(strlen(input));     /* Almost...but what about the null terminator? */
 copy = malloc(strlen(input) + 1); /* That's right. */
 strcpy(copy, input);   /* strcpy will provide the null terminator */
 return copy;
}

使用未初始化的变量

int myfunction() {
 int x;
 int y = x + 2;
 ...

自动变量持有内存或寄存器中偶然出现的垃圾或位模式。假设它总是初始化为零是错误的。

假设未初始化的内存将被清零

void myfunct() {
 char array[10];
 char *p = malloc(10);

自动(临时变量)和堆分配可能包含随机字节或垃圾。

逻辑和程序流程错误

这些是一系列可能导致程序编译但执行未预期功能的错误。

相等与相等

在 C 中,赋值运算符也返回赋值后的值。大多数时候它被忽略。我们可以用它来在同一行初始化多个东西。

int p1, p2;
p1 = p2 = 0;

更令人困惑的是,如果我们忘记在相等运算符中省略等号,我们最终会赋值给那个变量。大多数时候这不是我们想要的。

int answer = 3; // Will print out the answer.
if (answer = 42) { printf("The answer is %d", answer);}

解决这个问题的快速方法是养成将常数放在前面的习惯。这个错误在 while 循环条件中很常见。大多数现代编译器不允许在没有括号的情况下将条件赋给变量。

 if (42 = answer) { printf("The answer is %d", answer);}

有时候我们想要这样做。一个常见的例子是 getline。

while ((nread = getline(&line, &len, stream)) != -1)

这段代码调用了 getline,并将返回值或读取的字节数赋值给 nread。它还在同一行检查该值是否为-1,如果是,则终止循环。将括号放在任何赋值条件周围总是好的做法。

未声明的或原型不正确的函数

一些代码片段可能执行以下操作。

time_t start = time();

系统函数“time”实际上接受一个参数,即指向某个内存的指针,该内存可以接收 time_t 结构或 NULL。编译器未能捕获这个错误,因为程序员通过包含省略了有效的函数原型。

更令人困惑的是,这可能会编译,工作几十年后崩溃。原因是时间在链接时间而不是编译时间找到,C 标准库几乎肯定已经在内存中。由于没有传递参数,我们希望堆栈上的参数(任何垃圾)被清零,因为如果没有,时间将尝试将函数的结果写入那个垃圾,这将导致程序 SEGFAULT。

多余的分号

这是一个相当简单的例子,不需要时不要使用分号。

for(int i = 0; i < 5; i++) ; printf("Printed once");
while(x < 10); x++ ; // X is never incremented

然而,以下代码是完全可以接受的。

for(int i = 0; i < 5; i++){
 printf("%d\n", i);;;;;;;;;;;;;
}

这种代码是可以的,因为 C 语言使用分号(;)来分隔语句。如果在分号之间没有语句,那么就没有什么可做的,编译器会继续到下一个语句。为了避免很多混淆,始终使用花括号。这增加了代码的行数,这是一个很好的生产力指标。

主题

  • C 字符串表示

  • C 字符串作为指针

  • char p[]与 char* p

  • 简单的 C 字符串函数(strcmp, strcat, strcpy)

  • sizeof char

  • sizeof x 与 x*

  • 堆内存生命周期

  • 对堆分配的调用

  • 解引用指针

  • 取地址运算符

  • 指针算术

  • 字符串复制

  • 字符串截断

  • 双重释放错误

  • 字符串字面量

  • 打印格式化。

  • 内存越界错误

  • 静态内存

  • 文件输入/输出。POSIX 与 C 库

  • C 输入输出:fprintf 和 printf

  • POSIX 文件 I/O(读取、写入、打开)

  • stdout 的缓冲

问题/练习

  • 以下代码打印出什么?

    int main(){
    fprintf(stderr, "Hello ");
    fprintf(stdout, "It's a small ");
    fprintf(stderr, "World\n");
    fprintf(stdout, "place\n");
    return 0;
    }
    
  • 以下两个声明之间的区别是什么?其中一个的返回值是什么?

    char str1[] = "first one";
    char *str2 = "another one";
    
  • C 中的字符串是什么?

  • 编写一个简单的。关于,,或?附加题:在只遍历字符串一次的情况下编写函数。

  • 以下每一行通常应该返回什么?

    int *ptr;
    sizeof(ptr);
    sizeof(*ptr);
    
  • 什么是?它与.有什么不同?一旦分配了内存,我们如何使用?

  • 什么是运算符?关于?

  • 指针算术。假设以下地址。以下位移是什么?

    char** ptr = malloc(10); //0x100
    ptr[0] = malloc(20); //0x200
    ptr[1] = malloc(20); //0x300
    
  • 我们如何防止双重释放错误?

  • 打印字符串的 printf 指定符是什么,或者?

  • 以下代码是否有效?为什么?在哪里?

    char *foo(int var){
    static char output[20];
    snprintf(output, 20, "%d", var);
    return output;
    }
    
  • 编写一个函数,该函数接受一个路径作为字符串,并打开该文件,每次打印 40 个字节的内容,但每隔一次打印会反转字符串(尝试使用 POSIX API 来完成此操作)。

  • POSIX 文件描述符模型与 C 的(即使用哪些函数调用,哪个是缓冲的)有什么区别?POSIX 是否使用 C 的内部,反之亦然?

快速问答:指针算术

指针算术很重要!深呼吸,找出每个操作移动指针的字节数。以下是一个快速问答部分。我们将使用以下定义:

int *int_; // sizeof(int) == 4;
long *long_; // sizeof(long) == 8;
char *char_;
int *short_; //sizeof(short) == 2;
int **int_ptr; // sizeof(int*) == 8;

以下加法操作移动了多少字节?

快速问答解决方案

  1. 4

  2. 56

  3. -12

  4. -16

  5. 0

  6. -16

  7. 72

进程

要理解什么是进程,你需要了解什么是操作系统。操作系统是一个程序,它提供了硬件和用户软件之间的接口,同时也提供了一套软件可以使用的一系列工具。操作系统管理硬件,并给用户程序提供了一种统一的方式与硬件交互,只要操作系统可以安装在该硬件上。尽管这个想法听起来像是终极解决方案,但我们知道有许多不同的操作系统,它们都有自己的怪癖和标准。为了解决这个问题,存在另一层抽象:POSIX 或可移植操作系统接口。这是一个标准(现在可能有多个标准)——操作系统必须实现以成为 POSIX 兼容——我们将要研究的几乎所有系统几乎都是 POSIX 兼容的,这更多是由于政治原因。

在我们讨论 POSIX 系统之前,我们应该了解内核的一般概念。在操作系统(OS)中,有两个空间:内核空间和用户空间。内核空间是一种强大的操作模式,允许系统与硬件交互,并有可能破坏你的机器。用户空间是大多数应用程序运行的地方,因为它们不需要这种级别的权力来进行每个操作。当用户空间程序需要额外的权力时,它通过内核执行的系统调用来与硬件交互。这增加了一层安全性,以确保普通用户程序不能破坏你的整个操作系统。为了我们课程的目的,我们将讨论单机多用户操作系统。这就是在标准笔记本电脑或台式机上有一个中央时钟的地方。其他操作系统放宽了中央时钟的要求(分布式)或硬件的“标准化”(嵌入式系统)。其他不变量确保事件在特定时间发生。

操作系统由许多不同的部分组成。可能有程序在运行以处理传入的 USB 连接,另一个程序保持连接到网络等。最重要的是内核——尽管它可能是一组进程——它是操作系统的核心。内核有许多重要的任务。其中第一个是引导。

  1. 计算机硬件从只读存储器中执行代码,称为固件。

  2. 固件执行引导加载程序,它通常符合可扩展固件接口(),这是系统固件和操作系统之间的接口。

  3. 引导加载程序的引导管理器根据引导设置加载操作系统内核。

  4. 你的内核从无到有执行引导自身。

  5. 内核执行启动脚本,如启动网络和 USB 处理。

  6. 内核执行用户空间脚本,如启动桌面,然后你可以使用你的计算机!

当一个程序在用户空间执行时,内核为用户空间中的程序提供一些重要的服务。

  • 调度进程和线程

  • 处理同步原语(futexes、互斥锁、信号量等)

  • 提供系统调用,如或

  • 管理虚拟内存和低级二进制设备,如驱动程序

  • 管理文件系统

  • 处理网络上的通信

  • 处理进程间的通信

  • 动态链接库

  • 列表可以一直继续下去。

内核创建第一个进程(另一种选择是 system.d)。init.d 启动程序,如图形用户界面、终端等——默认情况下,这是系统创建的唯一一个显式进程。所有其他进程都是通过系统调用和从单个进程实例化而来的。

文件描述符

虽然这些在上一个章节中已经提到,但我们将快速回顾一下文件描述符。Julia Evans 的一本小册子提供了更多细节(Evans 2018)。

内核跟踪文件描述符及其指向的内容。稍后我们将学习两个要点:文件描述符指向的不仅仅是文件,操作系统还跟踪它们。

注意,文件描述符可以在进程之间重用,但在进程内部,它们是唯一的。文件描述符可能有一个位置的概念。这些被称为可寻址流。程序可以完全读取磁盘上的文件,因为操作系统跟踪文件中的位置,这个属性也属于你的进程。

其他文件描述符指向网络套接字和各种其他信息,这些是不可寻址流。

进程

进程是计算机程序可能正在运行的实例。进程拥有许多可用的资源。每个程序的开始时,程序会获得一个进程,但每个程序可以创建更多的进程。程序由以下部分组成:

  • 二进制格式:这告诉操作系统关于二进制中各个位段的详细信息——哪些部分是可执行的,哪些部分是常量,哪些库需要包含等。

  • 一组机器指令

  • 表示从哪个指令开始的一个数字

  • 常量

  • 链接库以及在哪里填写这些库的地址

进程很强大,但它们是隔离的!

这意味着默认情况下,没有进程可以与另一个进程通信。

这很重要,因为在复杂的系统(如伊利诺伊大学工程工作站)中,不同的进程可能具有不同的权限。当然不希望普通用户能够通过故意或意外修改进程来使整个系统崩溃。正如你们大多数人现在所意识到的那样,如果你将以下代码片段放入程序中,两个并行调用的程序变量之间是不共享的。

int secrets;
secrets++;
printf("%d\n", secrets);

在两个不同的终端上,它们都会打印出 1 而不是 2。即使我们更改代码以尝试影响其他进程实例,也无法无意中改变另一个进程的状态。然而,还有其他有意改变其他进程程序状态的方法。

进程内容

内存布局

当一个进程启动时,它会获得自己的地址空间。每个进程都会得到以下内容。

  • 栈是自动分配的变量和函数调用返回地址存储的地方。每次声明一个新的变量时,程序都会将栈指针向下移动以为该变量预留空间。这个栈段是可写的,但不可执行。这种行为由不可执行(NX)位控制,有时称为 W^X(写 XOR 执行)位,有助于防止恶意代码,例如在栈上运行。

    如果栈增长得太远——意味着它要么超过了预设的边界,要么与堆相交——程序将出现栈溢出错误,这很可能会导致 SEGFAULT。默认情况下,栈是静态分配的;可以写入的空间是有限的。

  • 堆是内存中一个连续的、可扩展的区域(“malloc 概述” 2018)。如果一个程序想要分配一个其生命周期是手动控制的或其大小在编译时无法确定的对象,它就会想要创建一个堆变量。

    堆从文本段的顶部开始,向上增长,这意味着它可能会将堆边界(称为程序断点)向上推。

    我们将在关于内存分配的章节中更深入地探讨这个问题。这个区域也是可写的,但不可执行。如果系统受限或程序耗尽了地址,就会耗尽堆内存,这种现象在 32 位系统上更为常见。

  • 数据段

    这个段包含两部分,一个初始化数据段和一个未初始化段。此外,初始化数据段被分为可读和可写部分。

    • 初始化数据段 这包含了程序的所有全局变量以及任何其他静态变量。

      这个部分从文本段的末尾开始,并且因为全局变量的数量在编译时是已知的,所以它的大小是固定的。数据段的末尾称为“程序断点”,可以通过使用 brk / sbrk 来扩展。

      这个部分是可写的(Van der Linden 1994 P. 124)。最值得注意的是,这个部分包含了以下方式初始化的变量:

      int global = 1;
      
    • 未初始化数据段 / BSS BSS 代表一个旧的汇编操作符,称为由符号开始的块。

      这包含了所有全局变量以及任何其他隐式置零的静态持续时间变量。

      示例:

      int assumed_to_be_zero;
      

      这个变量将被置零;否则,我们将面临涉及与其他进程隔离的安全风险。它们被放入不同的部分以加快进程启动时间。这个部分从数据段的末尾开始,并且由于全局变量的数量在编译时已知,因此其大小也是静态的。目前,初始化和未初始化的数据段被合并并称为数据段(Van der Linden 1994 P. 124),尽管在目的上有所不同。

  • 文本段

    这就是所有可执行指令存储的地方,它是可读的(函数指针)但不可写。程序计数器通过这个段逐个执行指令。需要注意的是,这是程序默认的唯一可执行部分。如果程序在运行时修改其代码,程序很可能会产生 SEGFAULT。有绕过这个问题的方法,但在这门课程中我们不会探讨这些方法。为什么它不总是从零开始?这是因为一个名为地址空间布局随机化的安全特性。关于这个特性的原因和解释超出了本课程的范围,但了解它的存在是有好处的。话虽如此,如果程序在编译时带有 DEBUG 标志,这个地址可以被设置为常量。

进程地址空间

进程地址空间

其他内容

为了跟踪所有这些进程,操作系统为每个进程分配一个称为进程 ID(PID)的数字。进程还被赋予其父进程的 PID,称为父进程 ID()。每个进程都有一个父进程,那个父进程可能是。

进程还可以包含以下信息:

  • 运行状态 - 进程是准备就绪、正在运行、已停止、已终止等(关于这一点将在调度章节中详细说明)。

  • 文件描述符 - 从整数到真实设备(文件、USB 闪存驱动器、套接字)的映射列表

  • 权限 - 文件正在运行什么以及进程属于哪个。进程随后只能根据授予其的权限执行操作,例如访问文件。有一些技巧可以使程序以不同于启动程序的用户身份运行(例如,启动一个由 a 启动的程序并以 a 的身份执行)。更具体地说,一个进程有一个真实用户 ID(标识进程的所有者),一个有效用户 ID(用于尝试访问仅由超级用户可访问的文件的非特权用户),以及一个保存用户 ID(用于特权用户执行非特权操作)。

  • 参数 - 一系列字符串,告诉你的程序在什么参数下运行。

  • 环境变量 - 一系列可以修改的键值对字符串。这些通常用于指定库和二进制文件的路径、程序配置设置等。

根据 POSIX 规范,一个进程只需要一个线程和地址空间,但大多数内核开发者和用户都知道,这些还不够(“定义” 2018)。

Fork 简介

一句警告

进程复制是一个强大而危险的工具。如果你犯了一个错误导致 fork 恶性循环,你可能会使整个系统崩溃。为了减少这种情况的可能性,通过在命令行中输入,将最大进程数限制在一个较小的数字,例如 40。注意,这个限制仅适用于用户,这意味着如果你触发 fork 恶性循环,你将无法杀死所有创建的进程,因为调用需要你的 shell 来完成。这非常不幸。一个解决方案是在事先以另一个用户(例如 root)的身份启动另一个 shell 实例,并从那里杀死进程。

另一个方法是使用内置命令来杀死所有用户进程(你只有一次尝试)。

最后,你可以重新启动系统,但使用 exec 函数,你只有一次机会。

在测试 fork() 代码时,确保你有对涉及机器的 root 权限和/或物理访问权限。如果你必须远程工作在 fork() 代码上,请记住,kill -9 -1 在紧急情况下可以救你。如果你没有准备好,fork 可能会 极其危险你已经收到警告了

Fork 功能

系统调用通过复制现有进程的状态(略有不同)来创建一个新的进程,称为子进程。

  • 子进程在父进程之后执行下一行。

  • 作为一个附带说明,在较老的 UNIX 系统中,无论资源是否被修改,父进程的整个地址空间都会被直接复制。当前的行为是内核执行 写时复制,这可以节省大量资源,同时效率高(Bovet 和 Cesati 2005 写时复制部分)。

这里有一个简单的例子:

printf("I'm printed once!\n");
fork();
// Now two processes running if fork succeeded
// and each process will print out the next line.
printf("This line twice!\n");

这里有一个简单的地址空间复制的例子。以下程序可能会打印出 42 两次——但是为什么是在 !? 之后?

#include <unistd.h>  /*fork declared here*/
#include <stdio.h>  /* printf declared here*/
int main() {
 int answer = 84 >> 1;
 printf("Answer: %d", answer);
 fork();
 return 0;
}

这一行只执行一次,然而请注意,打印的内容并没有被刷新到标准输出。没有打印换行符,我们没有调用 ,也没有改变缓冲模式。因此,输出文本仍然在进程内存中等待发送。当执行时,整个进程内存都会被复制,包括缓冲区。因此,子进程以一个非空输出缓冲区开始,这可能在程序退出时被刷新。我们说“可能”,因为内容可能在没有良好程序退出的情况下未被写入。

要编写针对父进程和子进程不同的代码,检查 fork 的返回值。如果返回 -1,则意味着在创建新子进程的过程中出现了错误。应该检查 errno 中存储的值以确定发生了什么类型的错误。常见的错误包括 和 ,它们本质上意味着“再试一次——资源暂时不可用”,以及“没有这样的文件或目录”。

同样,返回值为 0 表示我们处于子进程的上下文中,而正整数表示我们处于父进程的上下文中。

fork 返回的正值是子进程的进程 ID (pid)。

记忆 fork 返回值所代表的内容的一个方法是,子进程可以通过调用 - 来找到其父进程——被复制的原始进程,因此不需要从 . 中获取任何额外的返回信息。然而,父进程可能有多个子进程,因此需要明确地告知其子进程的 PID。

根据 POSIX 标准,每个进程只有一个父进程。

父进程只能从 fork 的返回值中知道新子进程的 PID。

pid_t id = fork();
if (id == -1) exit(1); // fork failed
if (id > 0) {
 // Original parent
 // A child process with id 'id'
 // Use waitpid to wait for the child to finish
} else { // returned zero
 // Child Process
}

下面是一个稍微有点愚蠢的例子。它会打印什么?试着用多个参数运行这个程序。

#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv) {
 pid_t id;
 int status;
 while (--argc && (id=fork())) {
 waitpid(id,&status,0); /* Wait for child*/
 }
 printf("%d:%s\n", argc, argv[argc]);
 return 0;
}

下面是另一个例子。这就是今天这个令人惊讶的并行 O(N) 的 sleepsort 是一个愚蠢的赢家。它首次在 2011 年发布于 4chan。下面展示了这个糟糕但有趣的排序算法的一个版本。这个排序算法可能无法产生正确的输出。

int main(int c, char **v) {
 while (--c > 1 && !fork());
 int val  = atoi(v[c]);
 sleep(val);
 printf("%d\n", val);
 return 0;
}

想象我们这样运行这个程序

$ ./ssort 1 3 2 4

排序 1, 3, 2, 4 的时间

排序 1, 3, 2, 4 的时间

由于系统调度器的工作方式,算法实际上并不是 O(N)。本质上,这个程序将实际的排序外包给了操作系统。

Fork Bomb

“fork bomb” 是我们之前警告过你的。当尝试创建无限数量的进程时,就会发生这种情况。这通常会导致系统几乎停止运行,因为它试图为准备运行的大量进程分配 CPU 时间和内存。系统管理员不喜欢它们,并可能对每个用户可以拥有的进程数量设置上限,或者因为它们干扰了其他用户的程序而撤销登录权限。一个程序可以使用 来限制创建的子进程数量。

fork bomb 不一定是恶意的——它们有时是由于编程错误而发生的。下面是一个简单的恶意示例。

while (1) fork();

如果你调用 fork 时不小心,很容易引发一个错误,尤其是在循环中。你能在这里找到 fork bomb 吗?

#include <unistd.h>
#define HELLO_NUMBER 10

int main(){
 pid_t children[HELLO_NUMBER];
 int i;
 for(i = 0; i < HELLO_NUMBER; i++){
 pid_t child = fork();
 if(child == -1) {
 break;
 }
 if(child == 0) {
 // Child
 execlp("ehco", "echo", "hello", NULL);
 }
 else{
 // Parent
 children[i] = child;
 }
 }

 int j;
 for(j = 0; j < i; j++){
 waitpid(children[j], NULL, 0);
 }
 return 0;
}

我们拼错了,所以调用失败了。这意味着什么?我们本应创建 10 个进程,却创建了*1024 个进程,导致我们的机器被 fork 炸弹攻击我们如何防止这种情况发生?在 exec 之后立即添加退出,这样如果 exec 失败,我们就不会无限制地调用 fork。**还有其他各种方法。如果我们删除了二进制文件呢?如果二进制文件本身创建了 fork 炸弹呢?

信号

我们将在课程结束前全面探讨信号,但现在讨论这个主题是相关的,因为与 fork 和其他函数调用相关的各种语义详细说明了信号是什么。

可以将信号视为软件中断。这意味着接收信号的进程将停止当前程序的执行,并使程序响应信号。

操作系统定义了各种信号,其中两个你可能已经知道:SIGSEGV 和 SIGINT。第一个是由于非法内存访问引起的,第二个是由想要终止程序的用户发送的。在每种情况下,程序都会从当前执行的行跳转到信号处理程序。如果程序没有提供信号处理程序,则执行默认处理程序——例如终止程序或忽略信号。

这里是一个简单用户定义的信号处理程序的例子:

void handler(int signum) {
 write(1, "signaled!", 9);
 // we don't need the signum because we are only catching SIGINT
 // if you want to use the same piece of code for multiple
 // signals, check the signum
}
int main() {
 signal(SIGINT, handler);
 while(1) ;
 return 0;
}

信号在其生命周期中有四个阶段:生成、挂起、阻塞和接收状态。这指的是进程生成信号、内核即将传递信号、信号被阻塞以及内核传递信号时,每个阶段都需要一些时间来完成。更多内容请参阅信号章节的介绍。

术语很重要,因为 fork 和 exec 根据信号的状态需要不同的操作。

要注意的是,在程序逻辑中使用信号通常是一种较差的编程实践,即发送信号以执行特定操作。原因是信号没有交付时间框架,也没有保证它们会被交付。有更好的方法在两个进程之间进行通信。

如果你想了解更多,可以自由地跳到关于 POSIX 信号的章节并阅读它。它不长,会给你详细介绍如何在进程中处理信号。

POSIX Fork 详细信息

POSIX 确定了 fork 的标准(“Fork” 2018)。你可以阅读前面的引用,但请注意,它可能相当冗长。以下是相关内容的摘要:

  1. fork 在成功时将返回一个非负整数。

  2. 子进程将继承父进程的所有打开文件描述符。这意味着如果父进程读取了文件的一半并进行了 fork,子进程将从该偏移量开始。在子进程端进行读取将按相同数量移动父进程的偏移量。任何其他标志也将被继承。

  3. 挂起的信号不会被继承。这意味着如果父进程有一个挂起的信号并创建了一个子进程,除非另一个进程向子进程发送信号,否则子进程不会接收到该信号。

  4. 进程将创建一个线程(关于这一点稍后讨论。普遍观点是不要同时创建进程和线程)。

  5. 由于我们使用了写时复制(COW),只读内存地址在进程间是共享的。

  6. 如果程序设置了某些内存区域,它们可以在进程间共享。

  7. 信号处理程序会被继承,但可以被更改。

  8. 进程的当前工作目录(通常缩写为 CWD)会被继承,但可以被更改。

  9. 环境变量会被继承,但可以被更改。

父亲和子进程之间的关键区别包括:

  • 由 . 返回的进程 ID,由 . 返回的父进程 ID。

  • 当子进程完成时,父进程通过信号 SIGCHLD 被通知,但反之则不然。

  • 子进程不会继承挂起的信号或定时器警报。完整的列表请参阅 fork 手册页

  • 子进程有其自己的环境变量集。

Fork 和 FILEs

在使用和分叉时有一些棘手的边缘情况。首先,我们必须进行技术上的区分。文件描述符是指向文件描述符的 struct。文件描述符可以指向许多不同的 struct,但就我们的目的而言,它们将指向一个表示文件系统上文件的 struct。这个文件描述符包含路径、描述符已读取到文件中的位置等元素。文件描述符指向文件描述符。这很重要,因为当一个进程被分叉时,只有文件描述符被克隆,而不是描述符。以下代码片段只包含一个描述符。

 int file = open(...);
 if(!fork) {
 read(file, ...);
 } else {
 read(file, ...);
 }

一个进程将读取文件的一部分,另一个进程将读取文件的另一部分。在以下示例中,由于两个不同的文件句柄,存在两个描述。

 if(!fork) {
 int file = open(...);
 read(file, ...);
 } else {
 int file = open(...);
 read(file, ...);
 }

让我们考虑我们的动机示例。

$ cat test.txt
A
B
C

看看这段代码,它做了什么?

size_t buffer_cap = 0;
char * buffer = NULL;
ssize_t nread;
FILE * file = fopen("test.txt", "r");
int count = 0;
while((nread = getline(&buffer, &buffer_cap, file) != -1) {
 printf("%s", buffer);
 if(fork() == 0) { 
 exit(0);
 }
 wait(NULL);
}

初始想法可能认为它会逐行打印文件,并有一些额外的分叉。实际上,这是未定义的行为,因为我们没有准备文件描述符。简而言之,以下是避免这个示例的步骤。

  1. 作为程序员,你需要确保在分叉之前准备所有文件描述符。

  2. 如果它是一个文件描述符或无缓冲的,它已经准备好了。

  3. 如果文件已打开用于读取并且已被完全读取,它已经准备好了。

  4. 否则,必须关闭或关闭以准备。

  5. 如果文件描述符已经准备好,那么如果子进程正在使用它,则父进程必须将其设置为非活动状态,反之亦然。一个进程在使用它,如果它被读取或写入,或者如果该进程 出于任何原因 调用了 。如果两个进程同时使用它,整个应用程序的行为是未定义的。

那么,我们如何修复代码?我们必须在分叉之前刷新文件,并在调用之后才使用它——关于这一点的具体细节将在下一节中讨论。

size_t buffer_cap = 0;
char * buffer = NULL;
ssize_t nread;
FILE * file = fopen("test.txt", "r");
int count = 0;
while((nread = getline(&buffer, &buffer_cap, file) != -1) {
 printf("%s", buffer);
 fflush(file);
 if(fork() == 0) { 
 exit(0);
 }
 wait(NULL);
}

如果父进程和子进程需要异步执行并需要保持文件句柄打开,会发生什么?由于事件顺序,我们需要确保父进程知道子进程已完成使用。我们将在后面的章节中讨论进程间通信,但现在我们可以使用双重 fork 方法。

//... 
fflush(file);
pid_t child = fork();
if(child == 0) { 
 fclose(file);
 if (fork() == 0) {
 // Do asynchronous work
 // Safe exit, this child doesn't know about
 // the file descriptor
 exit(0);
 }
 exit(0);
}
waitpid(child, NULL, 0);

如果你对它是如何工作的感兴趣,请查看附录以获取 Fork-file 问题的描述。

等待和执行

如果父进程想要等待子进程完成,它必须使用(或),这两个都等待子进程改变进程状态,这可以是以下之一:

  1. 儿童终止

  2. 儿童被信号停止

  3. 儿童被信号恢复

注意,waitpid 可以被设置为非阻塞,这意味着它们将立即返回,让程序知道子进程是否已退出。

pid_t child_id = fork();
if (child_id == -1) { perror("fork"); exit(EXIT_FAILURE);}
if (child_id > 0) {
 // We have a child! Get their exit code
 int status;
 waitpid( child_id, &status, 0 );
 // code not shown to get exit status from child
} else { // In child ...
 // start calculation
 exit(123);
}

是的简化版本。接受一个指向整数的指针并等待任何子进程。在第一个子进程改变状态后返回。以下是的操作行为:

一个程序可以等待一个特定的进程,或者它可以传递特殊值以执行不同的事情(检查手册页)。

waitpid 的最后一个参数是一个可选参数。选项如下:

WNOHANG - 返回搜索的进程是否已退出

WNOWAIT - 等待,但允许另一个等待调用使子进程可等待

WEXITED - 等待已退出的子进程

WSTOPPED - 等待停止的子进程

WCONTINUED - 等待继续的子进程

退出状态或存储在上面的两个调用中的整型指针的值将在下面解释。

退出状态

要找到从子进程返回的值或包含在中的值,请使用。通常,程序将使用和。有关更多信息,请参阅/手册页。

int status;
pid_t child = fork();
if (child == -1) {
 return 1; //Failed
}
if (child > 0) {
 // Parent, wait for child to finish
 pid_t pid = waitpid(child, &status, 0);
 if (pid != -1 && WIFEXITED(status)) {
 int exit_status = WEXITSTATUS(status);
 printf("Process %d returned %d" , pid, exit_status);
 }
} else {
 // Child, do something interesting
 execl("/bin/ls", "/bin/ls", ".", (char *) NULL); // "ls ."
}

一个进程只能有 256 个返回值,其余的位是信息位,信息通过位移动提取。然而,内核有一种内部方式来跟踪被信号、已退出或已停止的进程。此 API 被抽象化,以便内核开发者可以随意更改它。记住:这些宏只有在满足前提条件时才有意义。例如,如果进程没有被信号,进程的退出状态就不会被定义。宏不会对程序进行检查,因此程序员必须确保逻辑正确。例如,程序应该使用来检查进程是否被停止,然后使用来找到停止它的信号。因此,没有必要记住以下内容。这是对状态变量内部信息存储的高级概述。来自旧伯克利标准分布(BSD)内核的(“Source to Sys/Wait.h”,n.d.):

/* If WIFEXITED(STATUS), the low-order 8 bits of the status. */
#define _WSTATUS(x) (_W_INT(x) & 0177)
#define _WSTOPPED 0177 /* _WSTATUS if process is stopped */
#define WIFSTOPPED(x) (_WSTATUS(x) == _WSTOPPED)
#define WSTOPSIG(x) (_W_INT(x) >> 8)
#define WIFSIGNALED(x)  (_WSTATUS(x) != _WSTOPPED && _WSTATUS(x) != 0)
#define WTERMSIG(x) (_WSTATUS(x))
#define WIFEXITED(x)  (_WSTATUS(x) == 0)

关于退出代码有一个约定。如果进程正常退出且一切顺利,则应返回零。除此之外,并没有太多广泛接受的约定。如果程序指定了返回代码来表示某些条件,它可能能够更好地理解 256 个错误代码。例如,如果程序在进入阶段 1(如写入文件)时执行了其他操作,则可以返回,等等。通常,UNIX 程序不是设计来遵循这项政策的,为了简化。

僵尸和孤儿

在你的进程的子进程中等待是一个好习惯。如果父进程不等待其子进程,它们就会变成所谓的僵尸。当子进程终止并在内核进程表中为你的进程占用一个位置时,就会创建僵尸。进程表跟踪有关进程的以下信息:PID、状态以及它是如何被杀死的。消除僵尸的唯一方法是等待你的子进程。如果长时间运行的父进程从不等待其子进程,它可能会失去分叉的能力。

话虽如此,程序并不总是需要等待你的子进程!你的父进程可以继续执行代码,而无需等待子进程。如果父进程在没有等待其子进程的情况下死亡,一个进程可以使其子进程成为孤儿。一旦父进程完成,其任何子进程都将被分配给第一个进程,其 PID 为 1。因此,这些子进程将看到返回值为 1。这些孤儿最终会完成,并在短时间内成为僵尸。init 进程会自动等待其所有子进程,从而将这些僵尸从系统中移除。

高级:异步等待

警告:本节使用了一些部分介绍的信号。当子进程完成时,父进程会收到 SIGCHLD 信号,因此信号处理程序可以等待该进程。下面展示了一个稍微简化的版本。

pid_t child;

void cleanup(int signal) {
 int status;
 waitpid(child, &status, 0);
 write(1,"cleanup!\n",9);
}
int main() {
 // Register signal handler BEFORE the child can finish
 signal(SIGCHLD, cleanup); // or better - sigaction
 child = fork();
 if (child == -1) { exit(EXIT_FAILURE);}

 if (child == 0) {
 // Do background stuff e.g. call exec
 } else { /* I'm the parent! */
 sleep(4); // so we can see the cleanup
 puts("Parent is done");
 }
 return 0;
}

然而,上述示例遗漏了一些细微之处。

  1. 可能会有多个子进程完成,但父进程只会收到一个 SIGCHLD 信号(信号不会被排队)

  2. SIGCHLD 信号可以因其他原因被发送(例如,一个子进程暂时停止了)

  3. 它使用已弃用的代码,而不是更通用的 sigaction。

下面展示了一个更健壮的回收僵尸进程的代码。

void cleanup(int signal) {
 int status;
 while (waitpid((pid_t) (-1), 0, WNOHANG) > 0) {

 }
}

exec

要使子进程执行另一个程序,请在 fork 之后使用其中一个函数。该函数集用指定程序的进程映像替换进程映像。这意味着调用之后的任何代码行都将被执行程序的代码行替换。程序想要子进程做的任何其他工作都应该在调用之前完成。命名方案可以简称为助记符。

  1. e – 显式地将指向环境变量的指针数组传递给新的进程映像。

  2. l – 将命令行参数单独(作为列表)传递给函数。

  3. p – 使用 PATH 环境变量查找要执行的文件名。

  4. v – 命令行参数作为指针数组(向量)传递给函数。

注意,如果信息通过数组传递,则数组必须以 NULL 元素终止。

下面是此代码的一个示例。此代码执行

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

int main(int argc, char**argv) {
 pid_t child = fork();
 if (child == -1) return EXIT_FAILURE;
 if (child) {
 int status;
 waitpid(child , &status ,0);
 return EXIT_SUCCESS;

 } else {
 // Other versions of exec pass in arguments as arrays
 // Remember first arg is the program name
 // Last arg must be a char pointer to NULL

 execl("/bin/ls", "/bin/ls", "-alh", (char *) NULL);

 // If we get to this line, something went wrong!
 perror("exec failed!");
 }
}

尝试解码以下示例

#include <unistd.h>
#include <fcntl.h>  // O_CREAT, O_APPEND etc. defined here

int main() {
 close(1); // close standard out
 open("log.txt", O_RDWR | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR);
 puts("Captain's log");
 chdir("/usr/include");
 // execl( executable,  arguments for executable including program name and NULL at the end)

 execl("/bin/ls", /* Remaining items sent to ls*/ "/bin/ls", ".", (char *) NULL); // "ls ."
 perror("exec failed");
 return 0;
}

此示例将 "Captain’s Log" 写入文件,然后将 /usr/include 中的所有内容打印到同一文件中。上述代码中没有错误检查(我们假设 close、open、chdir 等按预期工作)。

  1. – 将使用最低可用的文件描述符(即 1),因此标准输出(stdout)现在被重定向到日志文件。

  2. – 将当前目录更改为 /usr/include。

  3. – 将程序映像替换为 /bin/ls 并调用其 main() 方法。

  4. – 我们不期望到达这里——如果我们做到了,那么就失败了。

  5. 我们需要 "return 0;",因为如果缺少它,编译器会报错。

POSIX Exec 详细信息

POSIX 详细说明了 exec 需要覆盖的所有语义(“Exec” 2018)。注意以下内容:

  1. 在 exec 之后,文件描述符将被保留。这意味着如果程序打开一个文件而没有关闭它,它将在子进程中保持打开状态。这是一个问题,因为通常子进程不知道这些文件描述符。尽管如此,它们在文件描述符表中占用一个槽位,可能会阻止其他进程访问该文件。这一点的例外是,如果文件描述符设置了 Close-On-Exec 标志(O_CLOEXEC)——我们将在后面介绍设置标志。

  2. 不同的信号语义:执行进程保留信号掩码和挂起的信号集,但不保留信号处理程序,因为它是不同的程序。

  3. 除非使用环境版本的 exec,否则环境变量将被保留。

  4. 操作系统可能会打开 0、1、2——stdin、stdout、stderr,如果它们在 exec 之后被关闭;大多数情况下,它们会保持关闭。

  5. 执行的进程以相同的 PID 运行,并且与上一个进程具有相同的父进程和进程组。

  6. 执行的进程将在相同的用户和组以及相同的当前工作目录下运行。

简化

pre-packs 上述代码(Jones 2010 P. 371)。以下是如何使用 system 的代码片段。

#include <unistd.h>
#include <stdlib.h>

int main(int argc, char**argv) {
 system("ls"); // execl("/bin/sh", "/bin/sh", "-c", "\\"ls\\"")
 return 0;
}

这个调用将 fork,执行通过参数传递的命令,原始父进程将等待这个命令完成。这也意味着这是一个阻塞调用。父进程不能继续,直到启动的进程退出。实际上,它创建了一个 shell,然后传递字符串,这比直接使用多出很多开销。标准 shell 将使用环境变量来搜索与命令匹配的文件名。通常,使用 system 对于许多简单的运行此命令问题就足够了,但对于更复杂或微妙的问题可能会迅速变得有限,并且它隐藏了 fork-exec-wait 模式的机制,所以我们鼓励你学习和使用和而不是。这也可能是一个巨大的安全风险。通过允许某人访问环境变量的 shell 版本,程序可能会遇到各种问题:

int main(int argc, char**argv) {
 char *to_exec = asprintf("ls %s", argv[1]);
 system(to_exec);
}

将类似于 argv[1] = "; sudo su"的内容传递出去是一个巨大的安全风险,被称为权限提升

fork-exec-wait 模式

一种常见的编程模式是调用后跟和。原始进程调用 fork,创建一个子进程。然后子进程使用 exec 启动新程序的执行。同时,父进程使用(或)等待子进程完成。

Fork, exec, wait diagram

Fork, exec, wait diagram

#include <unistd.h>

int main() {
 pid_t pid = fork();
 if (pid < 0) { // fork failure
 exit(1);
 } else if (pid > 0) {
 int status;
 waitpid(pid, &status, 0);
 } else {
 execl("/bin/ls", "/bin/ls", NULL);
 exit(1); // For safety.
 }
}

为什么不直接执行 ls?原因是现在我们有一个监控程序——我们的父进程可以执行其他操作。它可以继续并执行另一个函数,或者它也可以修改系统的状态或读取函数调用的输出。

环境变量

环境变量是系统为所有进程保留的变量。你的系统现在就设置了这些!在 Bash 中,一些已经定义了。

$ echo $HOME
/home/user
$ echo $PATH
/usr/local/sbin:/usr/bin:...

C 程序如何后来这些?它们可以分别调用和函数。

char* home = getenv("HOME"); // Will return /home/user
setenv("HOME", "/home/user", 1 /*set overwrite to true*/ );

环境变量很重要,因为它们在进程之间继承,并且可以用来指定一组标准行为(“环境变量” 2018),尽管你不需要记住这些选项。另一个与安全相关的问题是,环境变量不能被外部进程读取,而 argv 可以。

进一步阅读

阅读 man 页和上面的 POSIX 组!这里有一些指导问题。请注意,我们并不期望你记住 man 页。

  • fork 可能失败的一个原因是什么?

  • fork 是否将所有页面复制到子进程?

  • 文件描述符在父进程和子进程之间是否被克隆?

  • 文件描述符在父进程和子进程之间是否被克隆?

  • 以 exec 调用结束的?之间的区别是什么?

  • exec 调用中 l 和 v 之间的区别是什么?还有?

  • exec 错误发生在什么时候?会发生什么?

  • wait 是否仅在子进程退出时才通知?

  • 将负值传递给 wait 是否是错误?

  • 如何从状态中提取信息?

  • wait 失败的原因可能是什么?

  • 当父进程没有等待其子进程时会发生什么?

  • 分叉

  • exec

  • wait

主题

  • 正确使用 fork、exec 和 waitpid。

  • 使用带有路径的 exec。

  • 理解 fork、exec 和 waitpid 的作用。例如,如何使用它们的返回值。

  • SIGKILL 与 SIGSTOP 与 SIGINT 的区别。

  • 在终端按下 CTRL-C 时,会发送什么信号?

  • 使用 shell 中的 kill 命令或 kill POSIX 调用。

  • 进程内存隔离。

  • 进程内存布局(堆、栈等在哪里;无效的内存地址)。

  • 什么是分叉炸弹、僵尸进程和孤儿进程?如何创建/删除它们。

  • getpid 与 getppid

  • 如何使用 WAIT 退出状态宏 WIFEXITED 等。

问题/练习

  • 使用带有 p 和不带有 p 的 exec 有什么区别?操作系统

  • 程序如何将命令行参数传递给?关于?又如何?按照惯例,第一个命令行参数应该是什么?

  • 程序如何知道是否成功或失败?

  • wait 函数中传递的指针是什么?wait 何时会失败?

  • 之间、之间、之间、之间有什么区别?默认行为是什么?哪些可以由程序设置信号处理器?

  • 当你按下?键时,会发送什么信号?

  • 我的终端锚定在 PID = 1337,并且已经变得无响应。请告诉我发送到该终端的终端命令和 C 代码。

  • 一个进程能否通过常规方式更改另一个进程的内存?为什么?

  • 堆、栈、数据段和文本段在哪里?哪些段程序可以写入?哪些是无效的内存地址?

  • 用 C 语言编写一个分叉炸弹(请勿运行)。

  • 什么是孤儿进程?它是如何变成僵尸进程的?父进程应该做什么来避免这种情况?

  • 你不喜欢你的父母告诉你你不能做某事吗?编写一个程序,向父进程发送。

  • 编写一个函数,该函数通过 fork、exec 和 wait 等待一个可执行文件,并使用 wait 宏告诉我进程是否正常退出或被信号终止。如果进程正常退出,则打印返回值。如果不正常,则打印导致进程终止的信号编号。

内存分配器

简介

内存分配很重要!在任何应用程序中,分配和释放堆内存是最常见的操作之一。在系统级别,堆是一系列连续的地址,程序可以扩展或收缩并用作其地址空间(“malloc 概述” 2018)。在 POSIX 中,这被称为系统断点。我们使用来移动系统断点。大多数程序不会直接与这个调用交互,它们使用围绕它的内存分配系统来处理分块和跟踪哪些内存已分配,哪些已释放。

我们将主要探讨简单的分配器。只需知道还有其他方式来划分内存,比如使用或其他分配方案和方法。

C 内存分配 API

  • 是一个 C 库调用,用于保留可能未初始化的连续内存块(Jones 2010 P. 348)。与栈内存不同,内存保留分配直到用相同的指针调用。如果可以返回至少请求这么多空闲空间的指针,或者。这意味着 malloc 即使在有空间的情况下也可能返回 NULL。健壮的程序应该检查返回值。如果你的代码假设成功,但实际上没有,那么当程序尝试写入地址 0 时,程序可能会崩溃(segfault)。此外,由于性能原因,malloc 会在内存中留下垃圾,请检查你的代码以确保程序中所有程序值都已初始化。

  • 允许程序调整先前在堆上分配的现有内存分配的大小(通过 malloc、calloc 或 realloc)(Jones 2010 P. 349)。realloc 最常用的用途是调整用于存储值数组的内存的大小。realloc 有两个需要注意的问题。一是可能会返回新的指针。二是它可能会失败。以下是一个建议的 realloc 简单但可读的实现版本,以及示例用法。

    void * realloc(void * ptr, size_t newsize) {
     // Simple implementation always reserves more memory
     // and has no error checking
     void *result = malloc(newsize);
     size_t oldsize =  ... //(depends on allocator's internal data structure)
     if (ptr) memcpy(result, ptr, newsize < oldsize ? newsize : oldsize);
     free(ptr);
     return result;
    }
    
    int main() {
     // 1
     int *array = malloc(sizeof(int) * 2);
     array[0] = 10; array[1] = 20;
     // Oops need a bigger array - so use realloc..
     array = realloc(array, 3 * sizeof(int));
     array[2] = 30;
    
    }
    

    上述代码很脆弱。如果失败,程序会泄漏内存。健壮的代码会检查返回值,并且只有在非 NULL 的情况下才重新分配原始指针。

    int main() {
     // 1
     int *array = malloc(sizeof(int) * 2);
     array[0] = 10; array[1] = 20;
     void *tmp = realloc(array, 3 * sizeof(int));
     if (tmp == NULL) {
     // Nothing to do here.
     } else if (tmp == array) {
     // realloc returned same space
     array[2] = 30;
     } else {
     // realloc returned different space
     array = tmp;
     array[2] = 30;
     }
    
    }
    
  • 将内存内容初始化为零,并接受两个参数:项目数量和每个项目的字节大小。关于这些限制的深入讨论见这篇文章。程序员通常使用而不是显式调用,来将内存内容设置为零,因为考虑到了某些性能因素。注意与是相同的,但你应该遵循手册中的约定。以下是一个 calloc 的简单实现。

    void *calloc(size_t n, size_t size) {
     size_t total = n * size; // Does not check for overflow!
     void *result = malloc(total);
    
     if (!result) return NULL;
    
     // If we're using new memory pages
     // allocated from the system by calling sbrk
     // then they will be zero so zero-ing out is unnecessary,
     // We will be non-robust and memset either way.
     return memset(result, 0, total);
    }
    
  • 接收一个指向内存块开始的指针,并在后续调用其他分配函数时使其可用。这是很重要的,因为我们不希望我们的地址空间中的每个进程都占用大量的内存。一旦我们完成对内存的使用,我们就停止使用它,使用‘free’。以下是一个简单用法的示例。

    int *ptr = malloc(sizeof(*ptr));
    do_something(ptr);
    free(ptr);
    

    如果程序在释放内存后使用了一块内存——这是未定义的行为。

堆和 sbrk

堆是进程内存的一部分,其大小会变化。当程序调用(,)和时,堆内存分配由 C 库执行。通过调用 C 库,可以根据程序对更多堆内存的需求增加堆的大小。由于堆和栈都需要增长,我们将它们放在地址空间的相反两端。栈的增长方式与堆不同,新的栈部分是为新的线程分配的。对于典型的架构,堆向上增长,而栈向下增长。

现在,现代操作系统的内存分配器不再需要。相反,它们可以请求独立的虚拟内存区域,并维护多个内存区域。例如,吉字节请求可能被放置在比小分配请求不同的内存区域。然而,这个细节是不必要的复杂性。

程序通常不需要调用,尽管调用可能很有趣,因为它告诉程序堆当前结束的位置。相反,程序使用,和,它们是 C 库的一部分。这些函数的内部实现可能需要在额外的堆内存时调用。

void *top_of_heap = sbrk(0);
malloc(16384);
void *top_of_heap2 = sbrk(0);
printf("The top of heap went from %p to %p \n", top_of_heap, top_of_heap2);
// Example output: The top of heap went from 0x4000 to 0xa000

注意,操作系统新获得的内存必须被清零。如果操作系统保留了物理 RAM 的内容,那么一个进程可能会了解到之前使用过该内存的另一个进程的内存。这将是一个安全漏洞。不幸的是,这意味着在释放任何内存之前,请求通常是零。这是不幸的,因为许多程序员错误地编写了假设分配的内存将始终为零的 C 程序。

char* ptr = malloc(300);
// contents is probably zero because we get brand new memory
// so beginner programs appear to work!
// strcpy(ptr, "Some data"); // work with the data
free(ptr);
// later
char *ptr2 = malloc(300); // Contents might now contain existing data and is probably not zero

分配简介

让我们尝试编写 Malloc。这是我们第一次尝试——一个简单的版本。

void* malloc(size_t size)
{
 // Ask the system for more bytes by extending the heap space.
 // sbrk returns -1 on failure
 void *p = sbrk(size);
 if(p == (void *) -1) return NULL; // No space left
 return p;
}
 void free() {/* Do nothing */}

上面的 malloc 实现是最简单的,尽管有一些缺点。

  • 系统调用与库调用相比速度较慢。我们应该预留大量内存,并且只偶尔从系统请求更多内存。

  • 释放内存后不重用。我们的程序从不重用堆内存——它总是请求更大的堆。

如果在典型程序中使用此分配器,进程会很快耗尽所有可用内存。相反,我们需要一个可以高效使用堆空间并且仅在必要时请求更多内存的分配器。一些程序使用这种类型的分配器。考虑一个视频游戏在加载下一个场景时分配对象。与以下放置策略相比,这样做并丢弃整个内存块要快得多。

放置策略

在程序执行期间,内存被分配和释放,因此在堆内存中会出现可以用于未来内存请求的间隙。内存分配器需要跟踪堆的哪些部分当前被分配,哪些部分是可用的。假设我们的当前堆大小是 64K。让我们假设堆看起来像以下表格。

空堆块

空堆块

如果执行一个新的 2KiB malloc 请求(),应该在何处保留内存?它可以使用最后一个 2KiB 的空隙,这恰好是完美的尺寸!或者它可以将其他两个空闲空隙中的一个分割。这些选择代表了不同的安置策略。无论选择哪个空隙,分配器都需要将空隙分割成两个。新分配的空间,将返回给程序,如果还有剩余空间,则是一个更小的空隙。完美适配策略找到足够小的最小空隙(至少 2KiB):

最佳适配找到精确匹配

最佳适配找到精确匹配

最坏适配策略找到足够大的最大空隙,因此将 30KiB 的空隙分割成两个:

最坏适配找到最不匹配项

最坏适配找到最不匹配项

首次适配策略找到足够大的第一个可用空隙,因此将 16KiB 的空隙分割成两个。我们甚至不需要查看整个堆!

首次适配找到第一个匹配项

首次适配找到第一个匹配项

需要记住的一点是,这些安置策略不需要替换块。例如,我们的首次适配分配器可以返回未损坏的原始块。注意,这将导致大约 14KiB 的空间被用户和分配器未使用。我们称之为内部碎片。

相比之下,外部碎片化是指尽管我们在堆中有足够的内存,但它可能被分割成一种方式,使得连续的该尺寸块不可用。在我们的上一个例子中,64KiB 的堆内存中,有 17KiB 被分配,47KiB 是空闲的。然而,最大的可用块只有 30KiB,因为我们的可用未分配堆内存被分割成更小的块。

安置策略的优缺点

编写堆分配器的挑战

  • 需要最小化碎片化(即最大化内存利用率)

  • 需要高性能

  • 实现复杂——使用链表和指针算术进行大量指针操作。

  • 无论是碎片化还是性能,都取决于应用程序的分配配置文件,这可以评估但不能预测,在实践中,在特定的使用条件下,专用分配器通常可以优于通用实现。

  • 分配器事先不知道程序的内存分配请求。即使我们知道,这也是一个已知的 NP-hard 问题——背包问题!

不同的策略以非直观的方式影响堆内存的碎片化,这些影响只有通过数学分析或在现实条件下的仔细模拟(例如模拟数据库或网络服务器的内存分配请求)才能发现。

首先,我们将对每个算法(Garey, Graham, 和 Ullman 1972)采用更数学化、一次性的方法。论文描述了一个场景,即你有一定数量的桶和一定数量的分配,你试图将分配放入尽可能少的桶中,从而尽可能少地使用内存。论文讨论了理论影响,并在长期运行中对理想内存使用和实际内存使用之间的比率设定了一个很好的限制。对于那些感兴趣的人来说,论文得出结论,随着桶数量的增加,实际内存使用与理想内存使用的比率约为 1.7,对于最佳匹配算法,这个比率下限为 1.7。这个分析的问题在于,很少有实际应用需要这种一次性分配。视频游戏对象分配通常会为每个级别指定不同的子堆,并在需要快速内存分配方案时填满该子堆。

在实践中,我们将使用 2005 年进行的一项更严格调查的结果(Wilson 等人 1995)。调查确保指出内存分配是一个不断变化的目标。对一个程序来说好的分配方案可能对另一个程序来说并不好。程序不会均匀地遵循分配的分布。调查讨论了我们介绍的所有分配方案以及一些额外的方案。以下是一些总结的要点:

  1. 最佳匹配算法在选取几乎合适的块大小时可能会出现问题,剩余空间被分割得非常小,以至于程序可能不会使用它。解决这个问题的一个方法可能是设置一个分割阈值。在常规负载下,这种小的分割并不常见。此外,最佳匹配算法的最坏情况行为很糟糕,但这种情况通常不会发生[第 43 页]。

  2. 调查还讨论了首次匹配的一个重要区别。首次匹配可以有多个概念。首次可以是按照“释放”的时间顺序排列,或者可以通过块的起始地址排列,或者可以按照最后释放的时间顺序排列——首次是最不经常使用的。调查没有深入探讨每种性能,但确实记录了地址顺序和最近最少使用(LRU)列表最终比最近最常使用列表有更好的性能。

  3. 调查最后总结说,在模拟随机(假设随机均匀)的工作负载下,最佳适配(best fit)和首次适配(first fit)表现相当。即使在实践中,最佳适配和地址排序的首次适配在分割阈值和合并操作中也表现得相当。原因并不完全清楚。

我们还做一些额外的笔记

  1. 最佳适配可能比完整堆扫描所需时间更少。当找到一个完美大小或完美大小在阈值内的块时,可以根据你的边缘情况策略返回该块。

  2. 最坏适配也是如此。你的堆可以用最大堆数据结构表示,每次分配调用可以简单地弹出顶部,重新堆化,并可能插入一个分割内存块。然而,使用斐波那契堆可能会非常低效。

  3. 首次适配(First-Fit)需要有一个块顺序。大多数情况下,程序员会默认选择链表,这是一个不错的选择。在使用最近最少使用(least recently used)和最近最少使用(most recently used)链表策略时,改进空间不大,但使用地址排序的链表,你可以通过结合使用随机跳表(skip-list)和单链表,将插入速度从 O(n)提升到 O(log(n))。插入操作会使用跳表作为快捷方式来找到插入块的正确位置,而删除操作会像正常一样遍历列表。

  4. 我们还没有讨论过许多放置策略,其中一个是下一个适配(next-fit),它是在下一个适配块上的首次适配。这增加了确定性随机性——请原谅这个矛盾的说法。你不需要了解这个算法,因为你知道你正在实现一个作为机器问题一部分的内存分配器,还有更多这样的算法。

内存分配器教程

内存分配器需要跟踪哪些字节当前已被分配,哪些可供使用。本节介绍了构建分配器或实现分配器的实际代码的实现和概念细节。

从概念上讲,我们正在考虑创建链表和块列表!请欣赏以下 ASCII 艺术。bt 是边界标签的缩写。

3 Adjacent Memory blocks

3 Adjacent Memory blocks

在我们的下一个块中,我们将有隐式指针,这意味着我们可以通过加法从一个块跳转到另一个块。这与我们的元块中的显式字段形成对比。

Malloc addition

Malloc addition

可以通过找到当前块的末尾来获取下一个块。这就是我们所说的“隐式列表”。

实际的间隔可能不同。元数据可以包含不同内容。最小化元数据实现将仅包含块的大小。

由于我们写入内存中的整数和指针是我们已经控制的,因此我们可以后来一致地从地址跳转到下一个地址。这种内部信息代表了一些开销。这意味着即使我们从系统请求了 1024 KiB 的连续内存,分配该大小的请求也可能失败。

我们的堆内存是一系列块,其中每个块要么已分配,要么未分配。因此,在概念上有一个空闲块列表,但它以我们存储在每个块中的块大小信息的形式隐含存在。让我们从简单实现的角度来考虑它。

typedef struct {
 size_t block_size;
 char data[0];
} block;
block *p = sbrk(100);
p->size = 100 - sizeof(*p) - sizeof(BTag);
// Other block allocations

我们可以通过向块的尺寸添加来从一个块导航到下一个块。

p + sizeof(metadata) + p->block_size + sizeof(BTag)

确保你的类型转换正确!否则,程序将移动极端数量的字节。

调用程序永远不会看到这些值。它们是内存分配器实现内部的。例如,假设你的分配器被要求保留 80 字节(())并需要 8 字节的内部头数据。分配器需要找到一个至少 88 字节的未分配空间。在更新堆数据后,它会返回一个指向块的指针。然而,返回的指针指向的是可用空间,而不是内部数据!相反,我们将返回块的起始地址加 8 字节。在实现中,请记住指针算术依赖于类型。例如,它加上,不一定是 8 字节!

实现内存分配器

最简单的实现使用首次适配。从第一个块开始,假设它存在,并迭代,直到找到一个表示足够大未分配空间的块,或者我们已经检查了所有块。如果没有找到合适的块,那么是时候再次调用以足够扩展堆的大小了。对于这门课程,我们将尝试服务每一个内存请求,直到操作系统告诉我们我们将耗尽堆空间。其他应用程序可能限制自己使用特定的堆大小,导致请求间歇性失败。此外,快速实现可能会显著扩展它,这样我们就不需要很快请求更多的堆内存。

当找到一个空闲块时,它可能比我们需要的空间大。如果是这样,我们将在我们的隐式列表中创建两个条目。第一个条目是已分配的块,第二个条目是剩余的空间。如果程序想要保持开销小,有方法可以做到这一点。我们建议首先考虑可读性。

typedef struct {
 size_t block_size;
 int is_free;
 char data[0];
} block;
block *p = sbrk(100);
p->size = 100 - sizeof(*p) - sizeof(boundary_tag);
// Other block allocations

如果程序想要某些位持有不同的信息片段,请使用位字段!

typedef struct {
 unsigned int block_size : 7;
 unsigned int is_free : 1;
} size_free;

typedef struct {
 size_free info;
 char data[0];
} block;

编译器将处理位移。设置好你的字段后,它就变成了简单地遍历每个块并检查适当的字段。

这里是发生情况的视觉表示。如果我们假设我们有一个看起来像这样的块,我们想要分配 16 字节的空间,那么我们需要做的分割如下。

Malloc split

Malloc split

这也涉及到对齐问题。

对齐和向上取整的考虑

许多架构期望多字节数据对齐到 2 的某个倍数(例如 4、16 等)。例如,通常要求 4 字节数据对齐到 4 字节边界,8 字节数据对齐到 8 字节边界。如果多字节数据存储在不合理的边界上,性能可能会受到显著影响,因为它可能需要额外的内存读取。在某些架构上,这种惩罚甚至更大——程序会因为总线错误而崩溃。如果你们的架构课程中没有内存保护,你们中的大多数人可能都经历过这种情况。

由于不知道用户将如何使用分配的内存,返回给程序的指针需要针对最坏情况对齐,这取决于架构。

根据 glibc 文档,glibc 使用以下启发式方法(“虚拟内存分配和分页” 2001)

malloc 给你的块保证是对齐的,这样它就可以容纳任何类型的数据。在 GNU 系统上,地址通常是 8 的倍数,在 64 位系统上是 16 的倍数。"例如,如果你需要计算所需的 16 字节单元数,别忘了向上取整。

这就是 C 中的数学看起来像什么。

int s = (requested_bytes + tag_overhead_bytes + 15) / 16

额外的常数确保不完整的单元向上取整。注意,真正的代码更可能使用符号大小,例如,而不是编码数值常数 15。这里有一篇关于内存对齐的精彩文章,如果你对此更感兴趣

另一个可能的影响是,当给定的块大于其分配大小时,可能会发生内部碎片。假设我们有一个大小为 16B 的空闲块(不包括元数据)。如果它们分配 7 字节,分配器可能想要向上取整到 16B 并返回整个块。当实现合并和分割时,这会变得很危险。如果分配器没有实现其中任何一个,它可能最终会为一个 7B 的分配返回一个大小为 64B 的块!这个分配的开销很大,这正是我们试图避免的。

实现 free

当调用时,我们需要重新应用偏移量以回到块的“真实”起始位置——即我们存储大小信息的地方。一个简单的实现会简单地标记块为未使用。如果我们正在将块分配状态存储在位域中,那么我们需要清除位:

p->info.is_free = 0;

然而,我们还有更多的工作要做。如果当前块和下一个块(如果存在)都是空闲的,我们需要将这些块合并成一个单独的块。同样,我们还需要检查上一个块。如果它存在并且代表未分配的内存,那么我们需要将这些块合并成一个大的单独块。

为了能够将一个空闲块与前面的空闲块合并,我们还需要找到前面的块,因此我们也将块的大小存储在块的末尾。这些被称为“边界标签”(Knuth 1973)。这是 Knuth 解决合并问题的两种方法。由于块是连续的,一个块的末尾紧邻下一个块的开始。因此,当前块(除了第一个块之外)可以向后查看几个字节以查找前一个块的大小。有了这些信息,分配器现在可以向后跳转!

以双合并为例。如果我们想要释放中间的块,我们需要将周围的块转换成一个大的块

空闲双合并

空闲双合并

性能

根据上述描述,可以构建一个内存分配器。其主要优点是简单性——至少与其他分配器相比是简单的!分配内存是一个最坏情况下的线性时间操作——搜索链表以找到足够大的空闲块。释放分配是常数时间。不需要超过 3 个块合并成一个单独的块,并且使用最近最少使用块方案,只需要一个链表条目。

使用这个分配器,可以尝试不同的放置策略。例如,分配器可以从最后一个释放的块开始搜索。如果分配器存储块指针,它需要更新指针,以确保它们始终有效。

显式空闲列表分配器

通过实现一个显式的双向链表来管理空闲节点,可以取得更好的性能。在这种情况下,我们可以立即遍历到下一个空闲块和上一个空闲块。这可以减少搜索时间,因为链表只包括未分配的块。第二个优点是,我们现在可以控制链表的顺序。例如,当一个块被释放时,我们可以选择将其插入到链表的开始处,而不是总是插入到其邻居之间。我们可能需要更新我们的结构体,使其看起来像这样

typedef struct {
 size_t info;
 struct block *next;
 char data[0];
} block;

这就是它看起来像什么,以及我们的隐式链表

空闲列表

空闲列表

我们在哪里存储我们链表的指针?一个简单的技巧是意识到块本身没有被使用,并将下一个和前一个指针作为块的一部分存储,尽管你必须确保空闲块总是足够大,可以容纳两个指针。我们仍然需要实现边界标签,这样我们就可以正确地释放块并将它们与其两个邻居合并。因此,显式空闲列表需要更多的代码和复杂性。使用显式链接列表时,使用一个快速简单的“查找第一个”算法来查找第一个足够大的链接。然而,由于链接顺序可以修改,这对应着不同的放置策略。如果链接是从大到小维护的,那么这会产生一个“最坏匹配”放置策略。

尽管如此,也存在边缘情况,考虑如何在双合并的同时维护你的空闲列表。我们包含了一个常见的错误示例图。

空闲列表的良好和不良合并

空闲列表的良好和不良合并

我们建议在尝试实现 malloc 时,先在概念上绘制所有情况,然后再编写代码。

显式链接列表插入策略

新释放的块可以轻松地插入两个可能的位置:在开始处或在地址顺序中。在开始处插入创建了一个 LIFO(后进先出)策略。最近释放的空间将被重用。研究表明,碎片化比使用地址顺序更严重(Wilson 等人 1995))。

按地址顺序插入(“地址顺序策略”)将已释放的块插入,以便块按递增的地址顺序访问。这种策略需要更多时间来释放一个块,因为必须使用边界标签(大小数据)来找到下一个和上一个未分配的块。然而,碎片化较少。

案例研究:伙伴分配器,一个分隔列表的例子

分隔分配器是指将堆分成不同区域,这些区域由不同子分配器根据分配请求的大小来处理的分配器。大小被分组为 2 的幂,每个大小由不同的子分配器处理,并且每个大小都维护其空闲列表。

这种类型的知名分配器是 buddy 分配器(Rangan, Raman, 和 Ramanujam 1999 P. 85)。我们将讨论二进制 buddy 分配器,它将分配分成大小为2n;n=1,2,3,...2^n; n = 1, 2, 3, ...倍的一些基本单元字节数的块,但其他也存在,如斐波那契分割,其中分配会被向上舍入到下一个斐波那契数。基本概念很简单:如果没有大小为2n2^n的空闲块,就转到下一级,并偷取那个块并将其分割成两个。如果两个相同大小的相邻块都未被分配,它们可以合并成一个大小加倍的单一大块。

Buddy 分配器速度快,因为可以计算与合并的相邻块地址,而不是遍历大小标签。最佳性能通常需要少量汇编代码来使用专门的 CPU 指令找到最低的非零位。

Buddy 分配器的主要缺点是它们会遭受内部碎片化,因为分配会被向上舍入到最近的块大小。例如,一个 68 字节的分配将需要一个 128 字节的块。

案例研究:SLUB 分配器,Slab 分配

SLUB 分配器是一种为 Linux 内核SLUB服务的 slab 分配器。想象一下,你正在为内核创建一个分配器,你的要求是什么?这里是一个假设的简短清单。

  1. 首要的是,你希望内存占用低,以便内核能够安装在所有类型的硬件上:嵌入式、桌面、超级计算机等。

  2. 然后,你希望实际内存尽可能连续,以便利用缓存。每次执行系统调用时,内核的页面都需要加载到内存中。这意味着如果它们都是连续的,处理器将能够更有效地缓存它们。

  3. 最后,你希望你的分配速度快。

进入 SLUB 分配器。SLUB 分配器是一个具有最小分割和合并的分离列表分配器。这里的区别在于,分离列表专注于更现实的分配大小,而不是 2 的幂。SLUB 还专注于保持缓存中的页面,同时尽量减少整体内存占用。存在不同大小的块,内核将每个分配请求向上舍入到满足其要求的最小块大小。与其它分配器相比,这个分配器的一个重大区别是它通常符合页面大小。我们将在另一章中讨论虚拟内存和页面,但内核将以 4Kib 或 4096 字节的跨度直接处理内存页面。

进一步阅读

指导性问题

  • malloc 分配的内存是否已初始化?calloc 或 realloc 分配的内存呢?

  • realloc 是否接受元素数量或空间(以字节为单位)作为其参数?

  • 为什么分配函数可能会出错?

请参阅手册页或书籍附录中的 17.18.1 部分!

主题

  • 最佳适应

  • 最坏适应

  • 首次适应

  • 伙伴分配器

  • 内部碎片化

  • 外部碎片化

  • sbrk

  • 自然对齐

  • 边界标记

  • 合并

  • 分割

  • 块分配/内存池

问题/练习

  • 什么是内部碎片化?何时会成为一个问题?

  • 什么是外部碎片化?何时会成为一个问题?

  • 什么是最佳适应放置策略?它在外部碎片化方面如何?时间复杂度是多少?

  • 什么是最坏适应放置策略?它在外部碎片化方面是否有任何优势?时间复杂度是多少?

  • 什么是首次适应放置策略?它在外部碎片化方面有何优势?期望的时间复杂度是多少?

  • 假设我们正在使用一个 64kb 的新块伙伴分配器。它是如何分配 1.5kb 的?

  • 当 malloc 的 5 行实现有何用途?

  • 什么是自然对齐?

  • 什么是合并/分割?它们如何增加/减少碎片化?何时可以进行合并或分割?

  • 边界标记是如何工作的?它们如何被用来合并或分割?

线程

线程是“执行线程”的简称。它代表了 CPU 将要执行的指令序列。为了记住如何从函数调用中返回,以及存储自动变量和参数的值,线程使用一个栈。几乎有点奇怪的是,线程是一个进程,这意味着创建线程类似于进程,但是没有复制,也就是说没有写时复制。这允许进程共享相同的地址空间、变量、堆、文件描述符等。创建线程的实际系统调用类似于。它是。我们不会深入细节,但你可以阅读man 手册,记住这超出了本课程的直接范围。LWP(轻量级进程)或线程在许多场景下比 fork 更受欢迎,因为创建它们的开销要小得多。但在某些情况下,特别是 Python 使用这种情况,多进程是使你的代码更快的方法。

进程与线程的比较

创建单独的进程有用的情况:

  • 当需要更多安全性时。例如,Chrome 浏览器为不同的标签页使用不同的进程。

  • 当运行现有且完整的程序时需要创建新进程,例如启动‘gcc’。

  • 当你在遇到同步原语,并且每个进程都在操作系统中的某个东西时。

  • 当你有太多线程时——内核试图将所有线程调度到彼此附近,这可能会造成比好处更多的伤害。

  • 当你不想担心竞态条件时

  • 当通信量足够小,以至于只需要简单的 IPC(进程间通信)时。

另一方面,创建线程更有用,当:

  • 你想利用多核系统的力量来完成一项任务

  • 当你无法处理进程的开销时

  • 当你想要简化进程间的通信时

  • 当你希望线程成为同一进程的一部分时

线程内部结构

你的主函数和其他函数有自动变量。我们将使用栈在内存中存储它们,并通过使用简单的指针(“栈指针”)来跟踪栈的大小。如果线程调用另一个函数,我们将移动我们的栈指针,以便有更多空间用于参数和自动变量。一旦从函数返回,我们可以将栈指针移回到其先前值。我们保留旧栈指针值的副本——在栈上!这就是为什么从函数返回是快速的原因。因为程序需要改变栈指针,所以自动变量占用的内存很容易“释放”。

在多线程程序中,有多个栈,但只有一个地址空间。pthread 库分配一些栈空间,并使用函数调用在栈地址处启动线程。

线程栈可视化

线程栈可视化

一个程序可以在一个进程中运行多个线程。程序会免费获得第一个线程!它运行你写在‘main’中的代码。如果程序需要更多线程,它可以调用以使用 pthread 库创建新线程。您需要传递一个指向函数的指针,以便线程知道从哪里开始。

由于线程都是同一进程的一部分,它们都生活在相同的虚拟内存中。因此,它们都可以看到堆、全局变量和程序代码。

堆中指向同一位置的线程

堆中指向同一位置的线程

因此,一个程序可以在同一进程中同时由两个(或更多)CPU 工作,并且它们可以同时工作。操作系统负责将线程分配给 CPU。如果一个程序的活动线程比 CPU 多,内核将分配一个线程给 CPU 进行短暂的处理,或者直到它没有更多事情可做,然后自动将 CPU 切换到处理另一个线程。例如,一个 CPU 可能正在处理游戏 AI,而另一个线程正在计算图形输出。

简单用法

要使用 pthread,需要包含并编译和链接或编译器选项。此选项告诉编译器,您的程序需要线程支持。要创建线程,请使用函数。此函数接受四个参数:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
  • 第一个是指向将持有新创建的线程 ID 的变量的指针。

  • 第二个是指向我们可以用来调整和调整 pthread 一些高级功能的属性的指针。

  • 第三是我们要运行的函数的指针

  • 第四是传递给我们的函数的指针

参数难以阅读!这意味着一个接受指针并返回指针的指针。它看起来像函数声明,除了函数名被括号包围。

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

void *busy(void *ptr) {
 // ptr will point to "Hi"
 puts("Hello World");
 return NULL;
}
int main() {
 pthread_t id;
 pthread_create(&id, NULL, busy, "Hi");
 void *result;
 pthread_join(id, &result);
}

在上述示例中,结果将是,因为繁忙的函数返回了。我们需要传递结果地址,因为我们将写入指针的内容。

在手册页中,它警告程序员应该使用作为不透明类型,并且不要查看内部结构。尽管如此,我们经常忽略这一点。

Pthread 函数

这里有一些常见的 pthread 函数:

  • 创建一个新线程。每个线程都会得到一个新的栈。如果程序两次调用,则您的进程将包含三个栈 - 每个线程一个。第一个线程是在进程启动时创建的,其他两个是在创建后。实际上,可能还有更多的栈,但让我们保持简单。重要的思想是每个线程都需要一个栈,因为栈包含自动变量和旧的 CPU PC 寄存器,这样它就可以在函数完成后返回调用函数。

  • 停止一个线程。注意,线程可能仍然继续。例如,当线程执行操作系统调用(例如)时,它可能被终止。在实际应用中,很少使用,因为线程不会清理像文件这样的打开资源。另一种实现方法是使用一个布尔(int)变量,其值用于通知其他线程它们应该完成并清理。

  • 停止调用线程,意味着线程在调用后永远不会返回。如果没有其他线程正在运行,pthread 库将自动完成进程。这等价于从线程函数返回;两者都结束线程并设置线程的返回值(void 指针)。在线程中调用是简单程序确保所有线程完成的一种常见方式。例如,在以下程序中,线程可能没有时间开始。另一方面,退出整个进程并设置进程的退出值。这等价于在主方法中。进程内的所有线程都将停止。注意,版本会创建线程僵尸;然而,这不是一个长时间运行的进程,所以我们并不关心。

    int main() {
     pthread_t tid1, tid2;
     pthread_create(&tid1, NULL, myfunc, "Jabberwocky");
     pthread_create(&tid2, NULL, myfunc, "Vorpel");
     if (keep_threads_going) {
     pthread_exit(NULL);
     } else {
     exit(42); //or return 42;
     }
    
     // No code is run after exit
    }
    
  • 等待线程完成并记录其返回值。已完成的线程将继续消耗资源。最终,如果创建了足够的线程,将会失败。在实际应用中,这仅是长时间运行进程的问题,但对于简单、短暂的生命周期进程来说并不是问题,因为所有线程资源在进程退出时都会自动释放。这相当于让你的孩子变成僵尸,所以对于长时间运行的进程要记住这一点。在退出示例中,我们也可以等待所有线程。

    // ...
    void* result;
    pthread_join(tid1, &result);
    pthread_join(tid2, &result);
    return 42;
    // ...
    

有许多退出线程的方法。以下是一个不完整的列表:

  • 从线程函数返回

  • 调用

  • 使用

  • 通过信号终止进程。

  • 调用或

  • 执行另一个程序

  • 断开电脑的电源

  • 一些未定义的行为可以终止你的线程,这是未定义的行为

竞争条件

竞争条件是指程序的结果由处理器根据其事件序列确定。这意味着代码的执行是非确定性的。这意味着相同的程序可以运行多次,并且根据内核如何调度线程,可能会产生不准确的结果。以下是一个典型的竞争条件。

void *thread_main(void *p) {
 int *p_int = (int*) p;
 int x = *p_int;
 x += x;
 *p_int = x;
 return NULL;
}

int main() {
 int data = 1;
 pthread_t one, two;
 pthread_create(&one, NULL, thread_main, &data);
 pthread_create(&two, NULL, thread_main, &data);
 pthread_join(one, NULL);
 pthread_join(two, NULL);
 printf("%d\n", data);
 return 0;
}

分析汇编代码,有许多不同的代码访问方式。我们将假设数据存储在寄存器中。以下是不进行优化的增量代码(假设 int_ptr 包含 eax)。

mov eax, DWORD PTR [rbp-4]    ;Loads int_ptr
add eax, eax                  ;Does the addition
mov DWORD PTR [rbp-4], eax    ;Stores it back

考虑这种访问模式。

线程访问 - 不是竞争条件

线程访问 - 不是竞争条件

这种访问模式将导致变量变为 4。问题是当指令并行执行时。

线程访问 - 竞争条件

线程访问 - 竞争条件

这种访问模式将导致变量被 2. 这是一种未定义的行为和竞态条件。我们希望一次只有一个线程访问代码的一部分。

但当用编译时,汇编输出是一个单条指令。

shl dword ptr [rdi]   # Optimized way of doing the add

难道这不能解决问题吗?这只是一个汇编指令,所以没有交错?它不能解决硬件本身可能遇到的竞态条件问题,因为我们作为程序员没有告诉硬件去检查它。最简单的方法是添加lock前缀(指南 2011,1120)。

但我们不想在汇编语言中编码!我们需要为这个问题想出一个软件解决方案。

赛马日

这里还有一个小的竞态条件。以下代码本应启动十个线程,整数从 0 到 9。然而,当运行时打印出!或者很少打印出我们期望的内容。你能看出为什么吗?

#include <pthread.h>
void* myfunc(void* ptr) {
 int i = *((int *) ptr);
 printf("%d ", i);
 return NULL;
}

int main() {
 // Each thread gets a different value of i to process
 int i;
 pthread_t tid;
 for(i =0; i < 10; i++) {
 pthread_create(&tid, NULL, myfunc, &i); // ERROR
 }
 pthread_exit(NULL);
}

上述代码受到一个-的影响,i 的值正在改变。新线程在示例输出中开始得晚,最后一个线程在循环结束后开始。为了克服这个竞态条件,我们将为每个线程提供一个指向其自己的数据区域的指针。例如,对于每个线程,我们可能想要存储 id、起始值和输出值。我们将把 i 视为一个指针,并通过值进行转换。

void* myfunc(void* ptr) {
 int data = ((int) ptr);
 printf("%d ", data);
 return NULL;
}

int main() {
 // Each thread gets a different value of i to process
 int i;
 pthread_t tid;
 for(i =0; i < 10; i++) {
 pthread_create(&tid, NULL, myfunc, (void *)i);
 }
 pthread_exit(NULL);
}

竞态条件不在我们的代码中。它们可能存在于提供的代码中。一些函数如, , ,不是线程安全的。让我们看看一个也是不“线程安全”的简单函数。结果缓冲区可以存储在全局内存中。在单线程程序中这是好的。我们不想返回一个指向栈上无效地址的指针,但在整个内存中只有一个结果缓冲区。如果两个线程同时使用它,一个会破坏另一个。

char *to_message(int num) {
 static char result [256];
 if (num < 10) sprintf(result, "%d : blah blah" , num);
 else strcpy(result, "Unknown");
 return result;
}

有一些方法可以解决这个问题,比如使用同步锁,但首先让我们通过设计来做这件事。你会如何修复上面的函数?你可以更改任何参数和任何返回类型。这里有一个有效的解决方案。

int to_message_r(int num, char *buf, size_t nbytes) {
 size_t written;
 if (num < 10) {
 written = snprintf(buf, nbtytes, "%d : blah blah" , num);
 } else {
 strncpy(buf, "Unknown", nbytes);
 buf[nbytes] = '\0';
 written = strlen(buf) + 1;
 }
 return written <= nbytes;
}

我们不是让函数负责内存,而是让调用者负责!许多程序,包括希望你的程序,需要的通信量最小。通常 malloc 调用比锁定互斥锁或向另一个线程发送消息要少。

不要跨界

一个程序可以在一个多线程进程中 fork!然而,子进程只有一个线程,它是调用进程的线程的克隆。我们可以将这视为一个简单示例,其中后台线程永远不会在子进程中打印第二条消息。

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

static pid_t child = -2;

void *sleepnprint(void *arg) {
 printf("%d:%s starting up...\n", getpid(), (char *) arg);

 while (child == -2) {sleep(1);} /* Later we will use condition variables */

 printf("%d:%s finishing...\n",getpid(), (char*)arg);

 return NULL;
}
int main() {
 pthread_t tid1, tid2;
 pthread_create(&tid1,NULL, sleepnprint, "New Thread One");
 pthread_create(&tid2,NULL, sleepnprint, "New Thread Two");

 child = fork();
 printf("%d:%s\n",getpid(), "fork()ing complete");
 sleep(3);

 printf("%d:%s\n",getpid(), "Main thread finished");

 pthread_exit(NULL);
 return 0; /* Never executes */
}
8970:New Thread One starting up...
8970:fork()ing complete
8973:fork()ing complete
8970:New Thread Two starting up...
8970:New Thread Two finishing...
8970:New Thread One finishing...
8970:Main thread finished
8973:Main thread finished

实际上,在分叉之前创建线程可能导致意外的错误,因为(如上所示)在分叉时其他线程会立即终止。另一个线程可能通过调用 malloc 锁定互斥锁,然后永远不会解锁它。然而,高级用户可能会发现这很有用,但我们建议程序在充分理解这种方法局限性和困难的情况下避免在分叉之前创建线程。

令人尴尬的并行问题

近年来,并行算法的研究已经激增。任何只需稍加努力就能实现并行的难题都可以称为令人尴尬的并行问题。其中许多问题与某些同步概念相关,但并非总是如此。你已经知道一个可并行化的算法,即归并排序!

void merge_sort(int *arr, size_t len){
 if(len > 1){
 // Merge Sort the left half
 // Merge Sort the right half
 // Merge the two halves
 }

在你对线程有了新的理解之后,你所需要做的就是为左半部分创建一个线程,为右半部分创建一个线程。鉴于你的 CPU 有多个真实核心,你将看到根据Amdahl 定律的速度提升。在这里,时间复杂度分析也变得很有趣。并行算法以O(log3(n))O(\log³(n))的运行时间运行,因为我们假设我们有很多核心。

然而,在实践中,我们通常进行两个更改。一是,一旦数组足够小,我们就放弃并行归并排序算法,并使用在小型数组上运行快速的常规排序,通常在这个级别上遵循缓存一致性规则。另一件事是我们知道 CPU 没有无限的核心。为了解决这个问题,我们通常保持一个工作池。由于缓存一致性、调度额外线程等问题,你不会立即看到速度提升。然而,在更大的代码片段上,你将开始看到速度提升。

另一个令人尴尬的并行问题是并行映射。假设我们想要逐个元素将一个函数应用于整个数组。

int *map(int (*func)(int), int *arr, size_t len){
 int *ret = malloc(len*sizeof(*arr));
 for(size_t i = 0; i < len; ++i) {
 ret[i] = func(arr[i]);
 }
 return ret;
}

由于没有任何元素依赖于其他元素,你将如何进行并行化?你认为将工作分配给线程的最佳方式是什么?

在附录中查看线程调度以获取更多调度方法。

其他问题

来自Wikipedia

  • 在 Web 服务器上同时为多个用户提供静态文件。

  • 曼德布罗特集、Perlin 噪声和类似图像,其中每个点都是独立计算的。

  • 计算机图形的渲染。在计算机动画中,每一帧可以独立渲染(参见并行渲染)。

  • 密码学中的暴力搜索。

  • 举世瞩目的实际例子包括分布式.net 和加密货币中使用的证明工作系统。

  • 生物信息学中的 BLAST 搜索,用于多个查询(但不用于单个大型查询)

  • 比较数千个任意获取的面(例如,通过闭路电视的安全或监控视频)与大量先前存储的面(例如,流氓画廊或类似的观察名单)的大规模人脸识别系统。

  • 比较许多独立场景的计算机模拟,例如气候模型。

  • 遗传算法等进化计算元启发式算法。

  • 数值天气预报的集成计算。

  • 粒子物理学中的事件模拟和重建。

  • 行军方阵算法

  • 二次筛法和数域筛法的筛选步骤。

  • 随机森林机器学习技术的树增长步骤。

  • 每个谐波独立计算的离散傅里叶变换。

高级:轻量级进程?

在本章的开头,我们提到线程是进程。我们这是什么意思?你可以像创建进程一样创建一个线程。看看下面的示例代码。

 // 8 KiB stacks
#define STACK_SIZE (8 * 1024 * 1024)

int thread_start(void *arg) {
 // Just like the pthread function
 puts("Hello Clone!")
 // This share the same heap and address space!
 return 0;
}

int main() {
 // Allocate stack space for the child
 char *child_stack = malloc(STACK_SIZE);
 // Remember stacks work by growing down, so we need
 // to give the top of the stack
 char *stack_top = stack + STACK_SIZE;

 // clone create thread
 pid_t pid = clone(thread_start, stack_top, SIGCHLD, NULL);
 if (pid == -1) {
 perror("clone");
 exit(1);
 }
 printf("Child pid %ld\n", (long) pid);

 // Wait like any child
 if (waitpid(pid, NULL, 0) == -1) {
 perror("waitpid");
 exit(1);
 }

 return 0;
}

这看起来很简单,不是吗?为什么不用这个功能?首先,有一大堆样板代码。此外,pthread 是 POSIX 标准的一部分,并具有定义的功能。Pthreads 允许程序设置各种属性——一些类似于 clone 的选项——以自定义你的线程。但如我们之前提到的,出于可移植性的原因,随着每个抽象层次的增加,我们都会失去一些功能。clone 可以做一些很酷的事情,比如在创建其他页面的副本时保持堆的不同部分相同。程序对调度的控制更精细,因为它是一个具有相同映射的进程。

在本课程中,任何时候都不应该使用 clone。但将来,要知道它是一个完美的 fork 替代方案。你必须小心并研究边缘情况。

进一步阅读

指导问题:

  • pthread create 的第一个参数是什么?

  • pthread create 中的起始路由是什么?arg 又是如何?

  • 为什么 pthread create 可能会失败?

  • 在一个进程中,线程共享哪些东西?线程有什么不同之处?

  • 线程如何唯一地识别自己?

  • 一些非线程安全库函数的例子是什么?为什么它们可能不是线程安全的?

  • 程序如何停止一个线程?

  • 程序如何获取线程的“返回值”?

  • 手册页面

  • pthread 参考指南

  • 简洁的第三方示例代码解释创建、连接和退出

主题

  • pthread 生命周期

  • 每个线程都有一个堆栈

  • 从线程捕获返回值

  • 使用

  • 使用

  • 使用

  • 在什么条件下进程会退出

问题

  • 当 pthread 被创建时会发生什么?

  • 每个线程的堆栈在哪里?

  • 程序在给定的情况下如何获取返回值?线程可以设置该返回值的方式有哪些?如果程序丢弃了返回值,会发生什么?

  • 为什么这很重要(考虑栈空间、寄存器、返回值)?

  • 如果它不是最后一个线程,会怎么办?在调用 pthread_exit 之后,还会调用哪些其他函数?

  • 请给出三个多线程进程退出的条件。还有其他条件吗?

  • 什么是令人尴尬的并行问题?

同步

同步协调各种任务,以确保它们都完成在正确的状态。在 C 语言中,我们有一系列机制来控制在给定状态下允许线程执行的操作。大多数时候,线程可以无需通信地进展,但每隔一段时间,两个或更多的线程可能想要访问临界区。临界区是程序要正确运行时一次只能由一个线程执行的代码段。如果两个线程(或进程)同时在临界区内部执行代码,程序可能不再具有正确的行为。

正如我们在上一章中提到的,竞态条件发生在操作同时触及内存的同一时间。如果内存位置只能由一个线程访问,例如下面的自动变量,那么就没有竞态条件的可能性,也没有与之相关的临界区。然而,该变量是一个全局变量,被两个线程访问。可能两个线程会同时尝试增加该变量。

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

int sum = 0; //shared

void *countgold(void *param) {
 int i; //local to each thread
 for (i = 0; i < 10000000; i++) {
 sum += 1;
 }
 return NULL;
}

int main() {
 pthread_t tid1, tid2;
 pthread_create(&tid1, NULL, countgold, NULL);
 pthread_create(&tid2, NULL, countgold, NULL);

 //Wait for both threads to finish:
 pthread_join(tid1, NULL);
 pthread_join(tid2, NULL);

 printf("ARRRRG sum is %d\n", sum);
 return 0;
}

上述代码的典型输出是因为存在竞态条件。该代码允许两个线程同时读取和写入。例如,两个线程都将当前的总和值复制到运行每个线程的 CPU(让我们选择 123)。两个线程各自对其副本加一。两个线程都将值写回(124)。如果线程在不同的时间访问总和,计数将是 125。以下是一些可能的不同顺序。

允许的模式

良好的线程访问模式

线程 1 线程 2
加载地址,加 1(局部 i=1) ...
存储(全局 i=1) ...
... 加载地址,加 1(局部 i=2)
... 存储(全局 i=2)

部分重叠

恶劣的线程访问模式

线程 1 线程 2
加载地址,加 1(局部 i=1) ...
存储(全局 i=1) 加载地址,加 1(局部 i=1)
... 存储(全局 i=1)

完全重叠

可怕的线程访问模式

线程 1 线程 2
加载地址,加 1(局部 i=1) 加载地址,加 1(局部 i=1)
存储(全局 i=1) 存储(全局 i=1)

我们希望代码的第一个模式是互斥的。这引出了我们的第一个同步原语,互斥锁。

互斥锁

为了确保一次只有一个线程可以访问全局变量,请使用互斥锁(mutex)——即互斥的简称。如果当前有一个线程正在临界区内部,我们希望另一个线程等待直到第一个线程完成。在严格意义上,互斥锁不是一个原语,尽管它是具有有用线程 API 的最小原语之一。互斥锁也不是一个数据结构。它是一个抽象数据类型。

让我们考虑一个满足互斥锁 API 的鸭子。如果有人拥有鸭子,他们就可以访问共享资源!我们称之为互斥锁鸭子。其他人必须摇摇摆摆地等待。一旦有人放手鸭子,他们必须停止与资源的交互,下一个抓取者才能与共享资源交互。现在你知道了鸭子的起源。

实现互斥锁有许多方法,我们将在本章中给出几个。目前,让我们使用 pthread 库提供的黑盒。以下是声明互斥锁的方法。

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m); // start of Critical Section
// Critical section
pthread_mutex_unlock(&m); //end of Critical Section

互斥锁生命周期

对于所有互斥锁,有两种初始化互斥锁的方法:

宏在功能上等同于更通用的。换句话说,将创建具有默认属性的互斥锁。在 init 版本中包括选项以在性能和额外的错误检查、高级共享等方面进行权衡。虽然我们建议在程序中使用 init 函数来创建位于堆上的互斥锁,但你可以使用任何一种方法。

pthread_mutex_t *lock = malloc(sizeof(pthread_mutex_t));
pthread_mutex_init(lock, NULL);
//later
pthread_mutex_destroy(lock);
free(lock);

一旦我们完成对互斥锁的使用,我们也应该调用它。注意,程序只能销毁未锁定的互斥锁,在已锁定的互斥锁上销毁是未定义的行为。关于初始化和销毁互斥锁需要注意的事项:

  1. 初始化已经初始化的互斥锁是未定义的行为

  2. 销毁已锁定的互斥锁是未定义的行为

  3. 保持一个模式,只有一个线程初始化互斥锁。

  4. 将互斥锁的字节复制到新的内存位置然后使用它是不支持的。要引用互斥锁,程序必须有一个指向该内存地址的指针。

  5. 全局/静态互斥锁不需要销毁。

互斥锁用法

如何使用互斥锁?这里有一个完整的示例,灵感来源于之前的代码片段。

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

// Create a global mutex, this is ready to be locked!
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

int sum = 0;

void *countgold(void *param) {
 int i;

 //Same thread that locks the mutex must unlock it
 //Critical section is 'sum += 1'
 //However locking and unlocking ten million times
 //has significant overhead

 pthread_mutex_lock(&m);

 // Other threads that call lock will have to wait until we call unlock

 for (i = 0; i < 10000000; i++) {
 sum += 1;
 }
 pthread_mutex_unlock(&m);
 return NULL;
}

int main() {
 pthread_t tid1, tid2;
 pthread_create(&tid1, NULL, countgold, NULL);
 pthread_create(&tid2, NULL, countgold, NULL);

 pthread_join(tid1, NULL);
 pthread_join(tid2, NULL);

 printf("ARRRRG sum is %d\n", sum);
 return 0;
}

在上面的代码中,线程在进入之前获取了计数屋的锁。关键部分只是这样,所以下面的版本也是正确的。

for (i = 0; i < 10000000; i++) {
 pthread_mutex_lock(&m);
 sum += 1;
 pthread_mutex_unlock(&m);
}
return NULL;
}

这个过程运行得更慢,因为我们锁和解锁互斥锁一百万次,这是昂贵的——至少与增加变量相比。在这个简单的例子中,我们不需要线程——我们本可以加两次!一个更快的多线程示例是使用自动(局部)变量添加一百万,然后在计算循环完成后将结果添加到共享总和中:

int local = 0;
for (i = 0; i < 10000000; i++) {
 local += 1;
}

pthread_mutex_lock(&m);
sum += local;
pthread_mutex_unlock(&m);

return NULL;
}

从一些常见问题开始。首先,C 互斥锁不会锁定变量。互斥锁是一个简单的数据结构。它与代码一起工作,而不是数据。如果一个互斥锁被锁定,其他线程将继续。只有在线程尝试锁定一个已经锁定的互斥锁时,线程才需要等待。一旦原始线程解锁互斥锁,第二个(等待的)线程将获得锁并能够继续。以下代码创建了一个实际上什么也不做的互斥锁。

int a;
pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER,
m2 = = PTHREAD_MUTEX_INITIALIZER;
// later
// Thread 1
pthread_mutex_lock(&m1);
a++;
pthread_mutex_unlock(&m1);

// Thread 2
pthread_mutex_lock(&m2);
a++;
pthread_mutex_unlock(&m2);

这里有一些其他需要注意的问题,没有特定的顺序

  1. 不要横穿水流!如果在程序中使用线程,不要在程序中间进行分叉。这意味着在初始化互斥锁之后任何时候。

  2. 锁定互斥锁的线程是唯一可以解锁它的线程。

  3. 每个程序可以有多个互斥锁。一个线程安全的可能设计包括每个数据结构一个锁,每个堆一个锁,或者每组数据结构一个锁。如果一个程序只有一个锁,那么可能存在对锁的显著竞争。如果两个线程正在更新两个不同的计数器,使用相同的锁并不是必要的。

  4. 锁只是工具。它们不会发现临界区!

  5. 调用和 . 总会有一些开销,然而,这是为了正确运行的程序所必须付出的代价!

  6. 由于错误条件下的早期返回而没有解锁互斥锁

  7. 资源泄露(没有调用 )

  8. 使用未初始化的互斥锁或使用已经被销毁的互斥锁

  9. 在一个线程上两次锁定互斥锁而没有先解锁

  10. 死锁

互斥锁实现

所以我们有一个很酷的数据结构。我们如何实现它?下面是一个简单且不正确的实现示例。该函数简单地解锁互斥锁并返回。锁定函数首先检查锁是否已经锁定。如果当前锁定,它将再次检查,直到另一个线程解锁互斥锁。目前,我们将避免其他线程能够解锁它们不拥有的锁的条件,并专注于互斥排他方面。

// Version 1 (Incorrect!)

void lock(mutex_t *m) {
 while(m->locked) { /*Locked? Never-mind - loop and check again!*/ }

 m->locked = 1;
}

void unlock(mutex_t *m) {
 m->locked = 0;
}

第 1 版使用不必要的“忙等待”,浪费了 CPU 资源。然而,有一个更严重的问题。我们有一个竞争条件!如果两个线程同时调用,两个线程都可能读取为零。因此,两个线程都会认为它们对锁有独占访问权,并且两个线程将继续执行。

我们可能尝试通过在循环中调用来稍微减少 CPU 开销 - pthread_yield 建议操作系统该线程不会在短时间内使用 CPU,因此 CPU 可能会分配给等待运行的线程。这仍然留下了竞争条件。我们需要一个更好的实现。我们将在本章的临界区部分稍后讨论这个问题。现在,我们将讨论信号量。

高级:使用硬件实现互斥锁

我们可以使用 C11 原子操作来完美地做到这一点!一个完整的解决方案在这里详细说明。这是一个自旋锁互斥锁,futex的实现可以在网上找到。

首先是数据结构和初始化代码。

typedef struct mutex_{
 // We need some variable to see if the lock is locked
 atomic_int_least8_t lock;
 // A mutex needs to keep track of its owner so
 // Another thread can't unlock it
 pthread_t owner;
} mutex;

#define UNLOCKED 0
#define LOCKED 1
#define UNASSIGNED_OWNER 0

int mutex_init(mutex* mtx){
 // Some simple error checking
 if(!mtx){
 return 0;
 }
 // Not thread-safe the user has to take care of this
 atomic_init(&mtx->lock, UNLOCKED);
 mtx->owner = UNASSIGNED_OWNER;
 return 1;
}

这是初始化代码,这里没有特别之处。我们将互斥锁的状态设置为未锁定,并将所有者设置为锁定。

int mutex_lock(mutex* mtx){
 int_least8_t zero = UNLOCKED;
 while(!atomic_compare_exchange_weak_explicit
 (&mtx->lock,
 &zero,
 LOCKED,
 memory_order_seq_cst,
 memory_order_seq_cst)){
 zero = UNLOCKED;
 sched_yield(); // Use system calls for scheduling speed
 }
 // We have the lock now
 mtx->owner = pthread_self();
 return 1;
}

这段代码做了什么?它初始化了一个变量,我们将保持它作为未解锁状态。原子比较和交换是大多数现代架构支持的指令(在 x86 上它是)。这个操作的伪代码看起来像这样

int atomic_compare_exchange_pseudo(int* addr1, int* addr2, int val){
 if(*addr1 == *addr2){
 *addr1 = val;
 return 1;
 }else{
 *addr2 = *addr1;
 return 0;
 }
}

除了它都是通过原子操作完成的,这意味着在一个不可中断的操作中完成。那么“弱”部分是什么意思呢?原子指令容易发生虚假失败,这意味着这些原子函数有两种版本:强和弱部分,强部分保证成功或失败,而弱部分可能在操作成功时也会失败。这些就是你在下面的条件变量中会看到的虚假失败。我们使用弱部分是因为它更快,而且我们处于一个循环中!这意味着如果它稍微失败得更频繁一些,我们也可以接受,因为我们无论如何都会继续旋转。

在 while 循环内部,我们未能获取锁!我们将零重置为未锁定并稍作休眠。当我们醒来时,我们再次尝试获取锁。一旦我们成功交换,我们就进入了临界区!我们将互斥锁的所有者设置为当前线程以用于解锁方法,并成功返回。

这是如何保证互斥的呢?当与原子操作一起工作时,我们不确定!但在这个简单的例子中,我们可以,因为能够成功期望锁为未锁定(0)并将其交换为锁定(1)状态的线程被认为是获胜者。我们如何实现解锁?

int mutex_unlock(mutex* mtx){
 if(unlikely(pthread_self() != mtx->owner)){
 return 0; // Can't unlock a mutex if the thread isn't the owner
 }
 int_least8_t one = 1;
 //Critical section ends after this atomic
 mtx->owner = UNASSIGNED_OWNER;
 if(!atomic_compare_exchange_strong_explicit(
 &mtx->lock,
 &one,
 UNLOCKED,
 memory_order_seq_cst,
 memory_order_seq_cst)){
 //The mutex was never locked in the first place
 return 0;
 }
 return 1;
}

为了满足 API,除非线程是拥有它的那个线程,否则线程不能解锁互斥锁。然后我们取消分配互斥锁的所有者,因为原子操作后临界区已经结束。我们希望有一个强交换,因为我们不希望阻塞。我们期望互斥锁被锁定,并将其交换以解锁。如果交换成功,我们已解锁互斥锁。如果交换不成功,这意味着互斥锁是未锁定的,我们试图将其从未锁定切换到未锁定,保留了解锁的行为。

这是什么内存顺序的问题?我们之前讨论了内存栅栏,这里就是!我们不会深入讨论,因为这超出了本课程的范围,但在这篇文章的范围内。我们需要一致性来确保没有加载或存储在之前或之后被排序。程序需要创建依赖链以实现更有效的排序。

信号量

信号量是另一种同步原语。它被初始化为某个值。线程可以增加或减少这个值。如果值达到零并且调用了等待操作,线程将被阻塞,直到调用了发布操作。

使用信号量与使用互斥锁一样简单。首先,决定初始值,例如数组中剩余空间的数量。与 pthread 互斥锁不同,创建信号量没有捷径 - 使用点号。

#include <semaphore.h>

sem_t s;
int main() {
 sem_init(&s, 0, 10); // returns -1 (=FAILED) on OS X
 sem_wait(&s); // Could do this 10 times without blocking
 sem_post(&s); // Announce that we've finished (and one more resource item is available; increment count)
 sem_destroy(&s); // release resources of the semaphore
}

当使用信号量时,等待和发布操作可以从不同的线程中调用!与互斥锁不同,增加和减少操作可以来自不同的线程。

如果你想使用信号量来实现互斥锁,这会变得特别有用。互斥锁是一个信号量,它总是在它之前。一些教科书将互斥锁称为二进制信号量。你必须小心,不要将多于一个的互斥锁添加到信号量中,否则你的互斥锁抽象就会失效。这就是通常使用互斥锁来实现信号量,反之亦然的原因。

  • 使用计数为 1 的值初始化信号量。

  • 替换为

  • 替换为

sem_t s;
sem_init(&s, 0, 1);

sem_wait(&s);
// Critical Section
sem_post(&s);

但要小心,这并不相同!互斥锁可以很好地处理我们所说的锁反转问题。这意味着以下代码在传统互斥锁下会出错,但在线程中会产生竞态条件。

// Thread 1
sem_wait(&s);
// Critical Section
sem_post(&s);

// Thread 2
// Some threads want to see the world burn
sem_post(&s);

// Thread 3
sem_wait(&s);
// Not thread-safe!
sem_post(&s);

如果我们用互斥锁来替换它,现在就不会起作用了。

// Thread 1
mutex_lock(&s);
// Critical Section
mutex_unlock(&s);

// Thread 2
// Foiled!
mutex_unlock(&s);

// Thread 3
mutex_lock(&s);
// Now it's thread-safe
mutex_unlock(&s);

此外,二进制信号量与互斥锁不同,因为一个线程可以从另一个线程解锁互斥锁。

信号安全

此外,并不是所有可以在信号处理程序内部正确使用的函数都可以。我们可以释放一个等待的线程,这样它现在就可以执行我们不允许在信号处理程序内部调用的所有调用,例如。以下是一些利用这一点的代码示例;

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

sem_t s;

void handler(int signal) {
 sem_post(&s); /* Release the Kraken! */
}

void *singsong(void *param) {
 sem_wait(&s);
 printf("Waiting until a signal releases...\n");
}

int main() {
 int ok = sem_init(&s, 0, 0 /* Initial value of zero*/);
 if (ok == -1) {
 perror("Could not create unnamed semaphore");
 return 1;
 }
 signal(SIGINT, handler); // Too simple! See Signals chapter

 pthread_t tid;
 pthread_create(&tid, NULL, singsong, NULL);
 pthread_exit(NULL); /* Process will exit when there are no more threads */
}

信号量还有其他用途,比如跟踪数组中的空位。我们将在线程安全数据结构部分讨论这些内容。

条件变量

条件变量允许一组线程在唤醒之前睡眠。API 允许唤醒一个或所有线程。如果程序只唤醒一个线程,操作系统将决定唤醒哪个线程。线程不会直接通过 id 唤醒其他线程。相反,一个线程“信号”条件变量,然后它会唤醒一个(或所有)在条件变量内部睡眠的线程。

条件变量也和互斥锁以及循环一起使用,所以当唤醒时,它们必须在临界区检查一个条件。如果线程需要在临界区外被唤醒,POSIX 提供了其他方法来做这件事。在条件变量中睡眠的线程可以通过调用(唤醒所有)或(唤醒一个)来被唤醒。请注意,尽管函数名如此,这与 POSIX 标准无关!

有时,一个等待的线程可能会无缘无故地唤醒。这被称为虚假唤醒。如果你阅读了互斥锁的硬件实现部分,这类似于同名的原子失败。

为什么会出现虚假唤醒?为了性能。在多 CPU 系统中,可能存在竞态条件导致唤醒(信号)请求未被注意到。内核可能无法检测到丢失的唤醒调用,但可以检测到可能发生的情况。为了避免可能丢失的信号,线程被唤醒,以便程序代码可以再次测试条件。如果你想知道原因,请查看附录。

线程安全数据结构

自然地,我们希望我们的数据结构也是线程安全的!我们可以使用互斥锁和同步原语来实现这一点。首先是一些定义。原子性是指操作是线程安全的。我们通过提供锁前缀在硬件中提供原子指令。

lock ...

但原子性也适用于更高阶的操作。我们说一个数据结构操作是原子的,如果它一次性发生并且成功或根本不发生。

因此,我们可以使用同步原语来使我们的数据结构线程安全。大部分情况下,我们将使用互斥锁,因为它们比二进制信号量具有更多的语义意义。注意,这是一个介绍。编写高性能的线程安全数据结构需要它自己的书籍!例如,以下是一个线程不安全的栈。

// A simple fixed-sized stack (version 1)
#define STACK_SIZE 20
int count;
double values[STACK_SIZE];

void push(double v) {
 values[count++] = v;
}

double pop() {
 return values[--count];
}

int is_empty() {
 return count == 0;
}

栈的版本 1 是线程不安全的,因为如果有两个线程同时调用 push 或 pop,那么结果或栈可能不一致。例如,想象一下如果有两个线程同时调用 pop,那么两个线程可能会读取相同的值,都可能会读取原始的计数值。

要将此转换为线程安全的数据结构,我们需要确定代码的关键部分,这意味着我们需要询问哪些代码部分必须一次只有一个线程。在上面的例子中,,和函数访问相同的内存,以及栈的所有关键部分。当(和)执行时,数据结构处于不一致的状态,例如计数可能尚未写入,因此它可能仍然包含原始值。通过将这些方法包装在互斥锁中,我们可以确保一次只有一个线程可以更新(或读取)栈。下面显示了一个候选的“解决方案”。这是否正确?如果不正确,它将如何失败?

// An attempt at a thread-safe stack (version 2)
#define STACK_SIZE 20
int count;
double values[STACK_SIZE];

pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;

void push(double v) {
 pthread_mutex_lock(&m1);
 values[count++] = v;
 pthread_mutex_unlock(&m1);
}

double pop() {
 pthread_mutex_lock(&m2);
 double v = values[--count];
 pthread_mutex_unlock(&m2);

 return v;
}

int is_empty() {
 pthread_mutex_lock(&m1);
 return count == 0;
 pthread_mutex_unlock(&m1);
}

版本 2 至少包含一个错误。花点时间看看你是否能找到错误(们)并分析其后果。

如果有三个线程同时调用,锁确保在 push 或 is_empty 时只有一次只有一个线程操作栈——两个线程将需要等待第一个线程完成。类似的论点也适用于对.的并发调用。然而,版本 2 并没有防止 push 和 pop 同时运行,因为它们使用了两个不同的互斥锁。在这种情况下,修复很简单——为 push 和 pop 函数使用相同的互斥锁。

代码有一个第二个错误。在比较之后返回,并且没有锁定互斥锁。然而,错误不会立即被发现。例如,假设一个线程调用,而第二个线程稍后调用。这个线程会神秘地停止。使用调试器,你可以发现线程在方法内部的 lock()方法上卡住了,因为之前的调用从未解锁锁。因此,一个线程的疏忽导致在任意其他线程中时间较晚出现的问题。让我们尝试纠正这些问题。

// An attempt at a thread-safe stack (version 3)
int count;
double values[count];
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void push(double v) {
 pthread_mutex_lock(&m);
 values[count++] = v;
 pthread_mutex_unlock(&m);
}
double pop() {
 pthread_mutex_lock(&m);
 double v = values[--count];
 pthread_mutex_unlock(&m);
 return v;
}
int is_empty() {
 pthread_mutex_lock(&m);
 int result = count == 0;
 pthread_mutex_unlock(&m);
 return result;
}

版本 3 是线程安全的。我们已经确保了所有关键部分的互斥。有一些需要注意的事项。

  • 是线程安全的,但它的结果可能已经过时。当线程得到结果时,栈可能已经不再为空!这通常是在线程安全的数据结构中,移除或弃用返回大小的函数的原因。

  • 没有防止下溢(在空栈上弹出)或溢出(在已满栈上压入)的保护。

最后一点可以使用计数信号量来修复。该实现假设一个单独的栈。一个更通用版本可能包括互斥锁作为内存结构的一部分,并用于初始化互斥锁。例如,

// Support for multiple stacks (each one has a mutex)
typedef struct stack {
 int count;
 pthread_mutex_t m;
 double *values;
} stack_t;

stack_t* stack_create(int capacity) {
 stack_t *result = malloc(sizeof(stack_t));
 result->count = 0;
 result->values = malloc(sizeof(double) * capacity);
 pthread_mutex_init(&result->m, NULL);
 return result;
}
void stack_destroy(stack_t *s) {
 free(s->values);
 pthread_mutex_destroy(&s->m);
 free(s);
}

// Warning no underflow or overflow checks!

void push(stack_t *s, double v) {
 pthread_mutex_lock(&s->m);
 s->values[(s->count)++] = v;
 pthread_mutex_unlock(&s->m);
}

double pop(stack_t *s) {
 pthread_mutex_lock(&s->m);
 double v = s->values[--(s->count)];
 pthread_mutex_unlock(&s->m);
 return v;
}

int is_empty(stack_t *s) {
 pthread_mutex_lock(&s->m);
 int result = s->count == 0;
 pthread_mutex_unlock(&s->m);
 return result;
}

int main() {
 stack_t *s1 = stack_create(10 /* Max capacity*/);
 stack_t *s2 = stack_create(10);
 push(s1, 3.141);
 push(s2, pop(s1));
 stack_destroy(s2);
 stack_destroy(s1);
}

在我们修复信号量的问题之前。我们该如何修复条件变量的问题?在查看上一节中的代码之前先试一试。如果我们的栈满了或空了,我们需要在压入和弹出时等待。尝试的解决方案:

// Assume cv is a condition variable
// correctly initialized

void push(stack_t *s, double v) {
 pthread_mutex_lock(&s->m);
 if(s->count == 0) pthread_cond_wait(&s->cv, &s->m);
 s->values[(s->count)++] = v;
 pthread_mutex_unlock(&s->m);
}

double pop(stack_t *s) {
 pthread_mutex_lock(&s->m);
 if(s->count == 0) pthread_cond_wait(&s->cv, &s->m);
 double v = s->values[--(s->count)];
 pthread_mutex_unlock(&s->m);
 return v;
}

以下解决方案有效吗?在查看答案之前,先花点时间找出错误。

你都捕捉到它们了吗?

  1. 第一个很简单。在压入时,我们的检查应该针对总容量,而不是零。

  2. 我们只有 if 语句检查。wait()可能会意外唤醒

  3. 我们从不通知任何线程!线程可能会无限期地等待。

让我们修复那些错误,这个解决方案有效吗?

void push(stack_t *s, double v) {
 pthread_mutex_lock(&s->m);
 while(s->count == capacity) pthread_cond_wait(&s->cv, &s->m);
 s->values[(s->count)++] = v;
 pthread_mutex_unlock(&s->m);
 pthread_cond_signal(&s->cv);
}

double pop(stack_t *s) {
 pthread_mutex_lock(&s->m);
 while(s->count == 0) pthread_cond_wait(&s->cv, &s->m);
 double v = s->values[--(s->count)];
 pthread_cond_broadcast(&s->cv);
 pthread_mutex_unlock(&s->m);
 return v;
}

这个解决方案也不行!问题是出在信号上。你能看到为什么吗?你会怎么做来修复它?

现在,我们该如何使用计数信号量来防止溢出和下溢?我们将在下一节中讨论。

使用信号量

让我们使用计数信号量来跟踪剩余空间的数量,并使用另一个信号量来跟踪栈中的项目数量。我们将这两个信号量称为和。记住,如果信号量的计数被递减到零(由另一个线程调用 sem_post),则会等待。

// Sketch #1

sem_t sitems;
sem_t sremain;
void stack_init(){
 sem_init(&sitems, 0, 0);
 sem_init(&sremain, 0, 10);
}

double pop() {
 // Wait until there's at least one item
 sem_wait(&sitems);
 ...

 void push(double v) {
 // Wait until there's at least one space
 sem_wait(&sremain);
 ...
 }

图#2 过早地实现了。另一个在压入时等待的线程可能会错误地尝试写入一个满栈。同样,在 pop()中等待的线程也被允许过早地继续。

// Sketch #2 (Error!)
double pop() {
 // Wait until there's at least one item
 sem_wait(&sitems);
 sem_post(&sremain); // error! wakes up pushing() thread too early
 return values[--count];
}
void push(double v) {
 // Wait until there's at least one space
 sem_wait(&sremain);
 sem_post(&sitems); // error! wakes up a popping() thread too early
 values[count++] = v;
}

图 3 实现了正确的信号量逻辑,但你能否找到错误?

// Sketch #3 (Error!)
double pop() {
 // Wait until there's at least one item
 sem_wait(&sitems);
 double v= values[--count];
 sem_post(&sremain);
 return v;
}

void push(double v) {
 // Wait until there's at least one space
 sem_wait(&sremain);
 values[count++] = v;
 sem_post(&sitems);
}

图 3 正确地使用信号量强制执行缓冲区满和空的条件。然而,没有互斥。两个线程可以同时处于关键部分,这会破坏数据结构或至少导致数据丢失。修复方法是围绕关键部分包裹一个互斥锁:

// Simple single stack - see the above example on how to convert this into multiple stacks.
// Also a robust POSIX implementation would check for EINTR and error codes of sem_wait.

// PTHREAD_MUTEX_INITIALIZER for statics (use pthread_mutex_init() for stack/heap memory)
#define SPACES 10
pthread_mutex_t m= PTHREAD_MUTEX_INITIALIZER;
int count = 0;
double values[SPACES];
sem_t sitems, sremain;

void init() {
 sem_init(&sitems, 0, 0);
 sem_init(&sremains, 0, SPACES); // 10 spaces
}

double pop() {
 // Wait until there's at least one item
 sem_wait(&sitems);

 pthread_mutex_lock(&m); // CRITICAL SECTION
 double v= values[--count];
 pthread_mutex_unlock(&m);

 sem_post(&sremain); // Hey world, there's at least one space
 return v;
}

void push(double v) {
 // Wait until there's at least one space
 sem_wait(&sremain);

 pthread_mutex_lock(&m); // CRITICAL SECTION
 values[count++] = v;
 pthread_mutex_unlock(&m);

 sem_post(&sitems); // Hey world, there's at least one item
}
// Note a robust solution will need to check sem_wait's result for EINTR (more about this later)

当我们开始反转锁和等待命令时会发生什么?

double pop() {
 pthread_mutex_lock(&m);
 sem_wait(&sitems);

 double v= values[--count];
 pthread_mutex_unlock(&m);

 sem_post(&sremain);
 return v;
}

void push(double v) {
 sem_wait(&sremain);

 pthread_mutex_lock(&m);
 values[count++] = v;
 pthread_mutex_unlock(&m);

 sem_post(&sitems);
}

我们不会直接给你答案,而是让你自己思考。这是否是一种允许的加锁和解锁方式?是否存在一系列可能导致竞态条件的操作?关于死锁呢?如果有,请提供。如果没有,请提供一个简短的证明,说明为什么不会发生。

关键部分的软件解决方案

如前所述,我们的代码中有一些部分只能由一个线程一次执行。我们将这个要求描述为“互斥”。只有一个线程(或进程)可以访问共享资源。在多线程程序中,我们可以用互斥锁和解锁调用封装临界区:

pthread_mutex_lock() // one thread allowed at a time! (others will have to wait here)
// ... Do Critical Section stuff here!
pthread_mutex_unlock() // let other waiting threads continue

我们将如何实现这些锁和解锁调用?我们能否创建一个纯软件算法来确保互斥?这是我们之前尝试的方法。

pthread_mutex_lock(p_mutex_t *m) {
 while(m->lock) ;
 m->lock = 1;
}
pthread_mutex_unlock(p_mutex_t *m) {
 m->lock = 0;
}

如我们之前提到的,即使考虑到线程可以解锁其他线程的锁,这个实现也不满足互斥。让我们仔细看看两个线程同时运行的“实现”。

为了简化讨论,我们只考虑两个线程。注意这些论点适用于线程和进程,经典的计算机科学文献用两个需要独占访问临界区或共享资源的进程来讨论这些问题。提升标志代表线程/进程进入临界区的意图。

我们希望解决方案具有三个主要期望的特性,以解决临界区问题。

  1. 互斥。线程/进程获得独占访问权限。其他线程必须等待它退出临界区。

  2. 有界等待。一个线程/进程不能被另一个线程无限期地取代。

  3. 进展。如果没有线程/进程在临界区内,线程/进程应该能够继续进行,而无需等待。

在这些想法的指导下,让我们检查另一个候选解决方案,该方案仅在两个线程同时需要访问时才使用基于轮询的标志。

天真的解决方案

记住下面概述的伪代码是更大程序的一部分。线程或进程在进程生命周期内通常需要多次进入临界区。所以,想象每个例子都被包含在一个循环中,在这个循环中,线程或进程会在随机的时间内做其他工作。

下面描述的候选解决方案有什么问题吗?

// Candidate #1
wait until your flag is lowered
raise my flag
// Do Critical Section stuff
lower my flag

答案:候选解决方案 #1 也存在竞态条件,因为两个线程/进程都可能读取对方的标志值已降低并继续。

这表明我们应该在检查其他线程的标志之前提升标志,如下面的候选解决方案 #2。

// Candidate #2
raise my flag
wait until your flag is lowered
// Do Critical Section stuff
lower my flag

候选解决方案 #2 满足互斥。两个线程同时进入临界区是不可能的。然而,这段代码存在死锁!假设两个线程同时希望进入临界区。

候选解决方案 #2 分析

时间 线程 1 线程 2
1 提升标志
2 提升标志
3 等待 等待

两个进程现在都在等待对方降低它们的标志。由于两者现在都永远卡住了,所以没有一个会进入临界区!这表明我们应该使用基于轮询的变量来尝试解决谁应该继续。

轮询解决方案

以下候选解决方案 #3 使用轮询变量礼貌地允许一个线程然后是另一个线程继续

// Candidate #3
wait until my turn is myid
// Do Critical Section stuff
turn = yourid

候选人 #3 满足互斥性。每个线程或进程都可以独占访问关键部分。然而,两个线程/进程必须采取严格的轮询方式来使用关键部分。它们被迫进入交替的关键部分访问模式。如果线程 1 每毫秒都希望读取哈希表,而另一个线程每秒写入哈希表,那么读取线程将不得不再等待 999 毫秒才能再次从哈希表中读取。这个“解决方案”是无效的,因为我们的线程应该能够在没有其他线程当前在关键部分中的情况下进行进展并进入关键部分。

轮询和标志解决方案

以下是否是 CSP 的正确解决方案?

\\ Candidate #4
raise my flag
if your flag is raised, wait until my turn
// Do Critical Section stuff
turn = yourid
lower my flag

分析这些解决方案很棘手。即使是关于这个特定主题的同行评审论文也包含错误的解决方案(Hyman 1966!)!乍一看,这似乎满足了互斥性、有限等待和进展。轮询标志仅在出现平局时使用,因此允许进展和有限等待,互斥性似乎得到了满足。也许你能找到一个反例?

候选人 #4 失败是因为一个线程没有等待另一个线程降低其标志。经过一些思考或灵感,可以创建以下场景来演示互斥性没有得到满足。

想象第一个线程运行此代码两次。此时轮询标志指向第二个线程。当第一个线程仍在关键部分内部时,第二个线程到达。第二个线程可以立即继续进入关键部分!

候选解决方案 #4

时间 轮询 线程 # 1 线程 # 2
1 2 提升我的标志
2 2 如果你的标志被提升,等待我的轮询 提升我的标志
3 2 // 执行关键部分内容 如果你的标志被提升,等待我的轮询(TRUE!)
4 2 // 执行关键部分内容 执行关键部分内容 - 哎呀

工作解决方案

该问题的第一个解决方案是 Dekker 的解决方案。Dekker 算法(1962)是第一个可证明正确的解决方案。尽管如此,它是在一份未发表的论文中,所以直到后来才被发现(Dekker 和 Dijkstra 1965)(这是 1965 年发布的英文转录版本)。算法的一个版本如下。

raise my flag
while (your flag is raised) :
   if it is your turn to win :
     lower my flag
     wait while your turn
     raise my flag
// Do Critical Section stuff
set your turn to win
lower my flag

注意,在关键部分中,无论循环迭代零次、一次还是多次,进程的标志总是被提升。此外,标志可以解释为立即进入关键部分的意图。只有当另一个进程也提升了标志时,一个进程才会推迟,降低其意图标志并等待。让我们检查一下条件。

  1. 互斥。让我们尝试绘制一个简单的证明。循环不变量是在检查条件开始时,你的标志必须被提升——这是通过穷举实现的。由于线程离开循环的唯一方式是条件为假,因此标志必须在关键部分的全过程中被提升。由于循环阻止线程在另一个线程的标志提升时退出,并且在关键部分有一个线程提升了标志,因此另一个线程不能同时进入关键部分。

  2. 有限等待。假设关键部分在有限时间内结束,一旦一个线程离开了关键部分,它就不能再获得关键部分。原因是转换变量被设置为另一个线程,这意味着那个线程现在有优先权。这意味着一个线程不能被另一个线程无限期地取代。

  3. 进度。如果其他线程不在关键部分,它将简单地继续进行简单的检查。我们没有对系统调度器随机停止线程做出任何声明。这是一个理想化的场景,其中线程将不断执行指令。

Peterson 的解决方案

Peterson 在 1981 年(Peterson 1981)发表了其新颖且简单的解决方案。下面展示了他算法的一个版本,该版本使用了一个共享变量。

// Candidate #5
raise my flag
turn = other_thread_id
while (your flag is up and turn is other_thread_id)
    loop
// Do Critical Section stuff
lower my flag

这个解决方案满足互斥、有限等待和进度。如果线程#2 已经将转换设置为 2 并且目前处于关键部分。线程#1 到达,将转换重新设置为 1,然后等待线程 2 降低标志。

  1. 互斥。让我们再次尝试绘制一个简单的证明。一个线程只有在转换变量是你的或者另一个线程的标志没有提升之前才能进入关键部分。如果另一个线程的标志没有提升,它不是试图进入关键部分。这是线程执行的第一步,也是线程撤销的最后一步。如果转换变量被设置为这个线程,这意味着另一个线程已经将控制权交给了这个线程。由于我的标志被提升并且转换变量被设置,另一个线程必须等待在循环中,直到当前线程完成。

  2. 有限等待。在降低标志后,在 while 循环中等待的线程将离开,因为第一个条件被破坏。这意味着线程不能总是获胜。

  3. 进度。如果没有其他线程竞争,其他线程的标志就不会被提升。这意味着一个线程可以越过 while 循环并执行关键部分的项目。

不幸的是,由于指令乱序,我们今天不能以同样的方式实现软件互斥锁。请查看附录以获取该问题的解决方案。

实现计数信号量

现在我们已经解决了临界区问题,可以合理地实现互斥锁。我们将如何实现其他同步原语?让我们从信号量开始。为了实现一个具有高效 CPU 使用的信号量,我们将说我们已经实现了一个条件变量。仅使用互斥锁实现 O(1)空间条件变量并不简单,或者至少 O(1)堆空间条件变量并不简单。我们不想在实现原语时调用 malloc,否则我们可能会死锁!

  • 我们可以使用条件变量来实现计数信号量。

  • 每个信号量都需要一个计数、一个条件变量和一个互斥锁。

    typedef struct sem_t {
     ssize_t count;
     pthread_mutex_t m;
     pthread_condition_t cv;
    } sem_t;
    

实现初始化互斥锁和条件变量。

int sem_init(sem_t *s, int pshared, int value) {
 if (pshared) {
 errno = ENOSYS /* 'Not implemented'*/;
 return -1;
 }

 s->count = value;
 pthread_mutex_init(&s->m, NULL);
 pthread_cond_init(&s->cv, NULL);
 return 0;
}

我们的实现需要在计数上增加。我们还将唤醒在条件变量内休眠的所有线程。注意,我们锁定和解锁互斥锁,以确保一次只有一个线程在临界区内部。

void sem_post(sem_t *s) {
 pthread_mutex_lock(&s->m);
 s->count++;
 pthread_cond_signal(&s->cv);
 /* A woken thread must acquire the lock, so it will also have to wait until we call unlock*/

 pthread_mutex_unlock(&s->m);
}

我们的实现可能需要在信号量的计数为零时休眠。就像那样,我们使用锁来封装临界区,这样一次只有一个线程可以执行我们的代码。注意,如果线程需要等待,互斥锁将被解锁,允许另一个线程进入并唤醒我们。

还要注意,即使线程在从sem_post返回之前被唤醒,它也必须重新获取锁,因此它将不得不等待sem_post完成。

void sem_wait(sem_t *s) {
 pthread_mutex_lock(&s->m);
 while (s->count == 0) {
 pthread_cond_wait(&s->cv, &s->m); /*unlock mutex, wait, relock mutex*/
 }
 s->count--;
 pthread_mutex_unlock(&s->m);
}

这就是一个计数信号量的完整实现。请注意,我们每次都会调用它。在实践中,这意味着即使没有等待的线程,也会进行不必要的调用。一个更高效的实现只会在有需要时调用,即。

/* Did we increment from zero to one- time to signal a thread sleeping inside sem_post */
if (s->count == 1) /* Wake up one waiting thread!*/
pthread_cond_signal(&s->cv);

其他信号量考虑事项

  • 生产的信号量实现可能包括一个队列以确保公平性和优先级。这意味着我们唤醒最高优先级和/或最长休眠的线程。

  • 高级使用允许信号量在进程间共享。我们的实现仅适用于同一进程内的线程。我们可以通过设置条件变量和互斥锁属性来修复这个问题。

实现条件变量与互斥锁的复杂度较高,所以我们将其留在了附录中。

屏障

假设我们想要执行一个有两个阶段的并发计算,但我们不想在第一阶段完成之前进入第二阶段。我们可以使用一种称为屏障的同步方法。当一个线程到达屏障时,它将在屏障处等待,直到所有线程都到达屏障,然后他们一起继续前进。

想象一下,和朋友们一起去远足。你心里记着有多少个朋友,并约定在每座山的山顶等待彼此。比如说,你是第一个到达第一座山顶的人。你会在山顶等待你的朋友们。一个接一个,他们会到达山顶,但没有人会继续前进,直到你们小组的最后一个人到达。一旦他们到达,你们所有人就会继续前进。

Pthreads 有一个实现此功能的函数。您需要声明一个变量并将其初始化为。它接受将参与屏障的线程数量作为参数。以下是一个使用屏障的示例程序。

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>

#define THREAD_COUNT 4

pthread_barrier_t mybarrier;

void* threadFn(void *id_ptr) {
 int thread_id = *(int*)id_ptr;
 int wait_sec = 1 + rand() % 5;
 printf("thread %d: Wait for %d seconds.\n", thread_id, wait_sec);
 sleep(wait_sec);
 printf("thread %d: I'm ready...\n", thread_id);

 pthread_barrier_wait(&mybarrier);

 printf("thread %d: going!\n", thread_id);
 return NULL;
}

int main() {
 int i;
 pthread_t ids[THREAD_COUNT];
 int short_ids[THREAD_COUNT];

 srand(time(NULL));
 pthread_barrier_init(&mybarrier, NULL, THREAD_COUNT + 1);

 for (i=0; i < THREAD_COUNT; i++) {
 short_ids[i] = i;
 pthread_create(&ids[i], NULL, threadFn, &short_ids[i]);
 }

 printf("main() is ready.\n");

 pthread_barrier_wait(&mybarrier);

 printf("main() is going!\n");

 for (i=0; i < THREAD_COUNT; i++) {
 pthread_join(ids[i], NULL);
 }

 pthread_barrier_destroy(&mybarrier);

 return 0;
}

现在,让我们实现我们自己的屏障,并使用它来在大型计算中使所有线程保持同步。以下是我们的思考过程,

  1. 线程进行第一次计算(使用并更改数据中的值)

  2. 障碍!在继续之前,等待所有线程完成第一次计算

  3. 线程进行第二次计算(使用并更改数据中的值)

线程函数有四个主要部分-

// double data[256][8192]

void *calc(void *arg) {
 /* Do my part of the first calculation */
 /* Is this the last thread to finish? If so wake up all the other threads! */
 /* Otherwise wait until the other threads have finished part one */
 /* Do my part of the second calculation */
}

我们的主线程将创建 16 个线程,我们将每个计算分成 16 个单独的部分。每个线程将被分配一个唯一的值(0,1,2,..15),这样它就可以在自己的块上工作。由于(void*)类型可以容纳小的整数,我们将通过将其转换为 void 指针来传递的值。

#define N (16)
double data[256][8192] ;
int main() {
 pthread_t ids[N];
 for(int i = 0; i < N; i++) {
 pthread_create(&ids[i], NULL, calc, (void *) i);
 }
 //...
}

注意,我们永远不会将此指针值作为实际内存位置解引用。

我们将直接将其转换回整数。

void *calc(void *ptr) {
 // Thread 0 will work on rows 0..15, thread 1 on rows 16..31
 int x, y, start = N * (int) ptr;
 int end = start + N;
 for(x = start; x < end; x++) {
 for (y = 0; y < 8192; y++) {
 /* do calc #1 */
 }
 }
}

第一次计算完成后,我们需要等待较慢的线程,除非我们是最后一个线程!所以,跟踪到达我们障碍“检查点”的线程数量。

// Global:
int remain = N;

// After calc #1 code:
remain--; // We finished
if (remain == 0) {/*I'm last!  -  Time for everyone to wake up! */ }
else {
 while (remain != 0) { /* spin spin spin*/ }
}

然而,代码有几个缺陷。一个是两个线程可能会尝试递减。另一个是循环是一个忙等待循环。我们可以做得更好!让我们使用一个条件变量,然后我们将使用广播/信号函数来唤醒睡眠的线程。

提醒一下,条件变量类似于一所房子!线程去那里睡觉()。一个线程可以选择唤醒一个线程()或所有线程()。如果没有线程当前在等待,则这两个调用没有效果。

条件变量版本通常类似于忙等待错误的解决方案——正如我们将展示的。首先,让我们添加互斥锁和条件全局变量,并不要忘记在中初始化它们。

//global variables
pthread_mutex_t m;
pthread_cond_t cv;

int main() {
 pthread_mutex_init(&m, NULL);
 pthread_cond_init(&cv, NULL);

我们将使用互斥锁来确保一次只有一个线程修改。最后一个到达的线程需要唤醒所有睡眠的线程——所以我们将使用 not

pthread_mutex_lock(&m);
remain--;
if (remain == 0) {
 pthread_cond_broadcast(&cv);
}
else {
 while(remain != 0) {
 pthread_cond_wait(&cv, &m);
 }
}
pthread_mutex_unlock(&m);

当一个线程进入时,它释放互斥锁并睡眠。之后,线程将被唤醒。一旦我们将一个线程从睡眠中唤醒,在返回之前,它必须等待直到它可以锁定互斥锁。注意,即使一个睡眠的线程提前醒来,它也会检查 while 循环条件,并在必要时重新进入等待状态。

上述屏障不可重用。这意味着如果我们将其放入任何旧的计算循环中,代码有很大可能会遇到屏障要么死锁,要么线程比迭代快一步的情况。为什么会这样?因为有一个雄心勃勃的线程。

我们将假设有一个线程比所有其他线程都要快。使用屏障 API,这个线程应该正在等待,但它可能不是。为了使其具体化,让我们看看这段代码

void barrier_wait(barrier *b) {
 pthread_mutex_lock(&b->m);
 // If it is 0 before decrement, we should be on
 // another iteration right?
 if (b->remain == 0) b->remain = NUM_THREADS;
 b->remain--;
 if (b->remain == 0) {
 pthread_cond_broadcast(&cv);
 }
 else {
 while(b->remain != 0) {
 pthread_cond_wait(&cv, &m);
 }
 }
 pthread_mutex_unlock(&b->m);
}

for (/* ... */) {
 // Some calc
 barrier_wait(b);
}

如果一个线程变得雄心勃勃会发生什么。嗯

  1. 许多其他线程都在等待条件变量

  2. 最后一个线程进行广播。

  3. 一个单独的线程离开了 while 循环。

  4. 这个单独的线程在其计算开始之前,其他任何线程甚至都没有醒来

  5. 重置剩余线程的数量并返回睡眠状态。

所有其他本应醒来的线程都没有醒来,我们的实现陷入死锁。你将如何解决这个问题?提示:如果多个线程在循环中调用,则可以保证它们处于相同的迭代。

读者-写者问题

想象你有一个键值映射数据结构,它被许多线程使用。只要数据结构没有被写入,多个线程应该能够同时查找(读取)值。写者并不那么社交。为了避免数据损坏,一次只能有一个线程修改()数据结构,并且在此期间不允许任何读者读取。

这是一个读者-写者问题的例子。也就是说,我们如何有效地同步多个读者和写者,使得多个读者可以同时读取,但写者获得独占访问权限?

下面显示了不正确的尝试(“lock”是的缩写):

尝试#1

void read() {
 lock(&m)
 // do read stuff
 unlock(&m)
}

void write() {
 lock(&m)
 // do write stuff
 unlock(&m)
}

至少我们的第一次尝试没有数据损坏的问题。读者必须在写者写入时等待,反之亦然!然而,读者也必须等待其他读者。让我们尝试另一种实现。

尝试#2:

void read() {
 while(writing) {/*spin*/}
 reading = 1
 // do read stuff
 reading = 0
}

void write() {
 while(reading || writing) {/*spin*/}
 writing = 1
 // do write stuff
 writing = 0
}

我们的第二次尝试存在竞态条件。想象一下,如果有两个线程同时调用或或同时调用 write,那么两个线程都能够继续执行!其次,我们可以有多个读者和多个写者,所以让我们跟踪读者或写者的总数,这把我们带到了尝试#3。

尝试#3

记住,执行三个动作。首先,它原子性地解锁互斥锁然后睡眠(直到被或唤醒)。第三,唤醒的线程在返回之前必须重新获取互斥锁。因此,只有一个线程实际上可以在由锁和解锁()方法定义的关键部分中运行。

以下第 3 种实现确保读者在有任何写者正在写入时进入。

read() {
 lock(&m)
 while (writing)
 cond_wait(&cv, &m)
 reading++;

 /* Read here! */

 reading--
 cond_signal(&cv)
 unlock(&m)
}

然而,一次只有一个读者可以读取,因为候选方案#3 没有解锁互斥锁。一个更好的版本在读取之前解锁。

read() {
 lock(&m);
 while (writing)
 cond_wait(&cv, &m)
 reading++;
 unlock(&m)

 /* Read here! */

 lock(&m)
 reading--
 cond_signal(&cv)
 unlock(&m)
}

这是否意味着写者和读可以同时读取和写入?不!首先,记住 cond_wait 需要在返回之前线程重新获取互斥锁。因此,一次只有一个线程可以在标记为**的关键部分中执行代码!

read() {
 lock(&m);
 **  while (writing)
 **      cond_wait(&cv, &m)
 **  reading++;
 unlock(&m)
 /* Read here! */
 lock(&m)
 **  reading--
 **  cond_signal(&cv)
 unlock(&m)
}

写者必须等待所有人。互斥由锁保证。

write() {
 lock(&m);
 **  while (reading || writing)
 **      cond_wait(&cv, &m);
 **  writing++;
 **
 ** /* Write here! */
 **  writing--;
 **  cond_signal(&cv);
 unlock(&m);
}

上面的候选方案#3 也使用。这只会唤醒一个线程。如果有许多读者正在等待写者完成,只有一名沉睡的读者会被唤醒。读者和写者应该使用,以便所有线程都应该醒来并检查它们的 while 循环条件。

饥饿的写者

上面的候选者#3 存在饥饿问题。如果读者不断到达,那么写者将永远无法进行(“reading”计数器永远不会减少到零)。这被称为饥饿,在重负载下会被发现。我们的解决方案是实现写者的有界等待。如果写者到达,他们仍然需要等待现有的读者,但是未来的读者必须被放置在“拘留所”并等待写者完成。这个“拘留所”可以通过使用变量和条件变量来实现,这样我们就可以在写者完成后唤醒线程。

计划是在写者到达时,在等待当前读者完成之前,通过增加计数器‘writer’来注册我们的写意。

write() {
 lock()
 writer++

 while (reading || writing)
 cond_wait
 unlock()
 ...
}

当写者不为零时,不允许新来的读者继续。注意‘writer’表示有写者到达,而‘reading’和‘writing’计数器表示存在一个活跃的读者或写者。

read() {
 lock()
 // readers that arrive *after* the writer arrived will have to wait here!
 while(writer)
 cond_wait(&cv,&m)

 // readers that arrive while there is an active writer
 // will also wait.
 while (writing)
 cond_wait(&cv,&m)
 reading++
 unlock
 ...
}

尝试#4

下面是我们对读者-写者问题的第一个工作解决方案。注意,如果你继续阅读关于“读者-写者问题”的内容,你会发现我们通过给予写者优先访问锁的方式解决了“第二个读者-写者问题”。这个解决方案不是最优的。然而,它满足了我们的原始问题,即 N 个活跃的读者,一个活跃的写者,并且在有持续的读者流时避免写者的饥饿。

你能识别出任何改进的地方吗?例如,你会如何改进代码,以便我们只唤醒读者或一个写者?

int writers; // Number writer threads that want to enter the critical section (some or all of these may be blocked)
int writing; // Number of threads that are actually writing inside the C.S. (can only be zero or one)
int reading; // Number of threads that are actually reading inside the C.S.
// if writing !=0 then reading must be zero (and vice versa)

reader() {
 lock(&m)
 while (writers)
 cond_wait(&turn, &m)
 // No need to wait while(writing here) because we can only exit the above loop
 // when writing is zero
 reading++
 unlock(&m)

 // perform reading here

 lock(&m)
 reading--
 cond_broadcast(&turn)
 unlock(&m)
}

writer() {
 lock(&m)
 writers++
 while (reading || writing)
 cond_wait(&turn, &m)
 writing++
 unlock(&m)
 // perform writing here
 lock(&m)
 writing--
 writers--
 cond_broadcast(&turn)
 unlock(&m)
}

环形缓冲区

环形缓冲区是一种简单、通常固定大小的存储机制,其中连续的内存被当作环形处理,并且两个索引计数器跟踪队列的当前开始和结束位置。由于数组索引不是环形的,当索引计数器移动到数组末尾之后时,它们必须回绕到零。当数据被添加(入队)到队列的前端或从队列的尾部移除(出队)时,缓冲区中的当前项目形成一列火车,看起来像是在轨道上环形运行。

环形缓冲区可视化

环形缓冲区可视化

下面是一个简单的(单线程)实现示例。注意,入队和出队方法没有防止下溢或上溢。当队列已满时,仍然可以添加项目,当队列为空时,仍然可以移除项目。如果我们向队列中添加 20 个整数(1,2,3,…,20)并且没有出队任何项目,那么值将会覆盖。我们现在不会修复这个问题,而是在创建多线程版本时,我们将确保在环形缓冲区满或空时,入队和出队线程被阻塞。

void *buffer[16];
unsigned int in = 0, out = 0;

void enqueue(void *value) { /* Add one item to the front of the queue*/
 buffer[in] = value;
 in++; /* Advance the index for next time */
 if (in == 16) in = 0; /* Wrap around! */
}

void *dequeue() { /* Remove one item to the end of the queue.*/
 void *result = buffer[out];
 out++;
 if (out == 16) out = 0;
 return result;
}

环形缓冲区常见问题

很容易写出以下紧凑形式的入队或出队方法。

// N is the capacity of the buffer
void enqueue(void *value)
b[ (in++) % N ] = value;
}

这种方法看起来似乎可以工作,但包含一个微小的错误。在超过四十亿次 enqueue 操作之后,int 值的将溢出并回绕到 0!因此,你可能会最终写入例如!

紧凑形式是正确的,如果 N 是 2 的幂,则使用位掩码。(16,32,64,…)

b[ (in++) & (N-1) ] = value;

此缓冲区尚未防止覆盖。为此,我们将转向我们的多线程尝试,这将阻塞线程,直到有空间或至少有一个项目要删除。

多线程正确性

以下代码是一个不正确的实现。会发生什么?会阻塞吗?互斥性是否得到满足?缓冲区可以下溢吗?缓冲区可以溢出吗?为了清晰起见,我们将其简称为,并假设 sem_wait 不能被中断。

#define N 16
void *b[N]
int in = 0, out = 0
p_m_t lock
sem_t s1,s2
void init() {
 p_m_init(&lock, NULL)
 sem_init(&s1, 0, 16)
 sem_init(&s2, 0, 0)
}

enqueue(void *value) {
 p_m_lock(&lock)

 // Hint: Wait while zero. Decrement and return
 sem_wait( &s1 )

 b[ (in++) & (N-1) ] = value

 // Hint: Increment. Will wake up a waiting thread
 sem_post(&s1)
 p_m_unlock(&lock)
}
void *dequeue(){
 p_m_lock(&lock)
 sem_wait(&s2)
 void *result = b[(out++) & (N-1) ]
 sem_post(&s2)
 p_m_unlock(&lock)
 return result
}

分析

在继续阅读之前,看看你能找到多少错误。然后确定如果线程调用 enqueue 和 dequeue 方法会发生什么。

  • enqueue 方法在同一个信号量(s1)上等待并发布,同样,enqueue 和(s2)也是如此,即我们递减值然后立即递增值,所以在函数结束时信号量值不变!

  • s1 的初始值为 16,因此信号量永远不会减少到零 - 如果环形缓冲区已满,enqueue 将不会阻塞,因此可能发生溢出。

  • s2 的初始值为零,因此对 dequeue 的调用将始终阻塞且永远不会返回!

  • 互斥锁和 sem_wait 的顺序需要交换;然而,这个例子如此糟糕,以至于这个错误没有任何影响!

另一种分析

以下代码是一个不正确的实现。会发生什么?会阻塞吗?互斥性是否得到满足?缓冲区可以下溢吗?缓冲区可以溢出吗?为了清晰起见,我们将其简称为,并假设 sem_wait 不能被中断。

void *b[16]
int in = 0, out = 0
p_m_t lock
sem_t s1, s2
void init() {
 sem_init(&s1,0,16)
 sem_init(&s2,0,0)
}

enqueue(void *value){
 sem_wait(&s2)
 p_m_lock(&lock)

 b[ (in++) & (N-1) ] = value

 p_m_unlock(&lock)
 sem_post(&s1)
}

void *dequeue(){
 sem_wait(&s1)
 p_m_lock(&lock)
 void *result = b[(out++) & (N-1)]
 p_m_unlock(&lock)
 sem_post(&s2)

 return result;
}

下面是一些我们希望您已经发现的几个问题。

  • s2 的初始值为 0。因此,enqueue 在第一次调用 sem_wait 时将阻塞,即使缓冲区为空!

  • s1 的初始值为 16。因此,dequeue 在第一次调用 sem_wait 时不会阻塞,即使缓冲区为空 - 下溢!dequeue 方法将返回无效数据。

  • 代码不满足互斥性。两个线程可以同时修改或!代码似乎使用了互斥锁。不幸的是,锁从未用或初始化,因此锁可能不起作用(可能只是什么也不做)

环形缓冲区的正确实现

由于互斥锁存储在全局(静态)内存中,因此可以使用 . 初始化。如果我们已在堆上为互斥锁分配了空间,那么我们将使用

#include <pthread.h>
#include <semaphore.h>
// N must be 2^i
#define N (16)

void *b[N]
int in = 0, out = 0
p_m_t lock = PTHREAD_MUTEX_INITIALIZER
sem_t countsem, spacesem

void init() {
 sem_init(&countsem, 0, 0)
 sem_init(&spacesem, 0, 16)
}

enqueue 方法如下所示。请确保注意。

  1. 锁仅在临界区(对数据结构的访问)期间保持。

  2. 一个完整的实现需要防止由于 POSIX 信号而导致的从 early returns 的早期返回。

enqueue(void *value){
 // wait if there is no space left:
 sem_wait( &spacesem )

 p_m_lock(&lock)
 b[ (in++) & (N-1) ] = value
 p_m_unlock(&lock)

 // increment the count of the number of items
 sem_post(&countsem)
}

实现如下所示。注意对 . 的同步调用的对称性。在两种情况下,函数首先等待空间计数或项目计数为零。

void *dequeue(){
 // Wait if there are no items in the buffer
 sem_wait(&countsem)

 p_m_lock(&lock)
 void *result = b[(out++) & (N-1)]
 p_m_unlock(&lock)

 // Increment the count of the number of spaces
 sem_post(&spacesem)

 return result
}

思考食物:

  • 如果and调用顺序被交换会发生什么?

  • 如果and调用顺序被交换会发生什么?

额外:进程同步

你以为你在使用不同的进程,所以不需要同步?再想想!你可能在一个进程内没有竞态条件,但如果你需要与周围的系统交互呢?让我们考虑一个激励性的例子

void write_string(const char *data) {
 int fd = open("my_file.txt", O_WRONLY);
 write(fd, data, strlen(data));
 close(fd);
}

int main() {
 if(!fork()) {
 write_string("key1: value1");
 wait(NULL);
 } else {
 write_string("key2: value2");
 }
 return 0;
}

如果没有系统调用失败,那么我们应该得到类似这样的结果,因为文件一开始是空的。

key1: value1
key2: value2
key2: value2
key1: value1

中断

但是,有一个隐藏的细微差别。大多数系统调用可以被中断,这意味着操作系统可以停止一个正在进行的系统调用,因为它需要停止进程。所以,除了和之外,它们通常都会完成——如果写入失败并且没有写入字节,我们可能会得到类似或的结果。这是数据丢失,这是不正确的,但不会损坏文件。如果写入在部分写入后中断会发生什么?我们会得到各种各样的混乱。例如,

key2: key1: value1

解决方案

一个程序可以在fork之前创建一个互斥锁——然而子进程和父进程不会共享虚拟内存,每个进程都将有一个独立的互斥锁。高级提示:使用共享内存的高级选项允许子进程和父进程在以正确选项创建并使用共享内存段的情况下共享一个互斥锁。参见stackoverflow 示例

那我们应该怎么做?我们应该使用共享互斥锁!考虑以下代码。

pthread_mutex_t * mutex = NULL;
pthread_mutexattr_t attr;

void write_string(const char *data) {
 pthread_mutex_lock(mutex);
 int fd = open("my_file.txt", O_WRONLY);
 int bytes_to_write = strlen(data), written = 0;
 while(written < bytes_to_write) {
 written += write(fd, data + written, bytes_to_write - written);
 }
 close(fd);
 pthread_mutex_unlock(mutex);
}

int main() {
 pthread_mutexattr_init(&attr);
 pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
 pmutex = mmap (NULL, sizeof(pthread_mutex_t),
 PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANON, -1, 0);
 pthread_mutex_init(pmutex, &attrmutex);
 if(!fork()) {
 write_string("key1: value1");
 wait(NULL);
 pthread_mutex_destroy(pmutex);
 pthread_mutexattr_destroy(&attrmutex);
 munmap((void *)pmutex, sizeof(*pmutex));
 } else {
 write_string("key2: value2");
 }
 return 0;
}

代码在main函数中做的事情是使用一块内存初始化一个进程共享互斥锁。你将在稍后了解到这个调用做了什么——暂时假设它创建的是进程间共享的内存。我们可以在那块特殊的内存中初始化一个互斥锁,并像正常使用一样。为了防止失败,我们将调用放在一个 while 循环中,只要还有字节要写就持续写入。现在如果所有其他系统调用都正常工作,应该会有更多的竞态条件。

大多数程序试图完全避免这个问题,通过写入不同的文件,但了解跨进程的互斥锁是好的,它们是有用的。程序可以使用之前提到的所有原语!屏障、信号量和条件变量都可以在共享内存块上初始化,并以类似的方式用于它们的线程对应物。

  • 你不必担心任意内存地址成为竞态条件的候选者。只有那些特别映射的区域才处于危险之中。

  • 你可以得到进程的隔离性,如果一个进程失败,系统可以保持完整。

  • 当你有大量线程时,创建一个进程可能会减轻系统负载

还有其他同步方式,查看附录中的 goroutines 或更高阶的同步。

外部资源

手册页的指导问题

主题

  • 原子操作

  • 临界区

  • 生产者消费者问题

  • 使用条件变量

  • 使用计数信号量

  • 实现屏障

  • 实现环形缓冲区

  • 使用 pthread_mutex

  • 实现生产者消费者

  • 分析多线程代码

问题

  • 什么是原子操作?

  • 为什么以下代码在并行代码中不会工作?

    //In the global section
    size_t a;
    //In pthread function
    for(int i = 0; i < 100000000; i++) a++;
    

    这会怎样?

    //In the global section
    atomic_size_t a;
    //In pthread function
    for(int i = 0; i < 100000000; i++) atomic_fetch_add(a, 1);
    
  • 原子操作有哪些缺点?保持局部变量和许多原子操作哪个更快?

  • 什么是临界区?

  • 一旦你确定了临界区,确保一次只有一个线程在该区域的一种方法是什么?

  • 在这里识别临界区

    struct linked_list;
    struct node;
    void add_linked_list(linked_list *ll, void* elem){
     node* packaged = new_node(elem);
     if(ll->head){
     ll->head =
     }else{
     packaged->next = ll->head;
     ll->head = packaged;
     ll->size++;
     }
    }
    
    void* pop_elem(linked_list *ll, size_t index){
     if(index >= ll->size) return NULL;
    
     node *i, *prev;
     for(i = ll->head; i && index; i = i->next, index--){
     prev = i;
     }
    
     //i points to the element we need to pop, prev before
     if(prev->next) prev->next = prev->next->next;
     ll->size--;
     void* elem = i->elem;
     destroy_node(i);
     return elem;
    }
    
  • 你能将临界区做得有多紧密?

  • 什么是生产者消费者问题?上述内容中的生产者消费者问题如何使用在上述部分?生产者消费者问题与读者写者问题有何关联?

  • 什么是条件变量?为什么使用它比使用循环有优势?

  • 为什么这段代码是危险的?

    if(not_ready){
     pthread_cond_wait(&cv, &mtx);
    }
    
  • 什么是计数信号量?请给我一个类似饼干罐/披萨盒/有限食品的类比。

  • 什么是线程屏障?

  • 使用计数信号量实现屏障。

  • 编写一个生产者/消费者队列,生产者消费者栈如何?

  • 请给出一个使用条件变量的读者-写者锁的实现,创建一个包含所需内容的结构体,它需要能够支持以下函数

    typedef struct {
    
    } rw_lock_t;
    
    void reader_lock(rw_lock_t* lck) {
    
    }
    
    void writer_lock(rw_lock_t* lck) {
    
    }
    
    void reader_unlock(rw_lock_t* lck) {
    
    }
    
    void writer_unlock(rw_lock_t* lck) {
    
    }
    

    唯一的规范是在两个逗号之间和之间,没有写者可以写入。在写者锁定之间,一次只能有一个写者写入。

  • 编写代码以实现一个仅使用三个计数信号量的生产者消费者模型。假设可能有多个线程调用入队和出队操作。确定每个信号量的初始值。

  • 编写代码以使用条件变量和互斥锁实现生产者消费者模型。假设可能有多个线程调用入队和出队操作。

  • 使用 CVs 实现添加(unsigned int)和减去(unsigned int)阻塞函数,这些函数永远不会允许全局值大于 100。

  • 使用 CVs 为 15 个线程实现一个屏障。

  • 以下代码做了什么?

    void main() {
     pthread_mutex_t mutex;
     pthread_cond_t cond;
    
     pthread_mutex_init(&mutex, NULL);
     pthread_cond_init(&cond, NULL);
    
     pthread_cond_broadcast(&cond);
     pthread_cond_wait(&cond,&mutex);
    
     return 0;
    }
    
  • 以下代码是否正确?如果不正确,你能修复它吗?

    extern int money;
    void deposit(int amount) {
     pthread_mutex_lock(&m);
     money += amount;
     pthread_mutex_unlock(&m);
    }
    
    void withdraw(int amount) {
     if (money < amount) {
     pthread_cond_wait(&cv);
     }
    
     pthread_mutex_lock(&m);
     money -= amount;
     pthread_mutex_unlock(&m);
    }
    
  • 绘制如何使用二进制信号量作为互斥锁的示例。记住,除了互斥之外,互斥锁只能由调用它的线程解锁。

    sem_t sem;
    
    void lock() {
    
    }
    
    void unlock() {
    
    }
    
  • 以下哪些陈述是正确的?

    • 可以有多个活跃的读者

    • 可以有多个活跃的作者

    • 当有活跃的作者时,活跃的读者数量必须为零

    • 如果有活跃的读者,活跃的作者数量必须为零

    • 作者必须等待当前活跃的读者完成

死锁

死锁被定义为系统无法向前进展的情况。在本章的其余部分,我们将系统定义为一系列规则,这些规则允许一组进程从一个状态移动到另一个状态,其中状态要么是正在工作,要么是在等待特定的资源。向前进展被定义为至少有一个进程正在工作,或者我们可以授予一个等待特定资源的进程该资源。在许多系统中,通过忽略整个概念来避免死锁(Silberschatz, Galvin, 和 Gagne 2006,第 237 页)。你听说过“打开再关闭”吗?对于风险较低的产物(用户操作系统、手机),允许死锁可能更有效率。但在“失败不是选项”的情况下——阿波罗 13 号,你需要一个能够跟踪、打破或预防死锁的系统。阿波罗 13 号不是因为死锁而失败,但在起飞时重启系统并不是一个好的选择。

关键任务操作系统需要正式保证,因为拿人们的生命打赌不是一个好主意。那么我们如何做到这一点呢?我们建模这个问题。尽管有一个常见的统计术语说所有模型都是错误的,但模型越接近系统,方法成功的可能性就越高。

资源分配图

资源分配图

资源分配图

有一种方法是通过资源分配图(RAG)来模拟系统。资源分配图跟踪哪个进程持有哪个资源,以及哪个进程正在等待特定类型的资源。这是一个简单而强大的工具,可以说明交互进程如何导致死锁。如果一个进程正在使用资源,则从资源节点到进程节点画一条箭头。如果一个进程正在请求资源,则从进程节点到资源节点画一条箭头。如果在资源分配图中存在一个循环,并且循环中的每个资源只提供一个实例,那么进程将会死锁。例如,如果进程 1 持有资源 A,进程 2 持有资源 B,进程 1 正在等待 B,而进程 2 正在等待 A,那么进程 1 和进程 2 将会死锁 8.1。我们将定义系统处于死锁状态,如果所有工作进程都不能执行除了等待之外的操作。我们可以通过遍历图并使用图遍历算法(如深度优先搜索 DFS)来寻找循环来检测死锁。这个图被视为有向图,我们可以将进程和资源都视为节点。

 typedef struct {
 int node_id; // Node in this particular graph
 Graph **reachable_nodes; // List of nodes that can be reached from this node
 int size_reachable_nodes; // Size of the List
 } Graph;

 // isCyclic() traverses a graph using DFS and detects whether it has a cycle
 // isCyclic() uses a recursive approach
 // G points to a node in a graph, which can be either a resource or a process
 // is_visited is an array indexed with node_id and initialized with zeros (false) to record whether a particular node has been visited
 int isCyclic(Graph *G, int* is_visited) {
 if (this graph has been visited) {
 // Oh! the cycle is found
 return true;
 } else {
 1. Mark this node as visited
 2. Traverse through all nodes in the reachable_nodes
 3. Call isCyclic() for each node
 4. Evaluate the return value of isCyclic()
 }
 // Nope, this graph is acyclic
 return false;
 }

基于图的死锁

基于图的死锁

科夫曼条件

当然,在操作系统(OS)中,资源分配图(RAG)中的循环总是会发生,那么为什么系统不会停止呢?你可能看不到死锁,因为操作系统可能会抢占一些进程来打破循环,但你的三个孤独进程仍然有可能死锁。

死锁有四个必要充分的条件——这意味着如果这些条件成立,那么在任意迭代中系统发生死锁的概率非零。这些被称为科夫曼条件(Coffman, Elphick, 和 Shoshani 1971)。

  • 互斥:没有两个进程可以同时获得资源。

  • 循环等待:资源分配图中存在一个循环,或者存在一个进程集合 {P1, P2,…},其中 P1 正在等待 P2 持有的资源,而 P2 正在等待 P3,…,P3 正在等待 P1。

  • 持有并等待:一旦获得资源,进程就会保持资源锁定。

  • 无优先权:没有任何东西可以迫使进程放弃资源。

证明:

死锁只有在四个科夫曼条件都满足的情况下才会发生。

\rightarrow 如果系统死锁,四个科夫曼条件就会明显出现。

  • 为了进行矛盾证明,假设不存在循环等待。如果不成立,那么意味着资源分配图是无环的,这意味着至少有一个进程没有等待任何资源被释放。由于系统可以继续运行,系统不是死锁的。

  • 为了进行矛盾证明,假设不存在互斥。如果不成立,那么意味着没有进程等待其他进程的资源。这打破了循环等待,之前的论证证明了正确性。

  • 为了进行矛盾证明,假设进程不持有并等待,但我们的系统仍然死锁。由于我们根据第一个条件存在循环等待,至少有一个进程必须等待另一个进程。如果那样,并且进程不持有并等待,那么意味着一个进程必须释放一个资源。由于系统已经向前推进,它不可能死锁。

  • 为了进行矛盾证明,假设我们存在优先权,但系统无法解除死锁。有一个进程,或者创建一个进程,能够识别出从上面必须明显可见的循环等待,并断开其中一个链接。根据第一个分支,我们不应该有死锁。

\leftarrow 如果四个条件都明显存在,则系统处于死锁状态。我们将证明如果系统没有死锁,那么四个条件就不会明显存在。尽管这个证明不是正式的,但让我们构建一个不包含循环等待的三个要求系统。假设存在一个进程集合 P={p1,p2,...,pn}P = {p_1, p_2, ..., p_n} 和一个资源集合 R={r1,r2,...,rm}R = {r_1, r_2, ..., r_m}。为了简化,一个进程一次只能请求一个资源,但证明可以推广到多个资源。假设系统在时间 tt 的状态。假设系统的状态是一个元组 (ht,wt)(h_t, w_t),其中有两个函数 ht:RP{unassigned}h_t: R \rightarrow P \cup {\text{unassigned}},它将资源映射到拥有它们的进程(这是一个函数,意味着我们有互斥性)或者未分配的,以及 wt:PR{satisfied}w_t: P \rightarrow R \cup {\text{satisfied}},它将每个进程对资源的请求映射到资源或者进程是否满意。如果进程满意,我们考虑工作很平凡,进程退出,释放所有资源——这也可以推广。设 LtP×RL_t \subseteq P \times R 为一个进程在任意给定时间释放资源的请求列表集合。系统的演变在每个时间步长都在进行。

  • 释放 LtL_t 中的所有资源。

  • 找到一个请求资源的进程

  • 如果该资源可用,就将其分配给该进程,生成一个新的 (ht+1,wt+1)(h_{t+1}, w_{t+1}) 并退出当前迭代。

  • 否则,找到另一个进程并尝试在上一步骤中进行相同的资源分配过程。

如果已经调查了所有进程,并且所有进程都在请求资源且没有任何资源可以分配,则认为系统已死锁。更正式地说,这个系统死锁意味着如果 t0,tt0,pP,wt(p)satisfied and q,qpht(wt(p))=q\exists t_0, \forall t \geq t_0, \forall p \in P, w_t(p) \neq \text{satisfied} \text{ and } \exists q, q \neq p \rightarrow h_t(w_t(p)) = q(这是我们需要证明的)。

互斥和非抢占性被编码到系统中。循环等待意味着第二个条件,即一个资源被另一个进程拥有,而这个进程又是由另一个进程拥有的,意味着在这个状态下 pP,qpht(wt(p))=q\forall p \in P, \exists q \neq p \rightarrow h_t(w_t(p)) = q. 循环等待还意味着在这个当前状态下,没有任何进程是满意的,意味着在这个状态下 pP,wt(p)satisfied\forall p \in P, w_t(p) \neq \text{satisfied}. 保持和等待简单地证明了从这一点开始,系统将不会改变,这是我们所需展示的所有条件。 \square

如果一个系统违反了其中任何一个,它将无法出现死锁!考虑这样一个场景,两个学生都需要写笔和纸,而每种物品只有一件。违反互斥意味着学生们可以共享笔和纸。违反循环等待可能意味着学生们同意先拿笔再拿纸。作为反证法,假设在规则和条件下发生死锁。不失一般性,这意味着一个学生必须等待一支笔同时持有纸,而另一个学生也在等待一支笔并持有纸。我们自相矛盾,因为一个学生在没有拿笔的情况下拿走了纸,所以死锁无法发生。违反保持和等待可能意味着学生们尝试先拿笔再拿纸,如果学生未能拿走纸,则他们释放笔。这引入了一个新的问题,称为活锁,稍后将会讨论。违反抢占意味着如果两个学生陷入死锁,老师可以介入并通过给学生一个持有的物品或告诉两个学生放下物品来打破死锁。

livelock 与死锁相关。考虑上述的打破持有和等待解决方案。虽然死锁被避免了,但如果哲学家反复以相同的模式拿起相同的工具,将不会完成任何工作。活锁通常更难检测,因为进程在外部操作系统看来似乎正在工作,而在死锁中,操作系统通常知道两个进程正在等待系统级资源。另一个问题是,活锁有必要的条件(即死锁未能发生),但没有充分的条件——这意味着没有一组规则,活锁必须发生。你必须通过所谓的守恒量在系统中正式证明。必须枚举系统的每个步骤,如果每个步骤最终——在经过有限步骤之后——导致向前进展,则系统不会陷入活锁。甚至还有更好的系统可以证明有限等待;系统最多只能陷入活锁nn个周期,这对于像证券交易所这样的东西可能很重要。

解决活锁和死锁的方法

忽略死锁是最明显的方法。相当幽默的是,这种方法被称为鸵鸟算法。尽管没有明显的来源,但算法的想法来自鸵鸟将头埋在沙子里的概念。当操作系统检测到死锁时,它不会采取任何异常行动,任何死锁通常都会消失。操作系统在停止进程以进行上下文切换时抢占进程。操作系统可以中断任何系统调用,可能打破死锁场景。操作系统还使一些文件为只读,从而使得资源可共享。该算法所指的是,如果有一个特定的对手精心制作程序——或者等价地,一个用户编写程序不当——操作系统会陷入死锁。对于日常生活来说,这通常是可以的。当它不是这样时,我们可以转向以下方法。

死锁检测允许系统进入死锁状态。进入后,系统使用这些信息来打破死锁。例如,考虑多个进程访问文件。操作系统可以在某些级别上通过文件描述符跟踪所有文件/资源,无论是通过 API 抽象还是直接。如果操作系统检测到操作系统文件描述符表中的有向循环,它可能会通过调度等方式打破一个进程的持有,让系统继续进行。为什么这在这一领域如此受欢迎,是因为我们无法知道程序将选择哪些资源,除非运行程序。这是 Rice 定理(Rice 1953)的扩展,该定理表示我们无法知道任何语义特征,除非运行程序(语义意义如它试图打开哪些文件)。因此,从理论上讲,这是合理的。然后问题就出现了,如果我们反复抢占一组资源,我们可能会达到一个活锁场景。解决这个问题的方法主要是概率性的。操作系统选择一个随机的资源来打破。尽管用户可以编写一个程序,其中打破持有并等待每个资源会导致活锁,但在实际运行程序的机器上,这种情况并不常见,或者发生的活锁也只持续几个周期。这些系统适用于需要保持非死锁状态的产品,但可以容忍短时间内出现活锁的小概率。

此外,我们还有银行家算法。其基本前提是银行永远不会枯竭,这可以防止死锁。您可以随时查看附录以获取更多详细信息。

进餐哲学家

进餐哲学家问题是一个经典的同步问题。想象一下,我们邀请了nn(比如说 6)位哲学家共进晚餐。我们将他们安排在一张桌子旁,每两个哲学家之间有一根筷子。哲学家交替地想要进餐或思考。为了进餐,哲学家必须拿起他们位置两侧的两根筷子。原始问题要求每个哲学家都拥有两把叉子,但一个人可以用一把叉子吃饭,所以我们排除了这一点。然而,这些筷子是与邻居共享的。

进餐哲学家

进餐哲学家

有可能设计一个高效的解决方案,使得所有哲学家都能吃到食物吗?或者,会有一些哲学家饿死,永远得不到第二根筷子吗?或者,他们都会陷入死锁状态吗?例如,想象每个客人拿起他们左边的筷子,然后等待他们右边的筷子变得空闲。哎呀——我们的哲学家已经死锁了!每个哲学家本质上都是相同的,这意味着每个哲学家都有基于其他哲学家的相同指令集,即你不能告诉每个偶数哲学家做一件事,而每个奇数哲学家做另一件事。

失败的解决方案

void* philosopher(void* forks){
 info phil_info = forks;
 pthread_mutex_t* left_fork = phil_info->left_fork;
 pthread_mutex_t* right_fork = phil_info->right_fork;
 while(phil_info->simulation){
 pthread_mutex_lock(left_fork);
 pthread_mutex_lock(right_fork);
 eat(left_fork, right_fork);
 pthread_mutex_unlock(left_fork);
 pthread_mutex_unlock(right_fork);
 }
}

这看起来不错,但是。如果每个人都拿起他们的左筷子并等待他们的右筷子呢?我们已经使程序死锁了。重要的是要注意,死锁并不总是发生,并且随着哲学家数量的增加,这种解决方案死锁的概率会降低。重要的是要注意,最终这个解决方案会死锁,导致线程饿死,这是不好的。以下是一个简单的资源分配图,展示了系统可能如何死锁。

左右用餐哲学家循环

左右用餐哲学家循环

所以现在你正在考虑打破一个 Coffman 条件。让我们打破“持有和等待”条件!

void* philosopher(void* forks){
 info phil_info = forks;
 pthread_mutex_t* left_fork = phil_info->left_fork;
 pthread_mutex_t* right_fork = phil_info->right_fork;
 while(phil_info->simulation){
 int left_succeed = pthread_mutex_trylock(left_fork);
 if (!left_succeed) {
 sleep();
 continue;
 }
 int right_succeed = pthread_mutex_trylock(right_fork);
 if (!right_succeed) {
 pthread_mutex_unlock(left_fork);
 sleep();
 continue;
 }
 eat(left_fork, right_fork);
 pthread_mutex_unlock(left_fork);
 pthread_mutex_unlock(right_fork);
 }
}

现在哲学家拿起左筷子并尝试拿起右筷子。如果它可用,他们就开始吃。如果不可用,他们放下左筷子,然后再次尝试。没有死锁!但是,有一个问题。如果所有哲学家同时拿起左筷子,尝试拿起右筷子,放下左筷子,拿起左筷子,尝试拿起右筷子,如此循环呢?以下是系统时间演化的样子。

活锁失败

活锁失败

我们现在把解决方案活锁了!我们可怜的哲学家们仍然在饿着,所以让我们给他们一些合适的解决方案。

可行的解决方案

原始的仲裁者解决方案有一个仲裁者,比如一个互斥锁。让每个哲学家向仲裁者请求吃食物的许可或尝试锁定仲裁者的互斥锁。这个解决方案允许一次只有一个哲学家吃食物。当他们吃完后,另一个哲学家可以请求吃食物的许可。这防止了死锁,因为没有循环等待!没有哲学家需要等待其他任何哲学家。高级仲裁者解决方案是实现一个类,该类确定哲学家的筷子是否在仲裁者的控制下。如果是,就给他们筷子,让他们吃,然后收回筷子。这个解决方案的好处是能够让多个哲学家同时吃食物。

这些解决方案存在很多问题。一个是它们速度慢,并且有一个单点故障。假设所有哲学家都是善意的,仲裁者需要公平。在实际系统中,由于调度或伪随机性,仲裁者往往会给相同的过程分配筷子。需要注意的是,这防止了整个系统的死锁。但在我们的就餐哲学家模型中,哲学家必须自己释放锁。然后,你可以考虑恶意哲学家的案例(比如说笛卡尔,因为他的邪恶恶魔)。他可以永远抓住仲裁者。他会向前进步,系统也会向前进步,但没有办法确保每个进程都向前进步,除非对进程有所假设或者有真正的抢占——意味着一个更高的权威(比如说史蒂夫·乔布斯)告诉他们强制停止进餐。

证明:

仲裁者解决方案不会导致死锁

证明过程几乎是最简单的。一次只能有一位哲学家请求资源。在只有一个哲学家行动的情况下,即先拿起左边的叉子然后是右边的叉子,无法在资源分配图中形成一个循环,这正是我们需要证明的。

\square

仲裁者图

仲裁者图

离开餐桌(Stallings 的解决方案)

为什么第一个解决方案会导致死锁?好吧,有nn位哲学家和nn根筷子。如果桌上只有 1 位哲学家呢?我们会死锁吗?不会。那么 2 位哲学家呢?3 位呢?你可以看到这个趋势。Stallings(Stallings 2011 P. 280)的解决方案是通过从桌上移除哲学家直到不可能发生死锁——想想桌上哲学家的神奇数字。在实际系统中,通过信号量和允许一定数量的哲学家通过来实现这一点。这有一个好处,就是多个哲学家可以同时进餐。

在哲学家们不是邪恶的情况下,这个解决方案需要大量的耗时上下文切换。而且也没有可靠的方法事先知道资源数量。在就餐哲学家问题中,这是因为所有事情都是已知的,但试图指定一个操作系统,其中系统不知道哪个进程将要打开哪个文件,可能会导致错误的解决方案。再次强调,由于信号量是系统构造,它们遵循系统时钟,这意味着相同的过程往往会再次被添加回队列中。现在,如果一个哲学家变得邪恶,那么问题就变成了没有抢占。一个哲学家可以吃他们想吃的任何东西,系统将继续运行,但这意味着在最坏的情况下,这个解决方案的公平性可能很低。这种方法最好与超时或强制上下文切换结合使用,以确保有限等待时间。

证明:

斯塔林解决方案不会死锁。让我们给哲学家编号 {p0,p1,..,pn1}{p_0, p_1, .., p_{n-1}} 和资源 {r0,r1,..,rn1}{r_0, r_1, .., r_{n-1}}。哲学家 pip_i 需要资源 ri1modnr_{i-1 \mod n}ri+1modnr_{i + 1 \mod n}。在不失一般性的情况下,让我们将 pip_i 从图中去除。每个资源恰好有两个哲学家可以使用。现在资源 ri1modnr_{i-1 \mod n}ri+1modnr_{i + 1 \mod n} 只有一个哲学家在等待。即使持有和等待,没有抢占,也没有互斥或当前存在,资源永远不会进入一个状态,其中一个哲学家请求它们,而它们被另一个哲学家持有,因为只有一个哲学家可以请求它们。由于没有其他方式生成循环,循环等待不能成立。由于循环等待不能成立,死锁就不会发生。 \square

这里是最坏情况的可视化。系统即将死锁,但这种方法解决了它。

Stalling solution almost deadlock

Stalling solution almost deadlock

部分排序(迪杰斯特拉解决方案)

这就是迪杰斯特拉(Dijkstra)的解决方案(Dijkstra, n.d. P. 20)。他就是那个在考试中提出这个问题的那个人。为什么第一个解决方案会陷入死锁?迪杰斯特拉认为,最后一位拿起他左边叉子(导致解决方案死锁)的哲学家应该拿起他的右边叉子。他通过编号叉子 1..n1..n 来实现这一点,并告诉每位哲学家拿起他编号较低的叉子。让我们再次运行死锁条件。每个人都试图先拿起他们编号较低的叉子。哲学家 11 拿起叉子 11,哲学家 22 拿起叉子 22,以此类推,直到我们到达哲学家 nn。他们必须在叉子 11nn 之间做出选择。叉子 11 已经被哲学家 11 拿起,所以他们不能拿起那个叉子,这意味着他不会拿起叉子 nn。我们已经打破了规则!这意味着死锁是不可能的。

一些问题在于,实体要么需要事先知道有限资源集合,要么能够产生一个一致的偏序,以防止循环等待发生。这也意味着需要有一个实体,无论是操作系统还是另一个进程,来决定数量,并且所有哲学家都需要在新资源到来时同意这个数量。正如我们之前看到的前面解决方案一样,这依赖于上下文切换。这优先考虑了已经吃过饭的哲学家,但可以通过引入随机的睡眠和等待来使其更加公平。

证明:

迪杰斯特拉的解决方案不会死锁

证明与之前的证明类似。让我们对哲学家进行编号 {p0,p1,..,pn1}{p_0, p_1, .., p_{n-1}} 和资源 {r0,r1,..,rn1}{r_0, r_1, .., r_{n-1}}。哲学家 pip_i 需要资源 ri1modnr_{i-1 \mod n}ri+1modnr_{i + 1 \mod n}。每位哲学家将先抓取 ri1modnr_{i-1 \mod n} 然后抓取 ri+1modnr_{i + 1 \mod n},但最后一位哲学家将按相反顺序抓取。即使持有并等待,也没有抢占,也没有互斥或当前存在。由于最后一位哲学家将抓取 rn1r_{n-1} 然后是 r0r_0,有两种情况:哲学家拥有第一个锁或者哲学家没有。

如果最后一个哲学家pn1p_{n-1}持有第一个锁,意味着前一个哲学家pn2p_{n-2}正在等待rn1r_{n-1},意味着rn2r_{n-2}是可用的。由于没有其他阻塞者,前一个哲学家pn3p_{n-3}将获取她的第一个锁。现在这变成了对前面 stalling 证明的简化,因为我们现在有nn资源,但只有n1n-1个哲学家,这意味着这不会死锁。

如果哲学家没有得到第一个锁,那么我们就回到了上面 Stalling 的证明,因为现在有n1n-1个哲学家争夺nn资源。由于我们在这两种情况下都无法达到死锁,这个解决方案不会死锁,这正是我们需要证明的。

\square

Stalling solution partial deadlock

Stalling solution partial deadlock

附录中还有一些其他解决方案(清洁/脏叉子和演员模型)。

主题

  • Coffman 条件

  • 资源分配图

  • 进餐哲学家

  • 失败的 DP 解决方案

  • 活锁 DP 解决方案

  • 工作 DP 解决方案:优点/缺点

  • Ron Swanson Deadlock

问题

  • 什么是 Coffman 条件?

  • 每个 Coffman 条件意味着什么?定义每一个。

  • 依次给出打破每个 Coffman 条件的真实生活例子。一个需要考虑的情况:画家、颜料、画笔等。你将如何确保工作能够完成?

  • 在以下片段中,哪个 Coffman 条件没有得到满足?

    // Get both locks or none
    pthread_mutex_lock(a);
    if(pthread_mutex_trylock( b )) { /* failure */
     pthread_mutex_unlock( a );
    } 
    
  • 以下调用被发出

    // Thread 1
    pthread_mutex_lock(m1) // success
    pthread_mutex_lock(m2) // blocks
    
    // Thread 2
    pthread_mutex_lock(m2) // success
    pthread_mutex_lock(m1) // blocks 
    

    发生了什么?为什么会发生?如果第三个线程调用会发生什么?

  • 有多少进程被阻塞了?通常,假设一个进程可以在获取以下所有资源后完成。

    • P1 获取 R1

    • P2 获取 R2

    • P1 获取 R3

    • P2 等待 R3

    • P3 获取 R5

    • P1 等待 R4

    • P3 等待 R1

    • P4 等待 R5

    • P5 等待 R1

绘制资源图!

虚拟内存和进程间通信

在简单的嵌入式系统和早期的计算机中,进程直接访问内存——“地址 1234”对应于物理内存特定部分存储的特定字节。例如,IBM 709 必须直接读取和写入磁带,没有任何抽象层次(IBM,1958 年 8 月 P. 65)。即使在之后的系统中,采用虚拟内存也很困难,因为虚拟内存需要通过硬件改变整个获取周期——许多制造商仍然认为这是一个昂贵的改变。在 PDP-10 中,通过为每个进程使用不同的寄存器来使用一种解决方案,后来又添加了虚拟内存(“DEC Pdp-10 Ka10 控制面板”,n.d.)。在现代系统中,情况不再是这样。相反,每个进程都是隔离的,并且有一个地址转换过程,将特定 CPU 指令的地址或进程的数据块与物理内存的实际字节(“RAM”)相对应。内存地址不再映射到物理地址,进程在虚拟内存中运行。虚拟内存使进程保持安全,因为一个进程不能直接读取或修改另一个进程的内存。虚拟内存还允许系统有效地分配和重新分配内存的不同部分给不同的进程。现代的内存转换过程如下。

  1. 进程发出内存请求

  2. 电路首先检查地址页是否缓存在内存中的转换查找缓冲器(TLB)。如果找到,则跳转到读取/写入阶段,否则请求将发送到 MMU。

  3. 内存管理单元(MMU)执行地址转换。如果转换成功,页面将从 RAM 中拉取——概念上整个页面并没有被加载。结果是缓存在 TLB 中。

  4. CPU 通过从物理地址读取或写入地址来执行操作。

地址转换

内存管理单元是 CPU 的一部分,它将虚拟内存地址转换为物理地址。首先,我们将讨论虚拟内存抽象是什么以及如何转换地址

为了说明,考虑一个 32 位机器,这意味着指针是 32 位的。它们可以访问2322^{32}个不同的位置或 4GB 的内存,其中每个地址对应一个字节。想象一下,我们有一个大表,用于存储每个可能的地址,我们将存储‘真实’的即物理地址。每个物理地址需要 4 个字节——来存储 32 位。自然地,这个方案将需要 160 亿字节来存储所有条目。很明显,我们的查找方案将消耗掉我们为 4GB 机器所能购买的所有内存。我们的查找表应该比我们拥有的内存小,否则我们将没有空间留给我们的实际程序和操作系统数据。解决方案是将内存分成小块,称为“页面”和“帧”,并为每个页面使用一个查找表。

术语

页面是一个虚拟内存块。在 Linux 上,典型的块大小是 4KiB 或2122^{12}地址,尽管也可以找到更大块大小的例子。因此,我们与其谈论单个字节,不如谈论 4KiB 的块,每个块被称为页面。我们也可以对页面进行编号(“页面 0”、“页面 1”等)。让我们做一个示例计算,假设页面大小为 4KiB,看看有多少页面。

对于 32 位机器,

232地址/212(地址/页面)=220页面.2^{32} \text{地址} / 2^{12} \text{(地址/页面)} = 2^{20} \text{页面}.

对于 64 位机器,

264地址/212(地址/页面)=252页面1015页面.2^{64} \text{地址} / 2^{12} \text{(地址/页面)} = 2^{52} \text{页面} \approx 10^{15} \text{页面}.

我们也称之为或有时称为“页面帧”,它是一块物理内存或 RAM——随机存取存储器。帧的大小与虚拟页面相同,在我们的机器上是 4KiB。它存储感兴趣的字节。要访问帧中的特定字节,MMU 从帧的起始位置开始并添加偏移量——稍后讨论。

页表是从数字到特定帧的映射。例如,页面 1 可能映射到帧 45,页面 2 映射到帧 30。其他帧可能目前未被使用,或分配给其他正在运行的过程,或由操作系统内部使用。从名称中隐含地想象,将页表想象成一个表。

Explicit Frame Table

显式帧表

在实践中,我们将省略第一列,因为它始终是顺序的 0,1,2 等,而我们将使用从表开始处的偏移量作为条目号。

现在进行实际计算。我们将假设一个 32 位机器有 4KiB 页面。自然地,为了地址所有可能的条目,有2202^{20}个帧。由于有2202^{20}个可能的帧,我们需要 20 位来编号所有可能的帧,这意味着必须是 2.5 字节长。在实践中,我们将它向上取整到 4 字节,并对剩余的位进行一些有趣的操作。每个条目 4 字节 x 2202^{20}条目 = 4 MiB 的物理内存用于存储进程的整个页表。

记住我们的页表将页面映射到帧,但每个帧是一块连续的地址。我们如何计算特定帧内要使用的特定字节?解决方案是直接重用虚拟内存地址的最低位。例如,假设我们的进程正在读取以下地址-

以上的虚拟地址为例,我们如何使用单页表框架方案将其拆分?

Splitting Address

分割地址

我们可以将解引用的步骤想象成一个过程。一般来说,它看起来像以下这样。

One level dereference

一级解引用

从上述特定地址读取的方式在下面进行了可视化。

One level dereference example

一级解引用示例

如果我们从它读取,则返回该值。这听起来像是一个完美的解决方案。将每个地址按顺序映射到虚拟地址。这个过程会认为地址是连续的,但前 20 位用于确定,这将允许我们找到帧号,找到帧,加上偏移量(由最后 12 位推导而来)并执行读取或写入操作。

还有其他方法可以分割它。在一个页面大小为 256 字节的机器上,最低的 8 位(10101010)将用作偏移量。剩余的上位将是页面号(111100001111000011110000)。这个偏移量被视为一个二进制数,并在获取时添加到帧的开始处。

我们确实在 64 位操作系统中遇到了一个问题。对于具有 4KiB 页面的 64 位机器,每个条目需要 52 位。这意味着我们需要大约2522^{52}个条目,这相当于2552^{55}字节(大约 40 拍字节)。所以我们的页面表太大了。在 64 位架构中,内存地址是稀疏的,因此我们需要一种机制来减少页面表的大小,考虑到大多数条目将永远不会被使用。我们将在下面讨论这个问题。还有一个术语需要解释。

多级页面表

多级页面是解决 64 位架构页面表大小问题的解决方案之一。我们将查看最简单的实现方式——两级页面表。每个表都是一个指向下一级表的指针列表,某些子表可能被省略。以下是一个示例,展示了一个 32 位架构的两级页面表。

三级地址分割

三级地址分割

那么,取消引用地址的直觉是什么?首先,MMU(内存管理单元)取顶级页面表并找到第’t’个条目。这将包含一个数字,将 MMU 引导到适当的子表。然后转到该表的第’t’个条目。这将包含一个帧号。这是我们之前提到过的老式的 4KiB RAM。然后,MMU 添加偏移量并执行读取或写入操作。

可视化取消引用

在一张图中,取消引用看起来像以下图像。

全页表取消引用

全页表取消引用

按照我们的示例,以下是取消引用的外观。

全页示例取消引用

全页示例取消引用

计算大小问题

现在来计算一下大小。每个索引有 10 位宽,因为只有2102^{10}种可能,所以我们需要 10 位来存储每个目录索引。为了便于推理,我们将它四舍五入到 2 字节。如果顶级表中的每个条目使用 2 字节,并且只有2102^{10}个条目,我们只需要 2KiB 来存储整个第一级页面表。每个子表将指向物理帧,它们的每个条目都需要 4 字节,以便能够像之前提到的那样寻址所有帧。然而,对于只有微小内存需求的进程,我们只需要指定堆和程序代码的低内存地址条目以及堆栈的高内存地址条目。

因此,我们多级页表的内存开销从单级实现的 4MiB 减少到三个页表内存,即顶级页表 2KiB 和两个中间级别各 4KiB。原因如下。我们需要至少一个帧用于高级目录和两个帧用于两个子表。一个子表是必要的,用于低地址——程序代码、常量和可能是一个非常小的堆。另一个子表用于环境和高地址栈。在实践中,实际程序可能需要更多的子表条目,因为每个子表只能引用 1024*4KiB=4MiB 的地址空间。主要观点仍然成立。我们已经显著减少了执行页表查找所需的内存开销。

页表缺点

页表有很多问题——其中一个主要问题是它们很慢。对于单个页表,我们的机器现在慢了两倍!需要两次内存访问。对于两级页表,内存访问现在慢了三倍——需要三次内存访问。

为了克服这种开销,MMU 包括一个最近使用的虚拟页到帧查找的关联缓存。这个缓存称为 TLB(“转换旁路缓存”)。每次需要将虚拟地址转换为物理内存位置时,TLB 都会与页表并行查询。对于大多数程序的大多数内存访问,TLB 缓存结果的可能性很大。然而,如果一个程序缺乏缓存一致性,地址将缺失在 TLB 中,这意味着 MMU 必须使用较慢的页表转换。

MMU 算法

与 MMU 相关联有一种伪代码。我们将假设这是针对单级页表的。

  1. 接收地址

  2. 尝试根据编程方案转换地址

  3. 如果转换失败,报告无效地址

  4. 否则,

    1. 如果 TLB 包含物理内存,则从 TLB 获取物理帧并执行读写操作。

    2. 如果页面存在于内存中,检查进程是否有权限在页面上执行操作,这意味着进程可以访问页面,并且正在从页面读取/写入它有权限进行的页面。

      1. 如果是这样,则进行解引用提供地址,将结果缓存到 TLB 中

      2. 否则,触发硬件中断。内核很可能会向进程发送 SIGSEGV 或段错误。

    3. 如果页面不在内存中,则生成一个中断。

      1. 内核可能会意识到这个页面可能既未分配也可能在磁盘上。如果它符合映射,则分配页面并再次尝试操作。

      2. 否则,这是无效访问,内核很可能会向进程发送 SIGSEGV。

如何将其修改为多级页表?

帧和页面保护

帧可以在进程之间共享,这也是本章的核心所在。我们可以使用这些表与进程进行通信。除了存储帧号外,页表还可以用来存储进程是否可以写入或仅读取特定的帧。只读帧可以安全地在多个进程之间共享。例如,C 库指令代码可以在所有动态将代码加载到进程内存的进程中共享。每个进程只能读取该内存。这意味着如果程序尝试写入内存中的只读页面,它将。这就是为什么有时内存访问会导致 SEGFAULT,有时则不会,这完全取决于你的硬件是否允许程序访问。

此外,进程可以使用系统调用与子进程共享页面。这是一个有趣的调用,因为它不是将每个虚拟地址绑定到一个物理帧上,而是将其绑定到其他东西上。这是一个重要的区别,我们正在讨论的是 mmap,而不是一般的内存映射 I/O。系统调用不能可靠地用于执行其他内存映射操作,如与 GPU 通信和将像素写入屏幕 – 这主要取决于硬件。

页面上的位

这与芯片组密切相关。我们将包括一些在芯片组中历史上流行的位。

  1. 只读位将页面标记为只读。尝试写入页面的操作将导致页面错误。然后内核将处理页面错误。只读页面的两个例子包括在多个进程之间共享 C 标准库以增强安全性(你不希望允许一个进程修改库)和写时复制(Copy-On-Write),其中复制页面的成本可以延迟到第一次写入发生。

  2. 执行位定义了页面上字节是否可以作为 CPU 指令执行。处理器可能将这些位合并为一个,并认为页面要么可写要么可执行。这个位很有用,因为它防止了在将用户数据写入堆或栈时栈溢出或代码注入攻击,因为这些不是只读的,因此不是可执行的。进一步阅读:背景信息

  3. 污点位允许进行性能优化。如果一个页面仅被读取而没有更改,那么可以丢弃该页面而不需要同步到磁盘,因为页面没有发生变化。然而,如果页面在分页到内存后已被写入,其污点位将被设置,表示该页面必须写回后备存储。这种策略要求后备存储在页面分页到内存后保留页面的副本。当省略污点位时,后备存储只需要与任何时刻所有已分页出页的瞬时总大小一样大。当使用污点位时,始终会有一些页面同时存在于物理内存和后备存储中。

  4. 还有许多其他内容。看看你最喜欢的架构,看看还有哪些位与之相关!

页面错误

当进程访问内存中缺失的帧的地址时,可能会发生页面错误。页面错误有三种类型

  1. 次要 如果页面还没有映射,但它是一个有效的地址。这可能意味着操作系统可以等待第一次写入之前分配空间,如果它被读取了,操作系统可以短路操作来读取 0。操作系统只是创建页面,将其加载到内存中,然后继续。

  2. 主要 如果页面的映射仅限于磁盘。操作系统会将页面交换到内存中,并将另一个页面交换出去。如果这种情况频繁发生,你的程序就被说成是thrash了 MMU。

  3. 无效 当程序尝试写入不可写的内存地址或读取不可读的内存地址时。MMU 生成一个无效故障,操作系统通常会生成一个意义分割错误,意味着程序写到了它不能写入的段之外。

返回到 IPC

这与 IPC 有什么关系?在此之前,你知道进程有隔离。一是你不知道这种隔离是如何映射的。二是你可能不知道你如何打破这种隔离。要打破任何内存级别的隔离,你有两条途径。一是要求内核提供某种类型的接口。二是要求内核将两个页面的内存映射到相同的虚拟内存区域,并自行处理所有同步。

mmap

是虚拟内存的一个技巧,而不是将页面映射到帧,该帧可以由磁盘上的文件支持,或者帧可以在进程间共享。我们可以利用它来高效地从磁盘上的文件读取或同步对文件的变化。其中一个大的优化是文件可以延迟分配到内存中。以下代码为例。

 int fd = open(...); //File is 2 Pages
 char* addr = mmap(..fd..);
 addr[0] = 'l';

内核看到程序想要将文件映射到内存中,所以它会在你的地址空间中预留一些空间,其长度与文件长度相同。这意味着当程序写入时,它写入文件的第一字节。内核也可以进行一些优化。它不必将整个文件加载到内存中,它可能一次只加载页面。一个程序可能只访问 3 或 4 个页面,使加载整个文件成为浪费时间。页面错误之所以强大,是因为它让操作系统控制文件何时被使用。

mmap 定义

不仅将文件映射到内存中,它还是创建进程间共享内存的通用接口。目前它仅支持常规文件和 POSIX(“Mmap” 2018)。自然地,你可以在上面的参考中详细了解它,其中引用了当前的工作组 POSIX 标准。页面上的其他一些选项将在下面说明。

第一个选项是 mmap 的参数可以接受许多选项。

  1. 这意味着进程可以读取内存。然而,这并不是唯一给予进程读取权限的标志!在这种情况下,基础文件描述符必须以读权限打开。

  2. 这意味着进程可以向内存写入。为了使进程能够写入映射,必须提供这一点。如果提供了这一点,并且也提供了,那么后者将获胜,并且无法执行任何写入。在这种情况下,基础文件描述符必须以写权限打开,或者必须提供以下私有映射。

  3. 这意味着进程可以执行这块内存。尽管这一点在 POSIX 文档中没有明确说明,但这不应该与 WRITE 或 NONE 一起提供,因为这会使它在 NX 位或无法执行(分别)的情况下无效。

  4. 这意味着这个过程无法对映射做任何事情。如果你从安全角度实现守卫页面,这可能是有用的。如果你用许多无法访问的页面包围关键数据,这会降低各种攻击的机会。

  5. 这个映射将与底层文件对象同步。在这种情况下,文件描述符必须以写权限打开。

  6. 这个映射只会对进程本身可见。这对于避免操作系统过载很有用。

记住,一旦程序完成 ping,程序必须通知操作系统它不再使用分配的页面,这样操作系统就可以将其写回磁盘,并在需要另一个 mmap 时释放地址。虽然我们不会深入探讨这一点,但有一些伴随调用可以接受一个 mmap’ed 内存块并将其更改同步回文件系统。下面的注释说明中描述了 mmap 的其他参数。

注释说明的 mmap 流程

下面是 man 页面中示例代码的注释说明。我们的命令行工具将接受一个文件、偏移量和长度来打印。我们可以假设这些已经被正确初始化,并且偏移量 + 长度小于文件长度。

 off_t offset;
 size_t length;

我们假设所有系统调用都成功。首先,我们必须打开文件并获取其大小。

 struct stat sb;
 int fd = open(argv[1], O_RDONLY);
 fstat(fd, &sb);

然后,我们需要引入另一个变量,称为 . mmap 不允许程序传递任何值作为偏移量,它必须是页面大小的倍数。在我们的情况下,我们将向下取整。

 off_t page_offset = offset & ~(sysconf(_SC_PAGE_SIZE) - 1);

然后,我们调用 mmap,以下是参数的顺序。

  1. NULL,这告诉 mmap 我们不需要从任何特定地址开始

  2. length + offset - page_offset,将文件的“剩余部分”映射到内存中(从偏移量开始)

  3. PROT_READ,我们想要读取文件

  4. MAP_PRIVATE,告诉操作系统,我们不希望共享我们的映射

  5. fd,我们引用的对象描述符

  6. pa_offset,从哪里开始的页面对齐偏移量

 char * addr = mmap(NULL, length + offset - page_offset, PROT_READ,
 MAP_PRIVATE, fd, page_offset);

现在,我们可以像处理正常缓冲区一样与地址交互。之后,我们必须取消映射文件并关闭文件描述符,以确保释放其他系统资源。

 write(1, addr + offset - page_offset, length);
 munmap(addr, length + offset - pa_offset);
 close(fd);

查看 man 页面中的完整列表。

MMAP 通信

我们如何使用 mmap 在进程间进行通信?从概念上讲,这就像使用线程一样。让我们通过一个分解的例子来探讨。首先,我们需要分配一些空间。我们可以通过调用来实现。我们还将为 100 个整数分配空间。

 int size = 100 * sizeof(int);
 void *addr = mmap(0, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
 int *shared = addr;

然后,我们需要进行分支并执行一些通信。我们的父进程将存储一些值,而我们的子进程将读取这些值。

 pid_t mychild = fork();
 if (mychild > 0) {
 shared[0] = 10;
 shared[1] = 20;
 } else {
 sleep(1); // Check the synchronization chapter for a better way
 printf("%d\n", shared[1] + shared[0]);
 }

现在,不能保证值会被传递,因为使用的进程不是互斥锁。大多数时候这会工作。

 #include <stdio.h>
 #include <stdlib.h>
 #include <sys/types.h>
 #include <sys/stat.h>
 #include <sys/mman.h>  /* mmap() is defined in this header */
 #include <fcntl.h>
 #include <unistd.h>
 #include <errno.h>
 #include <string.h>

 int main() {

 int size = 100 * sizeof(int);
 void *addr = mmap(0, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

 printf("Mapped at %p\n", addr);

 int *shared = addr;
 pid_t mychild = fork();
 if (mychild > 0) {
 shared[0] = 10;
 shared[1] = 20;
 } else {
 sleep(1); // We will talk about synchronization later
 printf("%d\n", shared[1] + shared[0]);
 }

 munmap(addr,size);
 return 0;
 }

这段代码为 100 个整数分配空间,并创建了一个所有进程共享的内存块。然后代码执行分支。父进程将两个整数写入前两个槽位。为了避免数据竞争,子进程休眠一秒钟,然后打印出存储的值。这是一种不完美的防止数据竞争的方法。我们可以在同步部分提到的进程间使用互斥锁。但在这个简单的例子中,它工作得很好。请注意,每个进程在使用完这块内存后都应该调用 munmap。

共享匿名内存是进程间通信的一种高效形式,因为没有复制、系统调用或磁盘访问开销——两个进程共享相同的物理内存帧。另一方面,共享内存,就像在多线程上下文中一样,为数据竞争创造了空间。共享可写内存的进程可能需要使用互斥锁等同步原语来防止这种情况发生。

管道

你已经看到了虚拟内存方式的 IPC,但内核还提供了更多标准的 IPC 版本。其中一个大效用是 POSIX 管道。管道简单地接收一个字节流,并输出一个字节序列。

管道的起点可以追溯到 PDP-10 时代。在那个时代,对磁盘的写入甚至对终端的写入都很慢,因为可能需要打印出来。Unix 程序员仍然想要创建小型、可移植的程序,这些程序擅长做一件事,并且可以组合使用。因此,发明了管道来将一个程序的输出传递给另一个程序的输入,尽管它们今天还有其他用途——你可以在维基百科页面管道(Unix)上了解更多。考虑如果你在终端输入以下内容。

 $ ls -1 | cut -d'.' -f1 | sort | uniq | tee dirents

以下代码做了什么?首先,它列出当前目录。-1 表示每行输出一个条目。然后命令取第一个点之前的所有内容。对所有输入行进行排序,确保所有行都是唯一的。最后,将内容输出到文件和终端供您查阅。重要的是,bash 创建了5 个独立的过程,并通过管道将它们的标准输出/标准输入连接起来,其轨迹看起来像这样。

管道进程 文件描述符重定向

管道进程 文件描述符重定向

管道中的数字是每个进程的文件描述符,箭头表示重定向或管道输出的去向。POSIX 管道几乎就像它的实际对应物——一个程序可以将字节从一端塞入,它们将以相同的顺序出现在另一端。然而,与真实管道不同的是,流量始终是单向的,一个文件描述符用于读取,另一个用于写入。系统调用用于创建管道。这些文件描述符可以与和一起使用。使用管道的常见方法是在创建进程之前创建管道,以与子进程通信

 int filedes[2];
 pipe (filedes);
 pid_t child = fork();
 if (child > 0) { /* I must be the parent */
 char buffer[80];
 int bytesread = read(filedes[0], buffer, sizeof(buffer));
 // do something with the bytes read
 } else {
 write(filedes[1], "done", 4);
 }

管道创建了两个文件描述符。包含读取端。包含写入端。你友好的邻家助教记住的是,一个可以在写入之前读取,或者读取在写入之前发生。你可以尽情地抱怨,但这有助于记住哪个是读取端,哪个是写入端。

可以在同一个进程中使用管道,但通常没有额外的优势。这里有一个发送消息给自身的示例程序。

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

 int main() {
 int fh[2];
 pipe(fh);
 FILE *reader = fdopen(fh[0], "r");
 FILE *writer = fdopen(fh[1], "w");
 // Hurrah now I can use printf
 printf("Writing...\n");
 fprintf(writer,"%d %d %d\n", 10, 20, 30);
 fflush(writer);

 printf("Reading...\n");
 int results[3];
 int ok = fscanf(reader,"%d %d %d", results, results + 1, results + 2);
 printf("%d values parsed: %d %d %d\n", ok, results[0], results[1], results[2]);

 return 0;
 }

以这种方式使用管道的问题在于写入管道可能会阻塞,这意味着管道只有有限的缓冲区容量。缓冲区的最大大小是系统相关的;典型的值从 4KiB 到 128KiB,尽管它们可以被更改。

 int main() {
 int fh[2];
 pipe(fh);
 int b = 0;
 #define MESG "..............................."
 while(1) {
 printf("%d\n",b);
 write(fh[1], MESG, sizeof(MESG))
 b+=sizeof(MESG);
 }
 return 0;
 }

管道陷阱

这里有一个不完整的例子!子进程逐字节从管道中读取并打印出来——但我们从未看到消息!你能看出为什么吗?

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

 int main() {
 int fd[2];
 pipe(fd);
 //You must read from fd[0] and write from fd[1]
 printf("Reading from %d, writing to %d\n", fd[0], fd[1]);

 pid_t p = fork();
 if (p > 0) {
 /* I have a child, therefore I am the parent */
 write(fd[1],"Hi Child!",9);

 /*don't forget your child*/
 wait(NULL);
 } else {
 char buf;
 int bytesread;
 // read one byte at a time.
 while ((bytesread = read(fd[0], &buf, 1)) > 0) {
 putchar(buf);
 }
 }
 return 0;
 }

父进程将字节发送到管道中。子进程开始逐字节读取管道。在上面的例子中,子进程将读取并打印每个字符。然而,它永远不会离开 while 循环!当没有剩余的字符可读取时,它简单地阻塞并等待更多,除非所有写入者都关闭了它们的端点。另一种解决方案也可以通过检查消息结束标记来退出循环,

 while ((bytesread = read(fd[0], &buf, 1)) > 0) {
 putchar(buf);
 if (buf == '!') break; /* End of message */
 }

我们知道,当进程尝试从一个仍有写入者的管道中读取时,进程会阻塞。如果没有管道有写入者,读取会返回 0。如果进程尝试写入,而某个读取器的读取操作通过,或者失败——部分或完全失败——如果管道已满。那么当进程尝试写入而没有剩余的读取者时会发生什么呢?

 If all file descriptors referring to the read end of a pipe have been closed,
	then a write(2) will cause a SIGPIPE signal to be generated for the calling process.

小贴士:注意只有写入者(而不是读取者)可以使用这个信号。为了通知读取者写入者正在关闭管道的端点,程序可以写入一个特殊的字节(例如 0xff)或一条消息()

这里有一个捕捉这个失败信号的例子!你能看出为什么吗?

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

 void no_one_listening(int signal) {
 write(1, "No one is listening!\n", 21);
 }

 int main() {
 signal(SIGPIPE, no_one_listening);
 int filedes[2];

 pipe(filedes);
 pid_t child = fork();
 if (child > 0) {
 /* This process is the parent. Close the listening end of the pipe */
 close(filedes[0]);
 } else {
 /* Child writes messages to the pipe */
 write(filedes[1], "One", 3);
 sleep(2);
 // Will this write generate SIGPIPE ?
 write(filedes[1], "Two", 3);
 write(1, "Done\n", 5);
 }
 return 0;
 }

上述代码中的错误在于管道仍然有一个读取器!子进程仍然保留了管道的第一个文件描述符,并记得这个规范?所有读取器都必须关闭

在进行进程创建时,通常的做法是在子进程和父进程中关闭每个管道的不必要(未使用)的端点。例如,父进程可能会关闭读取端,而子进程可能会关闭写入端。

最后的补充是,程序可以将文件描述符设置为在没有监听者时返回,而不是使用 SIGPIPE,因为默认情况下 SIGPIPE 会终止你的程序。这种默认行为的原因是它使得上面的管道示例能够工作。考虑一下这个看似无用的 cat 用法

 $ cat /dev/urandom | head -n 20

Which 从 urandom 读取 20 行输入。在读取了 20 个换行符后终止。关于?需要接收一个 SIGPIPE 信号,告知程序尝试写入一个没有监听者的管道。

其他管道事实

当写入者向管道写入太多内容而没有读者读取任何内容时,管道会填满。当管道满时,所有写入都会失败,直到发生读取。即使这样,如果管道还有一点空间但不足以容纳整个消息,写入可能只会部分失败。通常,为了避免这种情况,会做两件事。要么增加管道的大小。或者更常见的是,修复你的程序设计,使管道始终被读取。

如前所述,管道写入是原子的,直到管道的大小。这意味着如果有两个进程尝试写入同一个管道,内核会与管道内部使用互斥锁,锁定它,执行写入,然后返回。唯一需要注意的问题是当管道即将满时。如果有两个进程正在尝试写入,而管道只能满足部分写入,那么这个管道写入就不是原子的——对此要小心!

无名管道存在于内存中,是一种简单且高效的进程间通信(IPC)形式,适用于流式数据和简单消息。一旦所有进程都已关闭,管道资源就会被释放。

管道通常设计为单向——意味着一个进程应该负责写入,另一个进程负责读取。否则,子进程会尝试读取其父进程(反之亦然)的数据!

管道和 Dup

通常,你希望与 dup 结合使用。以命令行中的简单程序为例。

 $ ls -1 | cut -f1 -d.

这个命令将 which 的输出(它按行列出当前目录的内容)通过管道传递给 cut。Cut 接受一个分隔符,在这种情况下是一个点,以及一个字段位置,在我们的例子中是 1,并按行输出每个分隔符的第 n 个字段。从高层次来看,这是获取当前目录中不带扩展名的文件名。

在内部,bash 就是这样做的

 #define _GNU_SOURCE

 #include <stdio.h>
 #include <fcntl.h>
 #include <unistd.h>
 #include <stdlib.h>

 int main() {

 int pipe_fds[2];
 // Call with the O_CLOEXEC flag to prevent any commands from blocking
 pipe2(pipe_fds, O_CLOEXEC);

 // Remember for pipe_fds, the program read then write (reading is 0 and writing is 1)

 if(!fork()) {
 // Child

 // Make the stdout of the process, the write end
 dup2(pipe_fds[1], 1);

 // Exec! Don't forget the cast
 execlp("ls", "ls", "-1", (char*)NULL);
 exit(-1);
 }

 // Same here, except the stdin of the process is the read end
 dup2(pipe_fds[0], 0);

 // Same deal here
 execlp("cut", "cut", "-f1", "-d.", (char*)NULL);
 exit(-1);

 return 0;
 }

两个程序的结果应该是相同的。记住,当你遇到更复杂的管道过程示例时,程序需要关闭所有未使用的管道端,否则程序将因为等待进程完成而陷入死锁。

管道便利

如果程序已经有一个文件描述符,它可以使用.将其“包装”成一个 FILE 指针

 #include <sys/types.h>
 #include <sys/stat.h>
 #include <fcntl.h>

 int main() {
 char *name="Fred";
 int score = 123;
 int filedes = open("mydata.txt", "w", O_CREAT, S_IWUSR | S_IRUSR);

 FILE *f = fdopen(filedes, "w");
 fprintf(f, "Name:%s Score:%d\n", name, score);
 fclose(f);

对于写入文件,这并不必要。使用 which 与.做相同的事情。然而,对于管道,我们已经有了一个文件描述符,所以这是一个很好的使用时机

这里有一个使用管道的完整示例,几乎可以工作!你能找到错误吗?提示:父进程从未打印过任何东西!

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

 int main() {
 int fh[2];
 pipe(fh);
 FILE *reader = fdopen(fh[0], "r");
 FILE *writer = fdopen(fh[1], "w");
 pid_t p = fork();
 if (p > 0) {
 int score;
 fscanf(reader, "Score %d", &score);
 printf("The child says the score is %d\n", score);
 } else {
 fprintf(writer, "Score %d", 10 + 10);
 fflush(writer);
 }
 return 0;
 }

注意,一旦子进程和父进程都退出,无名的管道资源将会消失。在上面的例子中,子进程会发送字节,父进程会从管道接收字节。然而,永远不会发送行结束字符,所以会继续请求字节,因为它正在等待行结束,即它会永远等待!修复方法是确保我们发送一个换行符,这样就会返回。

 change: fprintf(writer, "Score %d", 10 + 10);
 to: fprintf(writer, "Score %d\n", 10 + 10);

如果你希望你的字节立即发送到管道,你需要使用 fflush!记得回到介绍部分,展示了终端与非终端输出 stdout 之间的区别。

尽管我们有一个关于它的部分,但强烈不推荐使用文件描述符 API 来处理非可寻址文件。原因是虽然我们得到了便利,但也带来了像我们提到的缓冲区示例这样的烦恼,缓存等。基本的 C 库座右铭是,任何程序可以正确地移动到任意位置的设备,它应该能够做到。文件满足这种行为,共享内存也是如此,终端等。当涉及到管道、套接字、epoll 对象等时,不要这样做。

命名管道

无名的管道的替代方案是使用.创建的命名管道。从命令行:从 C:

你给它路径名和操作模式,它就准备好出发了!命名管道在文件系统上几乎不占用空间。这意味着管道的实际内容不会打印到文件中,也不会从同一个文件中读取。当你有一个命名管道时,操作系统会告诉你它会创建一个无名的管道,该管道指向命名管道,仅此而已!这里没有额外的魔法。这是为了编程方便,如果进程没有通过 fork 启动,那么就没有办法将文件描述符传递给子进程的无名管道。

挂起的命名管道

命名管道是一个程序调用带有读取和/或写入权限的管道。如果你想在不让一个进程必须 fork 另一个进程的情况下在两个进程之间有一个管道,这很有用。命名管道有一些需要注意的问题。下面有更多内容,但我们将在这里介绍一个简单的例子。读取和写入在命名管道上挂起,直到至少有一个读者和一个写者,注意这一点。

 1$ mkfifo fifo
 1$ echo Hello > fifo
 # This will hang until the following command is run on another terminal or another process
 2$ cat fifo
 Hello

在命名管道上调用任何操作时,内核会阻塞,直到另一个进程调用相反的打开操作。这意味着 echo 调用会阻塞,直到 cat 调用,然后程序才被允许继续。

命名管道的竞态条件

以下程序有什么问题?

 //Program 1

 int main(){
 int fd = open("fifo", O_RDWR | O_TRUNC);
 write(fd, "Hello!", 6);
 close(fd);
 return 0;
 }

 //Program 2
 int main() {
 char buffer[7];
 int fd = open("fifo", O_RDONLY);
 read(fd, buffer, 6);
 buffer[6] = '\0';
 printf("%s\n", buffer);
 return 0;
 }

这可能永远不会打印 hello,因为存在竞态条件。由于程序在第一个进程下以两种权限打开了管道,open 不会等待读者,因为程序告诉操作系统它是读者!有时它看起来像它工作了一样,因为代码的执行看起来像这样。

精细管道访问模式

进程 1 进程 2
时间 1 open(O_RDWR) & write()
时间 2 open(O_RDONLY) & read()
时间 3 close() & exit()
时间 4 print() & exit()

但这里有一系列无效的操作,会导致竞争条件。

管道竞争条件

进程 1 进程 2
时间 1 open(O_RDWR) & write()
时间 2 close() & exit()
时间 3 open(O_RDONLY) (无限期阻塞)

文件

在 Linux 中,有两个与文件相关的抽象。第一个是 Linux 级别的抽象。

  • 接受一个指向文件的路径并创建进程表中的文件描述符条目。如果文件不可访问,则出错。

  • 接收内核接收的一定数量的字节并将它们读入用户空间缓冲区。如果文件不是以读取模式打开,这将导致错误。

  • 将一定数量的字节输出到文件描述符。如果文件不是以写入模式打开,这将导致错误。这可能是在内部缓冲的。

  • 从进程的文件描述符中删除文件描述符。对于有效的文件描述符,这总是成功的。

  • 将文件描述符移动到某个位置。如果查找超出范围,则可能失败。

  • 是文件描述符的通用函数。设置文件锁、读取、写入、编辑权限等。

Linux 接口功能强大且表达性强,但有时我们需要可移植性,例如如果我们正在为 Macintosh 或 Windows 编写程序。这就是 C 的抽象发挥作用的地方。在不同的操作系统上,C 使用低级函数在所有地方使用的文件周围创建包装器,这意味着 Linux 上的 C 使用上述调用。

  • 打开一个文件并返回一个对象。如果没有权限访问文件,程序将返回。

  • 从文件中读取一定数量的字节。如果到达文件末尾时返回错误,程序必须调用以检查程序是否尝试读取超出文件末尾。

  • 从文件中获取字符或字符串

  • 从文件中读取格式化字符串

  • 将一些对象写入文件

  • 将格式化字符串写入文件

  • 关闭文件句柄

  • 取消任何缓冲更改并将它们刷新到文件

  • 如果你在文件末尾,则返回 true

  • 如果在读取、写入或查找时发生错误,则返回 true。

  • 设置缓冲(None、Line 或 Full)和用于缓冲的内存

但程序没有获得 Linux 系统调用提供的表达性。程序可以使用和来在它们之间来回转换。此外,C 文件是缓冲的,这意味着它们的内 容可能在调用返回后写入支持文件。您可以使用 C 选项来更改这一点。

确定文件长度

对于小于长文件大小的文件,使用 fseek 和 ftell 是一种简单的方法来完成这项任务。移动到文件末尾并找出当前位置。

 fseek(f, 0, SEEK_END);
 long pos = ftell(f);

这告诉我们文件中的当前位置(以字节为单位)——即文件的长度!

也可以用来设置绝对位置。

 fseek(f, 0, SEEK_SET); // Move to the start of the file
 fseek(f, posn, SEEK_SET); // Move to 'posn' in the file.

父进程或子进程中的所有后续读取和写入都将尊重这个位置。注意从文件写入或读取会改变当前位置。有关 fseek 和 ftell 的更多信息,请参阅 man 页面。

使用 stat 代替

这只在某些架构和编译器上有效。这个特性是长整型只需要4 字节大小,这意味着可以返回的最大大小略小于 2 吉字节。如今,我们的文件可能在大文件系统中达到数百吉字节甚至太字节。我们应该怎么做呢?使用!我们将在后面的部分介绍 stat,但这里有一些代码可以告诉程序文件的大小

 struct stat buf;
 if(stat(filename, &buf) == -1){
 return -1;
 }
 return (ssize_t)buf.st_size;

是类型,足够大以处理大文件。

文件使用时的注意事项

当两个不同的进程关闭文件流时会发生什么?关闭文件流是每个进程特有的。其他进程可以继续使用它们的文件句柄。记住,当创建子进程时,一切都会被复制,包括文件的相对位置。正如你可能在使用时观察到的,Ubuntu 上文件及其缓存实现的怪癖会在文件关闭后重置文件描述符。因此,确保在 fork 之前关闭,或者至少不要触发缓存不一致,这会难得多。

IPC 替代方案

好的,现在你的工具箱里有了处理进程间通信的工具列表,那么你应该使用哪个呢?

虽然这是一个最有趣的问题,但并没有明确的答案。通常,我们保留管道是为了遗留原因。这意味着我们只使用它们来重定向 stdin、stdout 和 stderr 以收集日志和类似程序。你也可能会发现进程试图与未命名或命名的管道进行通信。但大多数时候你不会直接处理这种交互。

几乎所有时候都使用文件作为 IPC 的一种形式。Hadoop 是一个很好的例子,其中进程将写入只追加表,然后其他进程将从中读取。我们通常在以下几种情况下使用文件。一种情况是我们想将操作的中间结果保存到文件中供将来使用。另一种情况是将它放入内存会导致内存不足错误。在 Linux 上,文件操作通常相当便宜,所以大多数程序员用它来存储较大的中间存储。

mmap 用于两种场景。一种是对文件的前向或后向读取。这意味着,程序从前到后或从后向前读取文件。关键是程序不要跳转太多。跳转太多会导致颠簸,并失去使用 mmap 的所有好处。mmap 的另一种用法是用于直接进程间内存通信。这意味着程序可以将结构存储在 mmap 内存片段中,并在两个进程之间共享它们。Python 和 Ruby 经常使用这种映射来利用写时复制语义。

主题

  1. 虚拟内存

  2. 页表

  3. MMU/TLB

  4. 地址转换

  5. 页面错误

  6. 帧页

  7. 单级与多级页表

  8. 计算多级页表的偏移量

  9. 管道

  10. 管道读写端

  11. 向零读者管道写入

  12. 从零写入管道读取

  13. 命名管道和无名管道

  14. 缓冲区大小/原子性

  15. 调度算法

  16. 效率度量

问题

  1. 什么是虚拟内存?

  2. 以下是什么,它们的目的又是什么?

    1. 翻译后备缓冲区

    2. 物理地址

    3. 内存管理单元。多级页表。帧号。页面号和页面偏移量。

    4. 污点位

    5. NX 位

  3. 什么是页面表?物理帧呢?页面是否总是需要指向物理帧?

  4. 什么是页面故障?有哪些类型?何时会导致 SEGFAULT?

  5. 单级页表的优势是什么?劣势?多级表呢?

  6. 多级表在内存中看起来是什么样子?

  7. 你如何确定页面偏移量使用了多少位?

  8. 给定一个 64 位地址空间,4kb 页面和帧,以及 3 级页表,虚拟页号 1,VPN2,VPN3 和偏移量各占多少位?

  9. 什么是管道?我们如何创建管道?

  10. 在什么情况下 SIGPIPE 会被发送给一个进程?

  11. 在什么条件下调用 pipe 上的 read()会阻塞?在什么条件下 read()会立即返回 0

  12. 命名管道和无名管道有什么区别?

  13. 管道是线程安全的吗?

  14. 编写一个函数,使用 fseek 和 ftell 替换文件中间的字符为‘X’

  15. 编写一个函数,创建一个管道并使用 write 向管道发送 5 个字节,“HELLO”。返回管道的读取文件描述符。

  16. 当你 mmap 一个文件时会发生什么?

  17. 为什么不建议使用 ftell 获取文件大小?应该怎样做?

调度

CPU 调度是高效选择在系统 CPU 核心上运行哪个进程的问题。在繁忙的系统上,就绪运行的进程数量将超过 CPU 核心的数量,因此系统内核必须评估哪些进程应该被调度运行,哪些进程应该稍后执行。系统还必须决定是否应该暂停特定进程的执行——以及任何相关的线程。平衡来自于在足够频繁地停止进程以保持计算机响应的同时,又足够不频繁地让程序本身在上下文切换上花费最少的时间。这是一个很难找到正确平衡的问题。

多线程和多个 CPU 核心的额外复杂性被视为对这种初始阐述的干扰,因此在这里被忽略。对于非母语者来说,另一个需要注意的问题是“时间”的双重含义:单词“时间”可以在时钟和经过的时间上下文中使用。例如,“第一个进程的到达时间是上午 9:00。”和“算法的运行时间是 3 秒”。

我们将进行的一个澄清是,我们的调度将主要处理短期或 CPU 调度。这意味着我们假设进程已经在内存中并且准备就绪。其他类型的调度是长期和中期。长期调度器充当处理世界的门卫。当一个进程请求执行另一个进程时,它可以告诉进程是、否或等待。中期调度器处理在进程太多或某些进程已知使用很少的 CPU 周期时,将进程从内存中的暂停状态移动到磁盘上的暂停状态的问题。考虑一个每小时只检查一次的进程。

高级调度器概述

调度器是软件程序的一部分。实际上,你可以自己实现调度器!如果你被给了一个要执行的命令列表,程序可以使用 SIGSTOP 和 SIGCONT 来调度它们。这些被称为用户空间调度器。Hadoop 和 Python 的 celery 可能进行某种用户空间调度或处理操作系统。

在操作系统级别,你通常会有这种流程图,下面首先用文字描述。请注意,请不要记住所有状态。

  1. 新状态是初始状态。进程已被请求进行调度。所有进程请求都来自 fork 或 clone。此时,操作系统知道它需要创建一个新的进程。

  2. 一个进程从新状态移动到就绪状态。这意味着内核中的任何结构体都被分配了。从那里,它可以进入就绪挂起或运行状态。

  3. 运行是我们希望大多数进程所处的状态,意味着它们正在进行有用的工作。进程可能会被抢占、阻塞或终止。抢占将进程返回到就绪状态。如果进程被阻塞,这意味着它可能正在等待互斥锁,或者它可能调用了 sleep – 无论哪种方式,它都自愿放弃了控制权。

  4. 在阻塞状态下,操作系统可以将进程设置为就绪状态,或者它可以进入一个称为阻塞挂起的更深的状态。

  5. 存在所谓的深度睡眠状态,称为阻塞挂起和阻塞就绪。您不需要担心这些。

我们将尝试选择一个方案,该方案决定何时将进程移动到运行状态,何时将其移回就绪状态。我们不会过多地提及如何考虑自愿阻塞状态以及何时切换到深度睡眠状态。

测量

调度会影响系统的性能,特别是系统的延迟吞吐量。吞吐量可能通过系统值来衡量,例如,I/O 吞吐量 - 每秒写入的位数,或者单位时间内可以完成的进程数量。延迟可能通过响应时间来衡量 – 进程可以开始发送响应之前的时间流逝,或者等待时间或周转时间 – 完成任务的流逝时间。不同的调度器提供不同的优化权衡,这些权衡可能适合预期的使用。没有适用于所有可能环境和目标的最佳调度器。例如,最短作业优先将最小化所有作业的总等待时间,但在交互式(UI)环境中,可能会以牺牲一些吞吐量为代价来最小化响应时间,而先来先服务(FCFS)看起来直观公平且易于实现,但受到车队效应的影响。到达时间是指进程首次到达就绪队列的时间,并准备好开始执行。如果 CPU 空闲,到达时间也将是执行的开始时间。

什么是抢占?

没有抢占的情况下,进程将一直运行,直到它们无法进一步利用 CPU。例如,以下条件将使进程从 CPU 中移除,CPU 将可用于为其他进程进行调度。进程由于信号而终止,被阻塞等待并发原语,或正常退出。因此,一旦进程被调度,即使就绪队列中出现具有高优先级的其他进程,它也将继续执行。

在抢占的情况下,如果将更优先的进程添加到就绪队列,现有的进程可以立即被移除。例如,假设在 t=0 时,使用最短作业优先调度程序有两个进程(P1 P2),它们的执行时间分别为 10 毫秒和 20 毫秒。P1 被调度。P1 立即创建了一个新的进程 P3,其执行时间为 5 毫秒,该进程被添加到就绪队列。如果没有抢占,P3 将在 10 毫秒后(P1 完成后)运行。如果有抢占,P1 将立即从 CPU 中移除,并重新放入就绪队列,然后 CPU 将执行 P3。

任何不使用某种形式的抢占的调度程序都可能因饥饿而受到影响,因为较早的进程可能永远不会被调度运行(分配 CPU)。例如,在 SJF(最短作业优先)中,如果系统继续有大量的短作业要调度,较长的作业可能永远不会被调度。这完全取决于调度程序的类型

为什么一个进程(或线程)可能会被放置在就绪队列上?

当一个进程可以使用 CPU 时,它会被放入就绪队列。一些例子包括:

  • 一个进程在等待从存储或套接字完成时被阻塞,现在数据已可用。

  • 已创建了一个新进程,并准备开始运行。

  • 一个进程线程在同步原语(条件变量、信号量、互斥锁)上被阻塞,但现在能够继续。

  • 一个进程在等待系统调用完成时被阻塞,但已发送了一个信号,信号处理程序需要运行。

效率指标

首先,一些定义

  1. 是进程的墙钟开始时间(CPU 开始工作)

  2. 是进程的结束墙钟时间(CPU 完成进程)

  3. 是完成进程所需的 CPU 时间总和

  4. 是进程进入调度程序的时间(CPU 可能开始工作)

这里是效率指标及其数学方程

  1. 是从进程到达到它结束的总时间。

  2. 是从进程到达到 CPU 实际开始工作的总延迟(时间)。

  3. 是总等待时间或进程在就绪队列上的总时间。一个常见的错误是认为它只是就绪队列中的初始等待时间。如果一个 CPU 密集型进程没有 I/O 操作,需要 7 分钟的 CPU 时间来完成,但需要 9 分钟的墙钟时间来完成,我们可以得出结论,它被放置在就绪队列上 2 分钟。在这 2 分钟内,进程准备运行但没有分配到 CPU。等待的时间不取决于作业何时等待,等待时间是 2 分钟。

队列效应

队列效应是指一个进程占据了大量的 CPU 时间,导致所有其他具有潜在较小资源需求的进程像一支车队一样跟在其后。

假设 CPU 当前分配给了一个 CPU 密集型任务,并且有一组 I/O 密集型进程在就绪队列中。这些进程需要很少的 CPU 时间,但它们无法继续执行,因为它们正在等待 CPU 密集型任务从处理器中移除。这些进程会一直饿着,直到 CPU 密集型进程释放 CPU。但是,CPU 很少会释放。例如,在 FCFS 调度程序的情况下,我们必须等待进程由于 I/O 请求而被阻塞。现在,I/O 密集型进程终于可以满足它们的 CPU 需求,因为它们的 CPU 需求很小,CPU 又被分配回 CPU 密集型进程。因此,整个系统的 I/O 性能通过所有进程 CPU 需求的间接饥饿效应而受到影响。

这种效应通常在 FCFS 调度程序的情况下讨论;然而,轮转调度程序也可以在长时间量下表现出车队效应。

调度算法

除非另有说明

  1. 进程 1:运行时间 1000ms

  2. 进程 2:运行时间 2000ms

  3. 进程 3:运行时间 3000ms

  4. 进程 4:运行时间 4000ms

  5. 进程 5:运行时间 5000ms

最短作业优先(SJF)

最短作业优先调度

最短作业优先调度

  • P1 到达:0ms

  • P2 到达:0ms

  • P3 到达:0ms

  • P4 到达:0ms

  • P5 到达:0ms

进程都在开始时到达,调度程序按照最短总 CPU 时间调度作业。明显的问题是,这个调度程序需要在运行程序之前知道这个程序将运行多长时间。

技术说明:一个现实的 SJF 实现不会使用进程的总执行时间,而是使用完成程序所需的爆发时间或 CPU 周期数。可以通过使用基于先前爆发时间的指数衰减加权滚动平均来估计预期的爆发时间(Silberschatz,Galvin 和 Gagne 2005 第六章)。为了本讨论,我们将简化这个讨论,使用进程的总运行时间作为爆发时间的代理。

优点

  1. 较短的作业往往先运行

  2. 平均等待时间和响应时间都下降了

缺点

  1. 需要算法具有全知全能

  2. 需要估计进程的突发性,这比比如说计算机网络更难

预抢占最短作业优先(PSJF)

预抢占最短作业优先类似于最短作业优先,但如果有一个新的作业进来,其运行时间比当前作业的总运行时间短,则运行该作业。如果它们相等,就像我们的例子一样,我们的算法可以选择。调度程序使用进程的运行时间。如果调度程序想要比较最短剩余时间,那么这就是 PSJF 的一个变体,称为最短剩余时间优先(SRTF)。

预抢占最短作业优先调度

预先抢占最短作业优先调度

  • P2 在 0ms

  • P1 在 1000ms

  • P5 在 3000ms

  • P4 在 4000ms

  • P3 在 5000ms

这里是我们的算法所做的工作。它运行 P2,因为它是唯一可以运行的任务。然后 P1 在 1000ms 到达,P2 运行 2000ms,因此我们的调度器会抢占 P2,让 P1 完全运行。这完全取决于算法,因为时间相同。然后,P5 到达——由于没有正在运行的进程,调度器将运行进程 5。P4 到达,由于运行时间相同,调度器停止 P5 并运行 P4。最后,P3 到达,抢占 P4,并运行完成。然后 P4 运行,然后 P5 运行。

优点

  1. 确保较短的作业先运行

缺点

  1. 需要再次知道运行时间

  2. 上下文切换和作业可能会被中断

先到先服务(First Come First Served, FCFS)

先到先服务调度

先到先服务调度

  • P2 在 0ms

  • P1 在 1000ms

  • P5 在 3000ms

  • P4 在 4000ms

  • P3 在 5000ms

进程按照到达顺序进行调度。FCFS 的一个优点是调度算法简单。就绪队列是一个先进先出(FIFO)队列。FCFS 存在车队效应。这里 P2 到达,然后 P1 到达,然后 P5,然后 P4,然后 P3。你可以看到 P5 的车队效应。

优点

  • 简单的算法和实现

  • 当存在长时间运行的进程时,上下文切换不频繁

  • 如果所有进程都保证能够终止,则不会发生饥饿

缺点

  • 简单的算法和实现

  • 当存在长时间运行的进程时,上下文切换不频繁

轮询(Round Robin, RR)

进程按照它们在就绪队列中的到达顺序进行调度。然而,经过一小段时间步长后,一个正在运行的进程将被强制从运行状态移除并放回就绪队列。这确保了长时间运行的进程不会使其他所有进程无法运行。一个进程在返回就绪队列之前可以执行的最大时间量称为时间量子。当时间量子接近无穷大时,轮询将等同于先到先服务(FCFS)。

轮询调度

轮询调度

  • P1 到达时间:0ms

  • P2 到达时间:0ms

  • P3 到达时间:0ms

  • P4 到达时间:0ms

  • P5 到达时间:0ms

时间量子 = 1000ms

在这里,所有进程同时到达。P1 运行一个时间量子并完成。P2 运行一个时间量子;然后,它被停止以运行 P3。在所有其他进程运行一个时间量子后,我们回到 P2,直到所有进程都完成。

优点

  1. 确保某种公平性概念

缺点

  1. 进程数量多 = 切换次数多

优先级

进程按照优先级值进行调度。例如,导航进程可能比日志进程更重要。

如果您需要一种数学方法来比较调度算法,请参阅附录和概念性调度部分

主题

  • 调度算法

  • 效率度量

问题

  • 什么是调度?

  • 什么是排队?有哪些不同的排队方法?

  • 什么是周转时间?响应时间?等待时间?

  • 什么是编队效应?

  • 哪些算法的平均周转时间/响应时间/等待时间表现最好?

  • 与非抢占式算法相比,抢占式算法在平均响应时间上表现更好吗?周转时间/等待时间又如何?

网络

在过去 10-20 年中,网络已经成为计算机最重要的用途之一。如今,我们大多数人无法忍受没有 WiFi 或任何连接的地方,因此作为程序员,了解网络和如何在网络上进行编程通信至关重要。尽管这可能听起来很复杂,但 POSIX 已经定义了很好的标准,使得连接到外部世界变得容易。POSIX 还允许你深入了解底层,并优化每个连接的各个小部分,以编写高性能的程序。

作为下一章中你将了解更多内容的补充,我们将对尺寸的表示法进行严格规定。这意味着当我们提到千(Kilo-)、兆(Mega-)等国际单位制(SI)前缀时,我们始终指的是 10 的幂。一个千字节等于一千字节,一个兆字节等于一千千字节,依此类推。如果我们需要提到字节,我们将使用更准确的术语 Kibibyte。Mibibyte 和 Gibibyte 分别是 Megabyte 和 Gigabyte 的类似物。我们做出这种区分是为了确保我们不会差 24。这种误称的原因将在文件系统章节中解释。

OSI 模型

开源互连 7 层模型(OSI 模型)是一系列定义了基础设施和协议标准的段,用于无线电通信的形式,在我们的案例中是互联网。7 层模型如下

  1. 第 1 层:物理层。这些是实际携带波特率穿越导线的波形。顺便说一句,比特不会穿越导线,因为在大多数介质中,你可以改变一个波形的两个特性——振幅和频率——并在每个时钟周期中传输更多的比特。

  2. 第 2 层:链路层。这是每个代理对某些事件(错误检测、嘈杂的信道等)的反应方式。这是以太网和 WiFi 所在的地方。

  3. 第 3 层:网络层。这是互联网的核心。底下的两个协议处理直接连接的两个不同计算机之间的通信。这一层负责将数据包从一端路由到另一端。

  4. 第 4 层:传输层。这一层指定了数据切片的接收方式。底下的三层不保证数据包接收的顺序,也不保证数据包丢失时会发生什么。通过使用不同的协议,这一层可以做到。

  5. 第 5 层:会话层。这一层确保如果上一层的连接丢失,下一层可以建立新的连接,并且对最终用户来说,看起来就像什么都没发生一样。

  6. 第 6 层:表示层。这一层处理加密、压缩和数据转换。例如,在不同操作系统之间的可移植性,如将换行符转换为 Windows 换行符。

  7. 第 7 层:应用层。HTTP 和 FTP 都定义在这一层。这通常是我们在整个互联网上定义协议的地方。作为程序员,我们只有在认为可以创建比下面所有层次都更适合我们需求的算法时,才会向下层移动。

这本书不会深入探讨网络技术。我们将重点关注第 3 层、第 4 层和第 7 层的一些方面,因为这些对于你打算与互联网打交道的人来说是至关重要的,在职业生涯的某个阶段你肯定会这样做。至于另一个定义,协议是一套由互联网工程任务组提出的规范,它规定了协议实现者如何在特定情况下使他们的程序或电路行为。

第 3 层:互联网协议

以下是对互联网协议(IP)的简要介绍,这是将信息数据报从一台机器发送到另一台机器的主要方式。“IP4”,或者更确切地说,IPv4 是互联网协议的第 4 版本,它描述了如何在网络中从一个机器发送信息数据包到另一个机器。即使到了 2018 年,IPv4 仍然主导着互联网流量,但谷歌报告称,现在有 24 个国家通过 IPv6 提供了他们 15%的流量(“2018 年 IPv6 部署状态” 2018)。IPv4 的一个显著限制是源地址和目的地址限制在 32 位。IPv4 是在一个认为有 40 亿台设备连接到同一网络的想法是不可想象或至少不值得增加数据包大小的时代设计的。IPv4 地址通常以四个八位字节序列的形式书写,由点分隔,例如“255.255.255.0”。

每个 IPv4 数据报都包含一个小的头部——通常是 20 个八位字节,它包括源地址和目的地址。从概念上讲,源地址和目的地址可以分为两部分:网络号,高位的位和低位位代表该网络上的特定主机号。

一个较新的数据包协议 IPv6 解决了 IPv4 的许多限制,如使路由表更简单和 128 位地址。然而,根据 2018 年的比较,很少有网络流量是基于 IPv6 的(“2018 年 IPv6 部署状态” 2018)。我们以八个四进制分隔符的序列书写 IPv6 地址,例如“1F45:0000:0000:0000:0000:0000:0000:0000”。由于这可能会变得难以管理,我们可以省略零“1F45::”。一台机器可以有一个 IPv6 地址和一个 IPv4 地址。

存在一些特殊的 IP 地址。IPv4 中的一个例子是,IPv6 作为或也称为 localhost。发送到 127.0.0.1 的数据包永远不会离开该机器;该地址指定为同一台机器。还有很多其他地址,由某些八位字节为零或 255(最大值)表示。您不需要知道所有术语,请记住,一台机器在全球互联网上可以拥有的实际 IP 地址数量小于“原始”地址的数量。本书涵盖了 IP 如何处理路由、分片和重新组装上层协议。以下是一个更深入的说明。

IPv6 是怎么回事?

IPv6 数据包可分性

IPv6 数据包可分性

IPv6 的一个大特性是地址空间。世界在很久以前就耗尽了 IP 地址,并一直在使用黑客手段来解决这个问题。有了 IPv6,就有足够的内部和外部地址,即使我们发现外星文明,我们可能也不会耗尽。另一个好处是,这些地址是租用的而不是购买的,这意味着如果互联网物联网发生重大事件,需要更改块地址方案,这是可以做到的。

另一个重要特性是通过 IPsec 实现的安全性。IPv4 在设计时几乎没有考虑安全性。因此,现在在高层有类似于 TLS 的关键交换,允许您加密通信。

另一个特性是简化处理。为了使互联网更快,IPv4 和 IPv6 头部在硬件中进行了验证。这意味着所有头部选项都按顺序在电路中处理。问题是,随着 IPv4 规范的增长,包括大量头部,硬件不得不变得越来越先进以支持这些头部。IPv6 重新排序头部,以便在更少的硬件周期内丢弃和路由数据包。在互联网的情况下,每个周期在尝试路由全球流量时都很重要。

我的地址是什么?

要获取当前机器的 IP 地址链表,请使用,它将返回包括其他接口在内的 IPv4 和 IPv6 IP 地址链表。我们可以检查每个条目并使用来打印主机的 IP 地址。该结构包括家族,但不包括结构的 sizeof。因此,我们需要根据家族手动确定结构的大小。

(family == AF_INET) ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6)

完整的代码如下所示。

int required_family = AF_INET; // Change to AF_INET6 for IPv6
struct ifaddrs *myaddrs, *ifa;
getifaddrs(&myaddrs);
char host[256], port[256];

for (ifa = myaddrs; ifa != NULL; ifa = ifa->ifa_next) {
 int family = ifa->ifa_addr->sa_family;
 if (family == required_family && ifa->ifa_addr) {
 int ret = getnameinfo(ifa->ifa_addr,
 (family == AF_INET) ? sizeof(struct sockaddr_in) :
 sizeof(struct sockaddr_in6),
 host, sizeof(host), port, sizeof(port)
 , NI_NUMERICHOST | NI_NUMERICSERV)
 if (0 == ret) {
 puts(host);
 }
 }
}

要从命令行获取您的 IP 地址,请使用 Windows 的。

然而,此命令为每个接口生成大量输出,因此我们可以使用 grep 过滤输出。

ifconfig | grep inet

Example output:
    inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
    inet 127.0.0.1 netmask 0xff000000
    inet6 ::1 prefixlen 128
    inet6 fe80::7256:81ff:fe9a:9141%en1 prefixlen 64 scopeid 0x5
    inet 192.168.1.100 netmask 0xffffff00 broadcast 192.168.1.255

要获取远程网站的 IP 地址,该函数可以将可读的域名(例如)转换为 IPv4 和 IPv6 地址。它将返回一个 addrinfo 结构体的链表:

struct addrinfo {
 int              ai_flags;
 int              ai_family;
 int              ai_socktype;
 int              ai_protocol;
 socklen_t        ai_addrlen;
 struct sockaddr *ai_addr;
 char            *ai_canonname;
 struct addrinfo *ai_next;
};

例如,假设你想找出位于的 Web 服务器的数值 IPv4 地址。我们分两个阶段来做这件事。首先,使用 getaddrinfo 构建可能的连接的链表。其次,使用将其中的一个二进制地址转换为可读形式。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

struct addrinfo hints, *infoptr; // So no need to use memset global variables

int main() {
 hints.ai_family = AF_INET; // AF_INET means IPv4 only addresses

 // Get the machine addresses
 int result = getaddrinfo("www.bbc.com", NULL, &hints, &infoptr);
 if (result) {
 fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(result));
 exit(1);
 }

 struct addrinfo *p;
 char host[256];

 for(p = infoptr; p != NULL; p = p->ai_next) {
 // Get the name for all returned addresses
 getnameinfo(p->ai_addr, p->ai_addrlen, host, sizeof(host), NULL, 0, NI_NUMERICHOST);
 puts(host);
 }

 freeaddrinfo(infoptr);
 return 0;
}

可能的输出。

212.58.244.70
212.58.244.71

可以使用.指定 IPv4 或 IPv6。只需在上面的代码中将 ai_family 属性替换为以下内容。

hints.ai_family = AF_UNSPEC

如果你在想计算机是如何将主机名映射到地址的,我们将在第 7 层讨论这个问题。剧透一下:这是一个名为 DNS 的服务。在我们进入下一节之前,重要的是要注意一个网站可以有多个 IP 地址。这可能是为了提高机器的效率。如果谷歌或 Facebook 只有一个服务器将它们的所有入站请求路由到其他计算机,他们可能不得不在那个计算机或数据中心上花费巨额资金。相反,他们可以为不同的地区分配不同的 IP 地址,并让计算机选择。通过非首选 IP 地址访问网站并不糟糕。页面可能会加载得更慢。

第 4 层:TCP 和客户端

额外:TCP 头部规范

额外:TCP 头部规范

今天的互联网上大多数服务都使用 TCP,因为它有效地隐藏了互联网底层、数据包级别的复杂性。TCP 或传输控制协议是一个基于连接的协议,它建立在 IPv4 和 IPv6 之上,因此可以描述为“TCP/IP”或“IP 上的 TCP”。TCP 在两台机器之间创建了一个管道,并抽象化了互联网的低级数据包特性。因此,在大多数情况下,通过 TCP 连接发送的字节被成功交付且未损坏。高性能和易出错的代码甚至不会假设这一点!

TCP 有许多特性使其与其他传输协议 UDP 区分开来。

  1. 端口与 IP 地址一起,你只能向一台机器发送数据包。如果你想一台机器处理多个数据流,你必须手动使用 IP 地址来做。TCP 给程序员提供了一套虚拟套接字。客户端指定要发送数据包的套接字,TCP 协议确保等待在该端口上接收数据包的应用程序收到数据。一个进程可以在特定端口上监听传入的数据包。然而,只有具有超级用户(root)访问权限的进程才能监听小于 1024 的端口。任何进程都可以监听 1024 或更高的端口。一个常用的端口是 80 号。它用于未加密的 HTTP 请求或网页。例如,如果网页浏览器连接到,它将连接到 80 端口。

  2. 重传数据包可能会因为网络错误或拥塞而丢失。因此,它们需要被重新传输。同时,重传不应该导致更多数据包丢失。这需要在网络拥塞和速度之间进行权衡。

  3. 乱序数据包。由于 IP 中的各种原因,数据包可能会被更优地路由。如果一个数据包在另一个数据包之前到达,协议应该检测并重新排序它们。

  4. 重复数据包。数据包可能会到达两次。因此,协议需要能够区分在溢出条件下具有序列号的两个数据包。

  5. 错误纠正。TCP 有一个校验和来处理位错误。但这很少被使用。

  6. 流量控制。流量控制是在接收方执行的。这可能是为了防止一个慢速接收方被数据包淹没。处理 10000 或 1000 万个并发连接的服务器可能需要告诉接收方减速,但保持连接,因为负载。还有确保本地网络流量稳定的问题。

  7. 阻塞控制。阻塞控制是在发送方执行的。阻塞控制是为了防止发送方将太多数据包发送到网络中。这很重要,以确保每个 TCP 连接都得到公平对待。这意味着离开计算机连接到谷歌和 YouTube 的两个连接将获得相同的带宽和 ping。可以很容易地定义一个协议,它将占用所有带宽,而将其他协议抛在后面,但这往往是有害的,因为很多时候将计算机限制为单个 TCP 连接会产生相同的结果。

  8. 面向连接/生命周期导向。你可以想象 TCP 连接是一系列通过管道发送的字节。尽管 TCP 连接有一个“生命周期”。TCP 通过 SYN SYN-ACK ACK 来处理建立连接。这意味着客户端将发送一个同步包,告诉 TCP 从哪个起始序列号开始。然后接收方将发送一个确认同步号的 SYN-ACK 消息。然后客户端将通过最后一个数据包确认这一点。现在连接两端都可以进行读写。TCP 将发送数据,接收数据的一方将确认已收到数据包。如果一段时间内没有发送数据包,TCP 将通过交换零长度数据包来确保连接仍然活跃。在任何时候,客户端和服务器都可以发送一个 FIN 包,这意味着服务器将不会传输。这个包可以通过位来修改,只关闭特定连接的读或写端。当所有端点都关闭时,连接结束。

TCP 虽然不提供很多功能。

  1. 安全性。连接到一个声称是特定网站的 IP 地址并不验证该声明(如 TLS)。你可能会向恶意计算机发送数据包。

  2. 加密。任何人都可以监听明文 TCP。传输中的数据包是明文。像密码这样重要的事情很容易被旁观者窃取。

  3. 会话重连。如果 TCP 连接中断,则必须创建一个新的整个连接,并且传输必须从头开始。这是由一个更高层次的协议处理的。

  4. 请求的分隔。TCP 是自然面向连接的。在 TCP 上通信的应用程序需要找到一种独特的方式来告诉对方这个请求或响应已经结束。HTTP 通过两个回车符分隔标题,并使用长度字段或一直监听直到连接关闭。

关于网络顺序的说明

整数可以以最低有效字节优先或最高有效字节优先的方式表示。只要机器本身在内部是一致的,这两种方法都是合理的。对于网络通信,我们需要在约定的格式上达成一致。

返回网络字节顺序的 16 位无符号整数‘short’值 xyz。返回网络字节顺序的 32 位无符号整数‘long’值 xyz。任何更长的整数都需要计算机指定顺序。

这些函数被读取为“主机到网络”。逆函数(, )将网络顺序的字节值转换为主机顺序。那么,主机顺序是小端序还是大端序?答案是——这取决于你的机器!它取决于运行代码的主机的实际架构。如果架构碰巧与网络顺序相同,则函数返回相同的整数。对于 x86 机器,主机和网络顺序是不同的。

除非另有约定,每次读取或写入低级 C 网络结构,即端口号和地址信息时,请记住使用上述函数以确保正确转换为/从机器格式。否则,显示或指定的值可能是不正确的。

这一点不适用于在事先协商字节序的协议。如果两台计算机在转换网络顺序的消息时受到 CPU 的限制——这在高性能系统中的 RPC 中很常见——那么如果它们具有相似的端序,协商以发送小端序可能是有价值的。

为什么网络顺序被定义为大端序?简单的答案是 RFC1700 这样规定(Reynolds 和 Postel 1994)。如果你想了解更多信息,我们将引用那篇著名的文章,该文章为特定版本进行了辩护(Cohen 1980)。最重要的是,它是标准的。当我们没有标准时会发生什么?我们有 4 种不同的 USB 插头类型(常规、Micro、Mini 和 USB-C),它们之间相互作用不佳。在此处包含相关的 XKCD 标准

TCP 客户端

有三个基本的系统调用用于连接到远程机器。

  1. 如果调用成功,它将创建一个结构体的链表,并将给定的指针设置为指向第一个。

    此外,您还可以使用 hints 结构来仅获取某些条目,如特定的 IP 协议等。addrinfo 结构被传递进去以定义您想要的连接类型。例如,要指定基于流的 IPv6 协议,可以使用以下代码片段。

    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    
    hints.ai_family = AF_INET6; // Only want IPv6 (use AF_INET for IPv4)
    hints.ai_socktype = SOCK_STREAM; // Only want stream-based connection
    

    “家族”的其他模式是和,分别表示 IPv4 和未指定。如果你正在寻找一个你不确定 IP 版本的服务,这可能很有用。自然地,如果你指定了 UNSPEC,你会在字段中获取版本。

    使用的错误处理略有不同。返回值就是错误代码。为了将其转换为可读的错误,请使用以获取等效的简短英文错误文本。

    int result = getaddrinfo(...);
    if(result) {
     const char *mesg = gai_strerror(result);
     ...
    }
    
  2. 套接字调用创建一个网络套接字并返回一个可以用于和的描述符。在这种情况下,它是打开文件流的网络类似物——只不过我们还没有将套接字连接到任何东西!

    套接字的创建使用一个域,对于或,是是否使用 UDP、TCP 或其他某些其他套接字类型,是针对我们的示例的协议配置的可选选择。在这个例子中,我们可以将其保留为 0 作为默认值。这个调用在内核中创建一个套接字对象,可以通过它与外部世界/网络进行通信。你可以使用的结果来填写参数,或者手动提供它们。

    套接字调用返回一个整数——一个文件描述符——对于 TCP 客户端,你可以将其用作常规文件描述符。你可以使用和来接收或发送数据包。

    TCP 套接字类似于,常用于需要 IPC 的情况。我们之前没有提到它,因为使用适合网络的设备在单个线程上的进程之间进行通信是过度杀鸡。

  3. 最后,connect 调用尝试连接到远程机器。我们传递原始套接字描述符以及存储在 addrinfo 结构体内的套接字地址信息。存在不同类型的套接字地址结构,可能需要更多的内存。因此,除了传递指针外,还需要传递结构的大小。为了帮助识别错误和错误,检查所有网络调用的返回值是一个好习惯,包括

    // Pull out the socket address info from the addrinfo struct:
    connect(sockfd, p->ai_addr, p->ai_addrlen)
    
  4. (可选)为了清理代码,在第一级结构上调用。

已有一个旧函数已被弃用。这是将主机名转换为 IP 地址的旧方法。端口号仍然需要使用函数手动设置。使用较新的方法来支持 IPv4 和 IPv6 要简单得多。

这就是创建一个简单的 TCP 客户端所需的所有内容。然而,网络通信提供了许多不同级别的抽象以及可以在每个级别设置的多个属性和选项。例如,我们还没有讨论过如何操作套接字选项。你还可以与较低的协议进行交互,因为内核提供了有助于这一点的原语。请注意,你需要以 root 权限创建原始套接字。此外,你需要有大量的“设置”或启动代码,准备好你的数据报可能因为格式错误而被丢弃。有关更多信息,请参阅此指南

发送一些数据

一旦我们建立了成功的连接,我们就可以像任何旧的文件描述符一样读取或写入。记住,如果您连接到网站,您希望遵守 HTTP 协议规范以获得任何有意义的结果。有库可以做到这一点。通常,您不会在套接字级别连接。读取或写入的字节数可能小于预期。因此,检查和的返回值非常重要。下面是一个简单的 HTTP 客户端,它向符合 URL 的 URL 发送请求。首先,我们将从无聊的部分和解析代码开始。

typedef struct _host_info {
 char *hostname;
 char *port;
 char *resource;
} host_info;

host_info *get_info(char *uri) {
 // ... Parses the URI/URL
}

void free_info(host_info *info) {
 // ... Frees any info
}

int main(int argc, char *argv[]) {
 if(argc != 2) {
 fprintf(stderr, "Usage: %s http://hostname[:port]/path\n", *argv);
 return 1;
 }
 char *uri = argv[1];
 host_info *info = get_info(uri);
 host_info *temp = send_request(info);

 return 0;
}

发送请求的代码如下。我们首先必须做的事情是连接到一个地址。

struct addrinfo current, *result;
memset(&current, 0, sizeof(struct addrinfo));
current.ai_family = AF_INET;
current.ai_socktype = SOCK_STREAM;

getaddrinfo(info->hostname, info->port, &current, &result);

connect(sock_fd, result->ai_addr, result->ai_addrlen)

freeaddrinfo(result);

下一段代码发送请求。以下是每个头部的含义。

  1. "GET %s HTTP/1.0" 这是指请求动词与路径的插值。这意味着使用 HTTP/1.0 方法在路径上执行 GET 动词。

  2. "Connection: close" 表示一旦请求完成,请关闭连接。此行不会用于任何其他连接。考虑到 HTTP 1.0 不允许您发送多个请求,这有点多余,但鉴于存在不兼容的技术,最好是明确指出。

  3. "Accept: /" 这意味着客户端愿意接受任何内容。

更健壮的代码还会检查写入是否失败或调用是否被中断。

char *buffer;
asprintf(&buffer,
 "GET %s HTTP/1.0\r\n"
 "Connection: close\r\n"
 "Accept: */*\r\n\r\n",
 info->resource);

write(sock_fd, buffer, strlen(buffer));
free(buffer);

最后一段代码是发送请求的驱动代码。如果您想方便地使用以下代码将文件描述符作为文件对象打开,请随意使用。但请注意,不要忘记将缓冲区设置为零,否则您可能会双重缓冲输入,这会导致性能问题。

void send_request(host_info *info) {
 int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
 // Re-use address is a little overkill here because we are making a
 // Listen only server and we don't expect spoofed requests.
 int optval = 1;
 int retval = setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &optval,
 sizeof(optval));
 if(retval == -1) {
 perror("setsockopt");
 exit(1);
 }
 // Connect using code snippet

 // Send the get request

 // Open so you can use getline
 FILE *sock_file = fdopen(sock_fd, "r+");
 setvbuf(sock_file, NULL, _IONBF, 0);

 ret = handle_okay(sock_file);
 fclose(sock_file);
 close(sock_fd);
}

上面的示例演示了使用超文本传输协议向服务器发送请求。一般来说,有六个部分

  1. 方法。GET、POST 等。

  2. 资源。“/” “/index.html” “/image.png”

  3. 协议“HTTP/1.0”

  4. 一个新行()。请求总是有回车符。

  5. 任何其他旋钮或开关参数

  6. 请求的实际主体由两个新行分隔。请求的主体要么是如果指定了大小,要么是直到接收方关闭他们的连接。

服务器第一次响应行描述了使用的 HTTP 版本以及请求是否成功,使用三位响应代码。

HTTP/1.1 200 OK

如果客户端请求了一个不存在的路径,例如,那么第一行包含的响应代码是众所周知的响应代码。

HTTP/1.1 404 Not Found

更多信息,RFC 7231 提供了今天最常见 HTTP 方法的最新规范(Fielding 和 Reschke 2014)。

第 4 层:TCP 服务器

创建最小 TCP 服务器所需的四个系统调用是、、、和。每个都有特定的目的,并且应该按照上述顺序大致调用

  1. 要创建网络通信的端点。一个新的套接字本身存储字节。尽管我们指定了基于数据包或流连接,但它未绑定到特定的网络接口或端口。相反,套接字返回一个网络描述符,可以用于后续的 bind、listen 和 accept 调用。

    作为一个问题,这些套接字必须声明为被动。被动服务器套接字等待另一个主机连接。相反,它们等待传入连接。此外,当对等方断开连接时,服务器套接字保持打开。相反,客户端通过服务器上的一个特定于该连接的单独活动套接字进行通信。

    由于 TCP 连接由发送者的地址和端口以及接收者的地址和端口定义,因此特定的服务器端口可以有一个被动服务器套接字,但可以有多个活动套接字。每个当前打开的连接都有一个。服务器的操作系统维护一个查找表,将唯一的元组与活动套接字关联起来,以便将传入的数据包正确路由到正确的套接字。

  2. 该调用将一个抽象套接字与一个实际的网络接口和端口关联起来。可以在 TCP 客户端上调用 bind。bind 使用的端口号可以手动设置(许多较老的仅支持 IPv4 的 C 代码示例就是这样做的),或者使用 来创建。

    默认情况下,当服务器套接字关闭后,端口会在一段时间后释放。相反,端口进入“TIMED-WAIT”状态。这可能导致在开发过程中出现重大混淆,因为超时可能会使有效的网络代码看起来像失败了。

    要能够立即重用端口,请在绑定端口之前指定。

    int optval = 1;
    setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
    
    bind(...);
    

    这里有一个关于socket 选项 so-reuseaddr 和 so-reuseport 的扩展 stackoverflow 入门讨论

  3. 该调用指定了未处理传入连接的队列大小。这是由 没有分配给文件描述符的连接。对于高性能服务器,典型值是 128 或更多。

  4. 一旦服务器套接字初始化完成,服务器就会调用等待新的连接。与 和 不同,这个调用将会阻塞,除非设置了非阻塞选项。如果没有新的连接,这个调用将会阻塞,并且只有在新的客户端连接时才会返回。返回的 TCP 套接字与一个特定的元组相关联,并将用于所有未来匹配此元组的传入和传出 TCP 数据包。

    注意,该调用返回一个新的文件描述符。这个文件描述符特定于某个客户端。使用原始服务器套接字描述符进行服务器 I/O 是一种常见的编程错误,然后你会 wonder 为什么网络代码失败了。

    系统调用可以通过传递 sockaddr 结构体来提供有关远程客户端的信息。不同的协议有不同的,它们的大小不同。最简单的结构体是,它足够大,可以表示所有可能的类型。请注意,C 语言没有继承模型。因此,我们需要显式地将我们的结构体转换为基类型结构体 sockaddr。

    struct sockaddr_storage clientaddr;
    socklen_t clientaddrsize = sizeof(clientaddr);
    int client_id = accept(passive_socket,
     (struct sockaddr *) &clientaddr,
     &clientaddrsize);
    

    我们已经看到可以构建一个 addrinfo 条目链表,并且这些条目中的每一个都可以包含套接字配置数据。如果我们想将套接字数据转换为 IP 和端口号呢?可以使用该工具将本地或远程套接字信息转换为域名或数字 IP。同样,端口号也可以表示为服务名。例如,端口号 80 通常用作接收 HTTP 请求的入站连接端口。在下面的示例中,我们请求客户端 IP 地址和客户端端口号的数字版本。

     socklen_t clientaddrsize = sizeof(clientaddr);
     int client_id = accept(sock_id, (struct sockaddr *) &clientaddr, &clientaddrsize);
     char host[NI_MAXHOST], port[NI_MAXSERV];
     getnameinfo((struct sockaddr *) &clientaddr,
     clientaddrsize, host, sizeof(host), port, sizeof(port),
     NI_NUMERICHOST | NI_NUMERICSERV);
    

    可以使用宏来表示主机名的最大长度,以及端口号的最大长度。将主机名作为数字 IP 地址获取,同样对于端口号,虽然通常以数字开头,但也可以使用字符串表示。有关更多信息,请参阅Open BSD man pages

  5. and

    当你不再需要从套接字读取更多数据、写入更多数据或完成这两项操作时,使用该调用。当你对套接字的读取和/或写入端调用时,该信息也会发送到连接的另一端。如果你在服务器端关闭套接字以进行进一步写入,那么稍后,一个阻塞调用可能会返回 0,表示不再期望更多字节。同样,向已关闭读取的 TCP 连接写入将生成 SIGPIPE 信号

    当你的进程不再需要套接字文件描述符时使用。

    如果在创建套接字文件描述符后执行了操作,则所有进程都需要在套接字资源可以重用之前关闭套接字。如果你关闭套接字以进行进一步读取,所有进程都会受到影响,因为你已经改变了套接字,而不是文件描述符。良好的代码会在调用之前关闭套接字。

创建服务器有几个需要注意的问题。

  • 使用被动服务器套接字的套接字描述符(如上所述)

  • 在 getaddrinfo 中未指定 SOCK_STREAM 要求

  • 无法重用现有端口。

  • 未初始化未使用的结构体条目

  • 如果端口当前正在使用中,该调用将失败。端口是针对机器的,而不是针对进程或用户。换句话说,你不能在另一个进程使用该端口时使用端口 1234。更糟糕的是,端口默认情况下在进程完成后会被“绑定”。

示例服务器

下面是一个工作简单服务器示例。注意:此示例是不完整的。例如,套接字文件描述符保持打开状态,由创建的内存保持分配。首先,我们获取当前机器的地址信息。

struct addrinfo hints, *result;
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;

int s = getaddrinfo(NULL, "1234", &hints, &result);
if (s != 0) {
 fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
 exit(1);
}

然后我们设置套接字,绑定它,并监听。

int sock_fd = socket(AF_INET, SOCK_STREAM, 0);

// Bind and listen
if (bind(sock_fd, result->ai_addr, result->ai_addrlen) != 0) {
 perror("bind()");
 exit(1);
}

if (listen(sock_fd, 10) != 0) {
 perror("listen()");
 exit(1);
}

我们终于准备好监听连接了,所以我们将告诉用户并接受我们的第一个客户端。

struct sockaddr_in *result_addr = (struct sockaddr_in *) result->ai_addr;
printf("Listening on file descriptor %d, port %d\n", sock_fd, ntohs(result_addr->sin_port));

// Waiting for connections like a passive socket
printf("Waiting for connection...\n");
int client_fd = accept(sock_fd, NULL, NULL);
printf("Connection made: client_fd=%d\n", client_fd);

之后,我们可以将新的文件描述符视为类似于管道的字节流。

char buffer[1000];
// Could get interrupted
int len = read(client_fd, buffer, sizeof(buffer) - 1);
buffer[len] = '\0';

printf("Read %d chars\n", len);
printf("===\n");
printf("%s\n", buffer);

[language=C]

抱歉打断

我们需要明确的一个概念是,你需要在你的网络代码中处理中断。这意味着你读取或写入的套接字或已接受的文件描述符可能会在调用过程中被中断——大多数情况下你可能会遇到一个或两个中断。实际上,你系统中的任何调用都可能被中断。我们现在提出这个问题的原因是你通常在等待网络。这比进程慢一个数量级。这意味着中断的可能性更高。

你会如何处理中断?让我们尝试一个快速示例。

while bytes_read isn't count {
 bytes_read += read(fd, buf, count);
 if error is EINTR {
 continue;
 } else {
 break;
 }
}

我们可以向你保证,以下代码会出错。你能看出为什么吗?表面上,它会在读取或写入后重新启动调用。但当错误是 EINTR 时,会发生什么?缓冲区的内容是否正确?你能发现其他问题吗?

第 4 层:UDP

UDP 是一种无连接协议,它建立在 IPv4 和 IPv6 之上。它使用简单。决定目标地址和端口,然后发送你的数据包!然而,网络不保证数据包是否会到达。如果网络拥塞,数据包可能会丢失。数据包可能会重复或顺序错乱。

UDP 的一个典型用例是当接收最新数据比接收所有数据更重要时。例如,一个游戏可能会发送玩家位置的连续更新。流媒体视频信号可能会使用 UDP 发送图片更新。

UDP 属性

  • 通过 UDP 发送的不可靠数据报协议数据包在前往目的地的过程中可能会丢失。这尤其令人困惑,因为如果你只在回环设备上测试——对于大多数用户来说,这是 localhost 或 127.0.0.1——那么数据包很少会丢失,因为没有发送网络数据包。

  • 简单 UDP 协议应该比 TCP 有更少的冗余。这意味着对于 TCP,有很多可配置的参数和很多实现中的边缘情况。UDP 是即发即忘。

  • 无状态/事务 UDP 协议是无状态的。这使得协议更简单,并允许协议表示简单的交易,如请求或响应查询。发送 UDP 消息的开销也更小,因为没有三次握手。

  • 手动流量/拥塞控制你必须手动管理流量和拥塞控制,这是一把双刃剑。一方面,你完全控制着一切。另一方面,TCP 有数十年的优化,这意味着你的协议对于其用例需要更高效,以便更利于使用。

  • 多播 这是你只能使用 UDP 做的事情之一。这意味着你可以向连接到特定路由器的每个对等体发送消息,该路由器是特定组的一部分。

完整的详细描述可在原始 RFC(“用户数据报协议” 1980)中找到。

虽然你可能不想在不想丢失数据的情况下使用 UDP,但许多协议基于 UDP 进行通信,这需要完整的数据。看看简单的文件传输协议(Trivial File Transfer Protocol),它使用 UDP 仅通过有线可靠地传输文件。当然,涉及更多的配置,但选择 UDP 而不是 TCP 涉及的因素远不止上述这些。

UDP 客户端

UDP 客户端非常灵活,下面是一个简单的客户端,它通过命令行指定的服务器发送一个数据包。请注意,这个客户端发送数据包后不会等待确认。它发射后即忘。下面的例子也使用了它,因为一些遗留功能在设置客户端时仍然表现得相当不错。

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
struct hostent *serv = gethostbyname(hostname);

之前的代码通过主机名获取一个匹配项。尽管这不是可移植的,但它完成了工作。首先是要连接到它并使其可重用——就像 TCP 套接字一样。请注意,我们传递的是而不是 。

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));

然后,我们可以将我们的结构体复制到另一个结构体中。完整的定义可以在手册页中找到,所以安全地复制它们是可行的。

memcpy(&addr.sin_addr.s_addr, serv->h_addr, serv->h_length);

然后 UDP 的一个最终有用的部分是,我们可以超时接收数据包,而不是 TCP,因为 UDP 是无连接的。执行此操作的代码片段如下。

struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = SOCKET_TIMEOUT;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

现在,套接字已连接并准备好使用。我们可以用它来发送数据包。我们还应该检查返回值。请注意,如果数据包没有送达,我们不会收到错误,因为这属于 UDP 协议的一部分。然而,对于无效的结构体、错误的地址等,我们会收到错误代码。

char *to_send = "Hello!"
int send_ret = sendto(sock_fd, // Socket
 to_send, // Data
 strlen(to_send), // Length of data
 0, // Flags
 (struct sockaddr *)&ipaddr, // Address
 sizeof(ipaddr)); // How long the address is

上述代码通过 UDP 简单地发送“Hello”。没有关于数据包是否到达、是否被处理等的想法。

UDP 服务器

有许多函数调用可用于发送 UDP 套接字。我们将使用较新的来帮助设置套接字结构。记住,UDP 是一个简单的基于数据包(‘数据报’)的协议。两个主机之间不需要建立连接。首先,初始化 hints addrinfo 结构以请求一个 IPv6,被动数据报套接字。

memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET6;
hints.ai_socktype =  SOCK_DGRAM;
hints.ai_flags =  AI_PASSIVE;

接下来,使用 getaddrinfo 指定端口号。我们不需要指定主机,因为我们正在创建一个服务器套接字,而不是向远程主机发送数据包。小心不要发送“localhost”或其他回环地址的同义词。我们可能会尝试被动监听自己,从而导致绑定错误。

getaddrinfo(NULL, "300", &hints, &res);

sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
bind(sockfd, res->ai_addr, res->ai_addrlen);

端口号小于 1024,因此程序需要特权。我们也可以指定一个服务名称而不是数字端口号。

到目前为止,调用与 TCP 服务器类似。对于基于流的 服务,我们会调用并接受。对于我们的 UDP 服务器,程序可以开始等待数据包的到来。

struct sockaddr_storage addr;
int addrlen = sizeof(addr);

// ssize_t recvfrom(int socket, void* buffer, size_t buflen, int flags, struct sockaddr *addr, socklen_t * address_len);

byte_count = recvfrom(sockfd, buf, sizeof(buf), 0, &addr, &addrlen);

addr 结构将保存关于到达数据包的发送者(源)信息。注意类型足够大,可以容纳所有可能的套接字地址类型 - IPv4、IPv6 或任何其他互联网协议。完整的 UDP 服务器代码如下。

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(int argc, char **argv) {
 struct addrinfo hints, *res;
 memset(&hints, 0, sizeof(hints));
 hints.ai_family = AF_INET6; // INET for IPv4
 hints.ai_socktype =  SOCK_DGRAM;
 hints.ai_flags =  AI_PASSIVE;

 getaddrinfo(NULL, "300", &hints, &res);

 int sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);

 if (bind(sockfd, res->ai_addr, res->ai_addrlen) != 0) {
 perror("bind()");
 exit(1);
 }
 struct sockaddr_storage addr;
 int addrlen = sizeof(addr);

 while(1){
 char buf[1024];
 ssize_t byte_count = recvfrom(sockfd, buf, sizeof(buf), 0, &addr, &addrlen);
 buf[byte_count] = '\0';

 printf("Read %d chars\n", byte_count);
 printf("===\n");
 printf("%s\n", buf);
 }

 return 0;
}

注意,如果您从数据包中执行部分读取,则其余数据将被丢弃。一次 recvfrom 调用对应一个数据包。为了确保有足够的空间,请使用 64 KiB 作为存储空间。

第 7 层:HTTP

OSI 模型的第 7 层处理应用层接口。这意味着您可以忽略此层以下的所有内容,将互联网视为一种与另一台计算机通信的方式,这种通信可以是安全的,并且会话可能会重新连接。常见的第 7 层协议如下

  1. HTTP(S) - 超文本传输协议。在 Web 服务器上发送任意数据并执行远程操作。S 标准表示安全,其中 TCP 连接使用 TLS 协议以确保通信不会被旁观者轻易读取。

  2. FTP - 文件传输协议。将文件从一个计算机传输到另一个计算机

  3. TFTP - 简单文件传输协议。与上面相同,但使用 UDP。

  4. DNS - 域名服务。将主机名转换为 IP 地址

  5. SMTP - 简单邮件传输协议。允许向邮件服务器发送纯文本电子邮件

  6. SSH - 安全壳。允许一台计算机远程连接到另一台计算机并执行命令。

  7. Bitcoin - 去中心化加密货币

  8. BitTorrent - 对等文件共享协议

  9. NTP - 网络时间协议。此协议帮助将您的计算机时钟与外界同步

我的名字是什么?

记得我们之前讨论过将网站转换为 IP 地址吗?使用了一个名为“DNS”(域名服务)的系统。如果 IP 地址未存在于机器的缓存中,则发送一个 UDP 数据包到本地 DNS 服务器。此服务器可能会查询其他上游 DNS 服务器。

DNS 本身速度快但不够安全。DNS 请求未加密,容易受到“中间人”攻击。例如,咖啡店的互联网连接可以轻易篡改您的 DNS 请求,并返回特定域名的不同 IP 地址。这种篡改通常发生在获取 IP 地址后,通常会通过 HTTPS 建立连接。HTTPS 使用称为 TLS(以前称为 SSL)的协议来确保传输安全并验证主机名是否由证书颁发机构认可。证书颁发机构经常被黑客攻击,因此请小心不要将绿色锁等同于安全。即使有这层额外的安全措施,美国政府最近要求每个人升级他们的 DNS 到 DNSSec,它包括额外的安全技术,以高概率验证 IP 地址确实与主机名相关。

顺便提一下,DNS 的工作原理如下

  1. 向您的 DNS 服务器发送一个 UDP 数据包

  2. 如果该 DNS 服务器已缓存该数据包,则返回结果

  3. 如果没有,向更高层的 DNS 服务器请求答案。缓存并发送结果

  4. 如果任一数据包在猜测的超时时间内没有响应,则重发请求。

如果你想查看完整的细节,请随意查看维基百科页面。本质上,存在一个 DNS 服务器的层次结构。首先,有点层次结构。这个层次结构首先解析顶级域名等。接下来,它解析下一级,即。然后本地解析器可以解析任意数量的 URL。例如,伊利诺伊 DNS 服务器处理和。你可以拥有的子域名数量有限,但这通常用于将请求路由到不同的服务器,以避免需要购买许多高性能服务器来路由请求。

非阻塞 IO

当你调用时,如果数据不可用,它将等待数据准备好后再返回函数。当你从磁盘读取数据时,这种延迟很短,但当你从慢速网络连接读取时,请求需要很长时间。而且数据可能永远不会到达,导致意外的关闭。

POSIX 允许你设置文件描述符上的一个标志,这样任何对该文件描述符的调用都会立即返回,无论它是否已经完成。在这种模式下,你的调用将启动读取操作,在它工作的时候,你可以做其他有用的工作。这被称为“非阻塞”模式,因为调用不会阻塞。

要设置文件描述符为非阻塞。

// fd is my file descriptor
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

对于套接字,你可以通过将添加到第二个参数来以非阻塞模式创建它:

fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);

当一个文件处于非阻塞模式并且你调用时,它将立即返回可用的任何字节。比如说,从你的套接字另一端的服务器传来了 100 个字节,你调用。‘read’将立即返回一个值为 100 的值,这意味着它读取了你请求的 150 个字节中的 100 个。如果你尝试通过调用读取剩余的数据,但最后 50 个字节还没有到达。将返回-1,并将全局错误变量errno设置为或。这是系统告诉你数据尚未准备好的方式。

也在非阻塞模式下工作。比如说,你想通过套接字向远程服务器发送 40,000 个字节。系统一次只能发送这么多字节。在非阻塞模式下,将立即返回它能够立即发送的字节数,大约是 23,000。如果你立即再次调用,它将返回-1,并将 errno 设置为或。这是系统告诉你它仍在发送最后一块数据,并且尚未准备好发送更多数据的方式。

有几种方法可以检查你的 IO 是否到达。让我们看看如何使用selectepoll来实现。我们拥有的第一个接口是 select。如果 POSIX 社区有替代方案,很多人不会选择它,而且在大多数情况下,都有一个替代方案。

int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);

给定三组文件描述符,将等待这些文件描述符中的任何一个变为“就绪”。

    • 当有可读数据或到达 EOF 时,文件描述符处于就绪状态。
    • 当调用 write()将成功时,文件描述符处于就绪状态。
    • 系统特定的,定义不明确。只需传递 NULL 即可。

返回就绪文件描述符的总数。如果在由timeout定义的时间内没有任何文件描述符变为就绪,它将返回 0。在返回后,调用者需要遍历 readfds 和/或 writefds 中的文件描述符,以查看哪些已就绪。由于 readfds 和 writefds 既作为输入参数也作为输出参数,当指示有就绪文件描述符时,它将覆盖它们以反映只有就绪文件描述符。除非调用者打算只调用一次,否则在调用之前保存 readfds 和 writefds 的副本是一个好主意。以下是一个综合示例。

fd_set readfds, writefds;
FD_ZERO(&readfds);
FD_ZERO(&writefds);
for (int i=0; i < read_fd_count; i++)
FD_SET(my_read_fds[i], &readfds);
for (int i=0; i < write_fd_count; i++)
FD_SET(my_write_fds[i], &writefds);

struct timeval timeout;
timeout.tv_sec = 3;
timeout.tv_usec = 0;

int num_ready = select(FD_SETSIZE, &readfds, &writefds, NULL, &timeout);

if (num_ready < 0) {
 perror("error in select()");
} else if (num_ready == 0) {
 printf("timeout\n");
} else {
 for (int i=0; i < read_fd_count; i++)
 if (FD_ISSET(my_read_fds[i], &readfds))
 printf("fd %d is ready for reading\n", my_read_fds[i]);
 for (int i=0; i < write_fd_count; i++)
 if (FD_ISSET(my_write_fds[i], &writefds))
 printf("fd %d is ready for writing\n", my_write_fds[i]);
}

有关 select()的更多信息 select 的问题以及为什么许多用户不使用它或 poll 的原因是,select 必须线性地遍历每个对象。如果在遍历对象的过程中,任何对象的状态发生变化,select 必须重新启动。如果我们每个集合中都有大量的文件描述符,这将非常低效。有一个替代方案,但并不比这个好多少。

epoll

不是 POSIX 的一部分,但 Linux 支持它。这是一种更高效地等待多个文件描述符的方法。它将确切地告诉你哪些描述符已准备好。它甚至为你提供了一种方法,可以在每个描述符中存储少量数据,如数组索引或指针,这使得访问与该描述符关联的数据更加容易。

首先,你必须使用epoll_create()创建一个特殊的文件描述符。你不会读取或写入这个文件描述符。你将把它传递给其他 epoll_xxx 函数,并在结束时调用 close()。

int epfd = epoll_create(1);

对于你想要使用 epoll 监控的每个文件描述符,你需要使用epoll_ctl()和该选项将其添加到 epoll 数据结构中。你可以向其中添加任意数量的文件描述符。

struct epoll_event event;
event.events = EPOLLOUT;  // EPOLLIN==read, EPOLLOUT==write
event.data.ptr = mypointer;
epoll_ctl(epfd, EPOLL_CTL_ADD, mypointer->fd, &event)

要等待一些文件描述符变为就绪状态,请使用epoll_wait()。它填充的 epoll_event 结构将包含你在添加此文件描述符时提供的 event.data 中的数据。这使得你可以轻松查找与该文件描述符关联的数据。

int num_ready = epoll_wait(epfd, &event, 1, timeout_milliseconds);
if (num_ready > 0) {
 MyData *mypointer = (MyData*) event.data.ptr;
 printf("ready to write on %d\n", mypointer->fd);
}

假设你正在等待向文件描述符写入数据,但现在你想等待从它读取数据。只需使用该选项更改你正在监控的操作类型。

event.events = EPOLLOUT;
event.data.ptr = mypointer;
epoll_ctl(epfd, EPOLL_CTL_MOD, mypointer->fd, &event);

要从 epoll 中取消一个文件描述符的订阅,同时保持其他文件描述符处于活动状态,请使用该选项。

epoll_ctl(epfd, EPOLL_CTL_DEL, mypointer->fd, NULL);

要关闭一个 epoll 实例,关闭其文件描述符。

close(epfd);

还可以将非阻塞和,任何对非阻塞套接字的调用也将是非阻塞的。要等待连接完成,请使用或 epoll 等待套接字可写。使用 epoll 而不是 select 有原因,但由于接口,这样做存在根本问题。

关于 select 存在问题的博客文章

Epoll 示例

让我们分解一下手册页中的 epoll 代码。我们假设我们有一个准备好的 TCP 服务器套接字。我们首先要做的是创建 epoll 设备。

epollfd = epoll_create1(0);
if (epollfd == -1) {
 perror("epoll_create1");
 exit(EXIT_FAILURE);
}

下一步是在触发模式下添加监听套接字。

// This file object will be `read` from (connect is technically a read operation)
ev.events = EPOLLIN;
ev.data.fd = listen_sock;

// Add the socket in with all the other fds. Everything is a file descriptor
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
 perror("epoll_ctl: listen_sock");
 exit(EXIT_FAILURE);
}

然后在循环中,我们等待并查看 epoll 是否有任何事件。

struct epoll_event ev, events[MAX_EVENTS];
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
 perror("epoll_wait");
 exit(EXIT_FAILURE);
}

如果我们在客户端套接字上接收到事件,这意味着客户端有数据准备好读取,我们执行这个操作。否则,我们需要更新我们的 epoll 结构以添加一个新的客户端。

if (events[n].data.fd == listen_sock) {
 int conn_sock = accept(listen_sock, (struct sockaddr *) &addr, &addrlen);
 // Must set to non-blocking
 setnonblocking(conn_sock);

 // We will read from this file, and we only want to return once
 // we have something to read from. We don't want to keep getting
 // reminded if there is still data left (edge triggered)
 ev.events = EPOLLIN | EPOLLET;
 ev.data.fd = conn_sock;
 epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev)
}

上述函数为了简洁省略了一些错误检查。请注意,这段代码性能良好,因为我们以触发模式添加了服务器套接字,并且以边缘触发模式添加了每个客户端文件描述符。边缘触发模式将更多的计算留给了应用程序的部分——应用程序必须继续读取或写入,直到文件描述符的字节用完——但它防止了饥饿。一个更高效的实现还会将监听套接字添加为边缘触发,以清除连接队列。

在开始编程之前,请阅读大部分内容。有很多陷阱。以下是一些更常见的陷阱将详细说明。

各种 Epoll 陷阱

使用 epoll 有几个问题。这里我们将详细说明几个。

  1. 有两种模式。触发模式和边缘触发模式。触发模式意味着当文件描述符上有事件时,它将在调用 ctl 函数时被 epoll 返回。在边缘触发模式下,调用者只有在从零事件变为事件时才会得到文件描述符。这意味着如果你忘记在文件描述符上读取、写入、接受等操作,直到你得到 EWOULDBLOCK,该文件描述符将被丢弃。

  2. 如果在任何时候你复制一个文件描述符并将其添加到 epoll 中,你将从这个文件描述符和复制的文件描述符中接收到事件。

  3. 你可以将 epoll 对象添加到 epoll 中。边缘触发和触发模式是相同的,因为 ctl 会将状态重置为零事件

  4. 根据条件,你可能会得到一个从 Epoll 关闭的文件描述符。这不是一个错误。这种情况发生的原因是 epoll 在内核对象级别工作,而不是在文件描述符级别。如果内核对象存活时间更长并且设置了正确的标志,进程可能会得到一个关闭的文件描述符。这也意味着如果你关闭文件描述符,就没有办法移除内核对象。

  5. Epoll 有一个标志,在返回文件描述符后将其移除

  6. 使用带级联模式的 Epoll 可能会饿死某些文件描述符,因为不知道应用程序将从每个描述符中读取多少数据。

更多信息请参阅或查看附录中提到的更好版本。

远程过程调用

RPC 或远程过程调用是我们可以在不同机器上执行过程的想法。在实践中,过程可能在同一台机器上执行。然而,它可能处于不同的上下文中。例如,在具有不同权限和不同生命周期的不同用户下执行操作。

例如,你可能可以向 docker 守护进程发送远程过程调用以更改容器的状态。并非每个应用程序都需要访问整个系统机器,但它们应该能够访问它们创建的容器。

权限分离

远程代码将在与调用者不同的用户和权限下执行。在实践中,远程调用可能具有比调用者更多或更少的权限。原则上,这可以通过确保组件以最低权限运行来提高系统的安全性。不幸的是,需要仔细评估安全顾虑,以确保 RPC 机制不会被滥用以执行不希望的操作。例如,RPC 实现可能隐式信任任何连接的客户端执行任何操作,而不是数据子集上的操作子集。

存根代码和序列化

存根代码是隐藏执行远程过程调用复杂性的必要代码。存根代码的一个角色是将必要的数据序列化为可以发送到远程服务器的字节流格式。

// On the outside, 'getHiscore' looks like a normal function call
// On the inside, the stub code performs all of the work to send and receive data to and from the remote machine.

int getHighScore(char* game) {
 // Marshal the request into a sequence of bytes:
 char* buffer;
 asprintf(&buffer,"getHiscore(%s)!", name);

 // Send down the wire (we do not send the zero byte; the '!' signifies the end of the message)
 write(fd, buffer, strlen(buffer) );

 // Wait for the server to send a response
 ssize_t bytesread = read(fd, buffer, sizeof(buffer));

 // Example: unmarshal the bytes received back from text into an int
 buffer[bytesread] = 0; // Turn the result into a C string

 int score= atoi(buffer);
 free(buffer);
 return score;
}

使用字符串格式可能有点低效。一个很好的例子是 Golang 的 gRPC 或 Google RPC。如果你想要检查 C 版本,也可以找到。

服务器存根代码将接收请求,将请求反序列化为有效的内存数据调用底层实现,并将结果发送回调用者。通常,底层库会为你完成这项工作。

要实现 RPC,你需要决定并记录你将使用哪些约定将数据序列化为字节序列。即使是简单的整数也有几种常见的选择。

  1. 签名还是未签名?

  2. ASCII、Unicode 文本格式 8,或其他编码?

  3. 字节数是固定的还是根据大小可变的。

  4. 如果使用二进制,是小端还是大端格式?

要序列化结构体,决定哪些字段需要序列化。可能没有必要发送所有数据项。例如,某些项可能与特定的 RPC 无关,或者可以从其他数据项中重新计算。

要序列化链表,没有必要发送链接指针,而是流式传输值。作为反序列化的一部分,服务器可以从字节序列中重新创建链表结构。

从头节点/顶点开始,可以通过递归访问一个简单的树来创建数据的序列化版本。一个循环图通常需要额外的内存来确保每个边和顶点恰好被处理一次。

接口描述语言

手动编写存根代码既痛苦又乏味,容易出错,难以维护,并且难以从实现代码中逆向工程线协议。更好的方法是指定数据对象、消息和服务,以自动生成客户端和服务器代码。现代接口描述语言的例子是 Google 的.proto 文件。

即使如此,远程过程调用(RPC)与本地调用相比,速度显著较慢(10 倍到 100 倍),并且更复杂。RPC 必须将数据序列化为线兼容的格式。这可能需要多次通过数据结构,临时内存分配以及数据表示的转换。

强健的 RPC 存根代码必须智能地处理网络故障和版本问题。例如,服务器可能需要处理仍在运行早期版本存根代码的客户端请求。

一个安全的 RPC 需要实现额外的安全检查,包括身份验证和授权,验证数据并加密客户端和主机之间的通信。很多时候,RPC 系统可以为你高效地完成这些工作。考虑一下,如果你在同一台机器上既有 RPC 客户端又有服务器。启动一个 thrift 或 Google RPC 服务器可以验证并将请求路由到本地套接字,该套接字不会通过网络发送。

转移结构化数据

让我们考察三种使用三种不同格式(JSON、XML 和 Google Protocol Buffers)传输数据的方法。JSON 和 XML 是基于文本的协议。以下是一些 JSON 和 XML 消息的示例。

<ticket><price currency='dollar'>10</price><vendor>travelocity</vendor></ticket>
{ 'currency':'dollar' , 'vendor':'travelocity', 'price':'10' }

Google Protocol Buffers 是一个开源的高效二进制协议,它强调高吞吐量,同时具有低 CPU 开销和最小内存复制。这意味着可以从.proto 规范文件生成多种语言的客户端和服务器存根代码,以将数据序列化到和从二进制流中。

Google Protocol Buffers通过忽略消息中存在的未知字段来减少版本问题。有关 Protocol Buffers 的更多信息,请参阅其介绍。

通常的链路是抽象出实际的业务逻辑和各种序列化代码。如果你的应用程序在解析 XML、JSON 或 YAML 时成为 CPU 密集型,切换到协议缓冲区!

主题

  • IPv4 与 IPv6

  • TCP 与 UDP

  • 数据包丢失/基于连接的

  • 获取地址信息

  • DNS

  • TCP 客户端调用

  • TCP 服务器调用

  • 关闭

  • recvfrom

  • epoll 与 select

  • RPC

问题

  • 什么是 IPv4?什么是 IPv6?它们之间有什么区别?

  • 什么是 TCP?什么是 UDP?请给出两者的优缺点。在什么情况下使用其中一个而不是另一个?

  • 哪个协议是无连接的,哪个是基于连接的?

  • DNS 是什么?DNS 采取的路径是什么?

  • 网络套接字的作用是什么?

  • 设置 TCP 客户端的调用有哪些?

  • 设置 TCP 服务器的调用有哪些?

  • 网络套接字关闭和关闭之间的区别是什么?

  • 何时可以使用和?关于又如何?

  • 与相比,有哪些优势?关于又如何?

  • 远程过程调用是什么?在什么情况下应该使用它而不是 HTTP 或本地运行代码?

  • marshaling/unmarshaling 是什么?为什么 HTTP 不是 RPC?

文件系统

文件系统很重要,因为它们允许你在计算机关闭、崩溃或内存损坏后持久化数据。在过去,文件系统使用成本很高。向文件系统(FS)写入涉及将数据写入磁带并从磁带中读取(“国际的”,n.d.)。这很慢,很重,并且容易出错。

如今,我们的大部分文件都存储在磁盘上——尽管并非所有文件都是这样!至少在速度上,磁盘仍然比内存慢一个数量级。

在我们开始本章之前,先介绍一些术语。文件系统,我们将在后面更具体地定义,是指满足文件系统 API 的任何东西。文件系统由存储介质支持,例如硬盘驱动器、固态驱动器、RAM 等。硬盘可以是硬盘驱动器(HDD),它包括一个旋转的金属盘和一个可以电击盘面来编码 1 或 0 的磁头,或者是一个可以翻转芯片或独立驱动器上的某些 NAND 门的设备来存储 1 或 0。截至 2019 年,SSD 的速度比标准 HDD 快一个数量级。这些都是文件系统的典型支持介质。文件系统是在这个支持介质之上实现的,这意味着我们可以在商业可用的硬盘上实现 EXT、MinixFS、NTFS、FAT32 等。这个文件系统告诉操作系统如何组织 1 和 0 来存储文件信息以及目录信息,但关于这一点我们稍后再谈。为了避免过于拘泥于细节,我们可以说 EXT 或 NTFS 这样的文件系统直接实现了文件系统 API(打开、关闭等)。通常,操作系统会添加一层抽象,并要求操作系统满足其 API(例如,想象中的函数 linux_open、linux_close 等)。这两个好处是,一个文件系统可以为多个操作系统 API 实现,添加新的操作系统文件系统调用不需要所有底层文件系统更改它们的 API。例如,在 linux 的下一个版本中,如果有一个新的系统调用用于创建文件的备份,操作系统可以使用内部 API 来实现,而不是要求所有文件系统驱动程序更改它们的代码。

最后一点背景信息非常重要。在本章中,我们将使用 ISO 兼容的 KiB 或 Kibibyte 来表示文件的大小。*iB 系列是二进制存储的简称。这意味着以下内容:

Kibibyte 值

前缀 字节数值
KiB 1024B
MiB 1024 * 1024 B
GiB 1024 3̂ B

标准记数前缀的含义如下:

千字节值

前缀 字节数值
KB 1000B
MB 1000 * 1000 B
GB 1000 3̂ B

我们将在书中和网络章节中这样做,以保持一致性,避免混淆任何人。在现实世界中,存在不同的惯例。这个惯例是,当一个文件在操作系统中显示时,KB 与 KiB 是相同的。当我们谈论计算机网络、CD、其他存储时,KB 与 KiB 不相同,并且是 ISO/公制定义的。这是历史上的一个小怪癖,是由网络开发者和内存/硬盘存储开发者之间的冲突带来的。硬盘和内存开发者发现,如果一个比特可以处于两种状态之一,那么将千位前缀称为 1024 是自然的,因为它大约是 1000。网络开发者必须处理比特、实时信号处理以及各种其他因素,所以他们遵循了已经接受的惯例,即 Kilo- 表示某物的 1000 倍(“International,” n.d.)。你需要知道的是,如果你在野外看到 KB,它可能基于上下文是 1024。如果在任何时候你在这个班级中看到 KB 或任何家族成员提到文件系统问题,你可以安全地推断它们指的是 1024 作为基本单位。尽管如此,当你推动生产代码时,请确保询问这些差异!

什么是文件系统?

你可能遇到过古老的 UNIX 格言,“万物皆文件”。在大多数 UNIX 系统中,文件操作提供了一个接口来抽象许多不同的操作。网络套接字、硬件设备和磁盘上的数据都由类似文件的对象表示。一个类似文件的对象必须遵循以下约定:

  1. 它必须向文件系统呈现自己。

  2. 它必须支持常见的文件系统操作,例如创建、删除、重命名。至少,它需要能够打开和关闭。

文件系统是文件接口的实现。在本章中,我们将探讨文件系统提供的各种回调,一些典型功能及其相关实现细节。在本课程中,我们将主要讨论那些允许用户访问磁盘数据的文件系统,这对于现代计算机至关重要。

这里是一些文件系统的常见特性:

  1. 它们处理存储本地文件和处理允许内核和用户空间之间安全通信的特殊设备。

  2. 它们处理故障、可扩展性、索引、加密、压缩和性能。

  3. 它们处理文件包含数据以及数据如何在磁盘上存储、分区和保护之间的抽象。

在我们深入探讨文件系统的细节之前,让我们看看一些例子。为了澄清,挂载点仅仅是将目录映射到内核中表示的文件系统。

  1. 通常在 Linux 系统上挂载到 /,这是通常提供磁盘访问的文件系统,正如你所习惯的那样。

  2. 通常挂载到 /proc,提供对进程的信息和控制。

  3. 通常安装在 /sys,这是 /proc 的一个更现代版本,它还允许控制各种其他硬件,例如网络套接字。

  4. 在某些系统中安装在 /tmp,这是一个内存文件系统,用于存储临时文件。

  5. 这通过协议同步文件。

它告诉您基于目录的文件系统系统调用解析为什么。例如,在我们的情况下由文件系统解析,尽管它包含作为子系统的,但它由系统解析。

如您所注意到的,一些文件系统提供了对“非文件”事物的接口。例如,通常被称为虚拟文件系统,因为它们不提供与传统文件系统相同的数据访问。技术上,内核中的所有文件系统都表示为虚拟文件系统,但我们将区分虚拟文件系统为实际上不存储任何东西在硬盘上的文件系统。

文件 API

文件系统必须为各种操作提供回调函数。其中一些列在下面:

  • 打开文件进行 IO 操作

  • 读取文件内容

  • 向文件写入

  • 关闭文件并释放相关资源

  • 修改文件的权限

  • 与字符设备(如终端)的设备参数交互

并非每个文件系统都支持所有可能的回调函数。例如,许多文件系统省略或。许多文件系统不提供,这意味着它们只提供顺序访问。程序不能移动到文件中的任意位置。这与类似。在本章中,我们不会检查每个文件系统回调。如果您想了解更多关于此接口的信息,请尝试查看用户空间级别的文件系统(FUSE)的文档。

在磁盘上存储数据

要了解文件系统如何与磁盘上的数据交互,我们将使用三个关键术语。

  1. 磁盘块是磁盘的一部分,用于存储文件或目录的内容。

  2. 一个 inode 一个文件或目录。这意味着 inode 包含有关文件的元数据以及指向磁盘块的指针,以便文件实际上可以被写入或读取。

  3. 超块包含有关 inode 和磁盘块元数据。一个示例超块可以存储每个磁盘块的使用情况,哪些 inode 正在使用等。现代文件系统可能实际上包含多个超块和一个类似超级块的超级块,它跟踪哪些扇区由哪些超级块管理。这有助于减少碎片。

虽然可能看起来令人不知所措,但到本章结束时,我们将能够理解文件系统的每个部分。

要对某种形式的存储上的数据进行推理——旋转磁盘、固态驱动器、磁带——通常的做法是首先考虑存储介质为的集合。块可以被视为磁盘上的连续区域。虽然其大小有时由底层硬件的一些属性决定,但它更频繁地基于给定系统的页面大小来决定,这样可以从内存中缓存磁盘数据以实现更快的访问——这是许多文件系统的一个重要特性。

文件系统有一个特殊的块,称为超级块,它存储有关文件系统的元数据,例如日志(记录文件系统的更改)、inode 表、磁盘上第一个 inode 的位置等。关于超级块的重要之处在于它在磁盘上的位置是已知的。如果不是这样,你的计算机可能无法启动!考虑一个编程到主板上的简单 ROM。如果你的处理器不能告诉主板开始读取和解码磁盘块以启动启动序列,你就麻烦了。

索引节点是我们文件系统最重要的结构,因为它代表了一个文件。在我们深入探讨它之前,让我们列出我们需要的关键信息,以便能够使用文件。

  • 名称

  • 文件大小

  • 创建时间、最后修改时间、最后访问时间

  • 权限

  • 文件路径

  • 校验和

  • 文件数据

文件内容

来自 维基百科

在 Unix 风格的文件系统中,索引节点,非正式地称为 inode,是一种用于表示文件系统对象的数据结构,它可以表示各种事物,包括文件或目录。每个 inode 存储了文件系统对象数据的属性和磁盘块位置。文件系统对象的属性可能包括操作元数据(例如更改、访问、修改时间),以及所有者和权限数据(例如组 ID、用户 ID、权限)。

超块可能存储一个 inode 数组,每个 inode 存储直接和可能的几种间接指针到磁盘块。由于 inode 存储在超块中,大多数文件系统对可以存在的 inode 数量有一个限制。由于每个 inode 对应一个文件,这也是该文件系统能够拥有的文件数量的限制。试图通过在其他位置存储 inode 来克服这个问题会极大地增加文件系统的复杂性。试图重新分配 inode 表的空间也是不可行的,因为 inode 数组之后的每个字节都必须移动,这是一个非常昂贵的操作。这并不是说完全没有解决方案,尽管通常没有必要增加 inode 的数量,因为 inode 的数量通常已经足够高。

大概念:忘记文件名。‘inode’就是文件。

人们通常认为文件名是“实际”的文件。不是这样!相反,考虑 inode 作为文件。inode 包含元信息(最后访问时间、所有权、大小)并指向用于存储文件内容的磁盘块。然而,inode 通常不存储文件名。文件名通常只存储在目录中(见下文)。

例如,要读取文件的前几个字节,遵循第一个直接块指针到第一个直接块,并读取前几个字节。写入过程遵循相同的步骤。如果一个程序想要读取整个文件,则持续读取直接块,直到读取的字节数等于文件的大小。如果文件的总大小小于直接块数量乘以块大小,则未使用的块指针将是未定义的。同样,如果文件的大小不是块大小的倍数,则最后一个块最后一个字节之后的数据将是垃圾。

如果一个文件比其直接块能寻址的最大空间还要大怎么办?针对这个问题,我们提出了程序员过于认真对待的一个格言。

“所有计算机科学问题都可以通过另一层间接引用来解决。” - 大卫·惠勒

除了过多间接引用层的问题。

为了解决这个问题,我们引入了。一个单级间接块是一个存储指向更多数据块的指针的块。同样,双级间接块存储指向单级间接块的指针,这个概念可以推广到任意层次的间接引用。这是一个重要的概念,因为 inode 存储在超级块中,或者在一些已知位置的其他结构中,具有固定数量的空间,间接引用允许 inode 跟踪的空间量呈指数增长。

作为一个工作示例,假设我们将磁盘划分为 4KiB 大小的块,并且我们想要访问多达2322^{32}个块。最大磁盘大小是4KiB232=16TiB4KiB 2^{32} = 16TiB,记住210=10242^{10} = 1024。一个磁盘块可以存储4KiB4B\frac{4KiB}{4B}个可能的指针或 1024 个指针。由于我们想要访问 32 位块,因此需要四个字节的指针。每个指针指向一个 4KiB 的磁盘块,因此你可以引用多达10244KiB=4MiB10244KiB = 4MiB的数据。对于相同的磁盘配置,一个双间接块存储 1024 个指向 1024 个间接表的指针。因此,一个双间接块可以引用多达1024*4MiB=4GiB1024 * 4MiB = 4GiB的数据。同样,一个三间接块可以引用多达 4TiB 的数据。由于间接级别增加,读取块之间的速度会慢三倍。实际的块内读取时间不会改变。

目录实现

目录是名称到 inode 编号的映射。它通常是普通文件,但在其 inode 中设置了一些特殊位,并且其内容具有特定的结构。POSIX 提供了一组小函数来读取每个条目的文件名和 inode 编号,我们将在本章后面深入讨论。

让我们思考一下实际文件系统中目录的样子。从理论上讲,它们是文件。磁盘块将包含目录条目dirents。这意味着我们的磁盘块可以看起来像这样

| inode_num | name   | | ----------- | ------ |
| 2043567   | hi.txt | | ... |

每个目录条目可以是固定大小的,也可以是可变长度的 C 字符串。这取决于特定的文件系统在底层如何实现它。要在 POSIX 系统上查看文件名到 inode 编号的映射,从 shell 中使用选项

# ls -i
12983989 dirlist.c      12984068 sandwich.c

你可以稍后看到这是一个强大的抽象。一个文件可以在目录中有多个不同的名称,或者存在于多个目录中。

UNIX 目录约定

在标准 UNIX 文件系统中,在请求读取目录时,会特别添加以下条目。

  1. 代表当前目录

  2. 代表父目录

令人反直觉的是,可以是磁盘上文件或目录的名称(你可以用试试),而不是祖父目录。只有当前目录和父目录有涉及(即,和)。令人困惑的是,shell 在展开 shell 命令时将解释为访问祖父目录的便捷快捷方式(如果存在)。

关于名称相关约定的额外事实:

  1. 通常由 shell 展开为家目录

  2. 在磁盘上以‘.’(点)开头的文件传统上被认为是‘隐藏’的,并且在没有额外标志的情况下(例如,程序会省略它们)。这不是文件系统的功能,程序可以选择忽略这一点。

  3. 一些文件也可能以空字节 NUL 开头。这些通常是抽象 UNIX 套接字,用于防止文件系统杂乱无章,因为它们将被任何未预料到的程序有效地隐藏。然而,它们将被详细提供套接字信息的工具列出,因此这不是提供安全性的功能。

  4. 如果你想要惹恼你的邻居,创建一个包含终端响铃字符的文件。每次文件被列出(例如通过调用‘ls’)时,都会听到一个可听到的响铃。

目录 API

在 C 语言中与文件交互通常是通过使用打开文件,然后或与文件交互,在调用之前释放资源来完成的,目录有特殊的调用,如,和。没有函数,因为这通常意味着创建文件或链接。程序会使用类似或的东西。

要探索这些功能,让我们编写一个程序来搜索目录中的特定文件。下面的代码有错误,试着找出它!

int exists(char *directory, char *name)  {
 struct dirent *dp;
 DIR *dirp = opendir(directory);
 while ((dp = readdir(dirp)) != NULL) {
 puts(dp->d_name);
 if (!strcmp(dp->d_name, name)) {
 return 1; /* Found */
 }
 }
 closedir(dirp);
 return 0; /* Not Found */
}

你找到错误了吗?它泄漏资源!如果找到一个匹配的文件名,那么在早期返回时永远不会调用‘closedir’。任何打开的文件描述符和由‘opendir’分配的任何内存永远不会释放。这意味着最终进程将耗尽资源,或者调用将失败。

修复方法是确保我们在所有可能的代码路径中释放资源。

在上面的代码中,这意味着在之前调用。忘记释放资源是 C 编程中常见的错误,因为 C 语言没有支持确保所有代码路径都释放资源的功能。

给定一个打开的目录,在调用后,要么(XOR),父目录或子目录可以使用,或者。如果父目录和子目录都使用上述方法,则行为是未定义的。

有两个主要的问题和一个注意事项。函数返回“.”(当前目录)和“..”(父目录)。另一个是程序需要显式排除子目录的搜索,否则搜索可能需要很长时间。

对于许多应用来说,在递归搜索子目录之前先检查当前目录是合理的。这可以通过存储结果在链表中或重置目录结构从开始处重新启动来实现。

以下代码尝试递归地列出目录中的所有文件。作为一个练习,尝试识别它引入的错误。

void dirlist(char *path) {
 struct dirent *dp;
 DIR *dirp = opendir(path);
 while ((dp = readdir(dirp)) != NULL) {
 char newpath[strlen(path) + strlen(dp->d_name) + 1];
 sprintf(newpath,"%s/%s", newpath, dp->d_name);
 printf("%s\n", dp->d_name);
 dirlist(newpath);
 }
}

int main(int argc, char **argv) {
 dirlist(argv[1]);
 return 0;
}

你找到了所有的 5 个错误吗?

// Check opendir result (perhaps user gave us a path that can not be opened as a directory
if (!dirp) { perror("Could not open directory"); return; }

// +2 as we need space for the / and the terminating 0
char newpath[strlen(path) + strlen(dp->d_name) + 2];

// Correct parameter
sprintf(newpath,"%s/%s", path, dp->d_name);

// Perform stat test (and verify) before recursing
if (0 == stat(newpath,&s) && S_ISDIR(s.st_mode)) dirlist(newpath)

// Resource leak: the directory file handle is not closed after the while loop
closedir(dirp);

最后一点注意事项。这不是线程安全的!你不应该使用函数的重入版本。在进程内同步文件系统很重要,所以使用锁来包围 。

有关更多详细信息,请参阅readdir 的 man 页

链接

链接迫使我们将文件系统建模为图而不是树。

当将文件系统建模为树时,会暗示每个 inode 都有一个唯一的父目录,但链接允许 inode 在多个地方呈现为文件,可能具有不同的名称,从而导致 inode 有多个父目录。有两种类型的链接:

  1. 硬链接简单来说就是目录中的一个条目,将某个 inode 号分配给已经具有不同名称和映射的 inode。如果我们已经在文件系统上有一个文件,我们可以使用以下命令创建指向相同 inode 的另一个链接:

    $ ln file1.txt blip.txt
    

    然而,blip.txt 确实是同一个文件。如果我们编辑 blip,我正在编辑与‘file1.txt’相同的文件。我们可以通过证明这两个文件名都指向相同的 inode 来证明这一点。

    $ ls -i file1.txt blip.txt
    134235 file1.txt
    134235 blip.txt
    

    相应的 C 调用是

    // Function Prototype
    int link(const char *path1, const char *path2);
    
    link("file1.txt", "blip.txt");
    

    为了简单起见,上面的例子在同一个目录内创建了硬链接。硬链接可以在同一个文件系统的任何地方创建。

  2. 第二种链接称为软链接、符号链接或 symlink。符号链接不同,因为它是一个设置了特殊位的文件,并存储指向另一个文件的路径。简单来说,如果没有这个特殊位,它不过是一个包含文件路径的文本文件。注意,当人们通常谈论一个链接而没有指定硬链接或软链接时,他们指的是硬链接。

    在 shell 中创建符号链接,使用 。要读取链接的内容作为文件,使用 。这两个都在下面演示。

    $ ln -s file1.txt file2.txt
    $ ls -i file1.txt blip.txt
    134235 file1.txt
    134236 file2.txt
    134235 blip.txt
    $ cat file1.txt
    file1!
    $ cat file2.txt
    file1!
    $ cat blip.txt
    file1!
    $ echo edited file2 >> file2.txt # >> is bash syntax for append to file
    $ cat file1.txt
    file1!
    edited file2
    $ cat file2.txt
    I'm file1!
    edited file2
    $ cat blip.txt
    file1!
    edited file2
    $ readlink myfile.txt
    file2.txt
    

    注意,和有不同的 inode 号,与硬链接不同。

    有一个创建符号链接的 C 库函数,它与link函数类似。

    symlink(const char *target, const char *symlink);
    

    符号链接的一些优点是

    • 可以引用尚未存在的文件

    • 与硬链接不同,可以引用目录以及常规文件

    • 可以引用当前文件系统之外的文件(和目录)

    然而,符号链接有一个关键缺点,它们比常规文件和目录要慢。当读取链接的内容时,它们必须被解释为指向目标文件的新路径,这导致需要额外的 open 和 read 调用,因为必须打开和读取实际文件。另一个缺点是 POSIX 禁止硬链接目录,而允许符号链接。该命令将只允许 root 执行此操作,并且只有当您提供选项时。然而,即使是 root 也可能无法执行此操作,因为大多数文件系统都阻止这样做!

文件系统的完整性假设目录结构是一个从根目录可达的循环树。如果允许目录链接,强制执行或验证此约束将变得昂贵。打破这些假设可能会使文件完整性工具无法修复文件系统。递归搜索可能永远不会终止,目录可以有多个父目录,但 “..” 只能引用一个父目录。总的来说,这是一个坏主意。符号链接只是被忽略,这就是为什么我们可以使用它们来引用目录。

当您使用 rm 或 rmdir 删除文件时,您正在从一个目录中删除一个 inode 引用。然而,inode 可能仍然被其他目录引用。为了确定文件内容是否仍然需要,每个 inode 都保留一个引用计数,该计数在创建或删除新链接时更新。这个计数只跟踪硬链接,符号链接可以引用一个不存在的文件,因此,并不重要。

硬链接的一个例子是高效地在不同的时间点创建文件系统的多个存档。一旦存档区域有特定文件的副本,未来的存档就可以重新使用这些存档文件,而不是创建一个重复的文件。这被称为增量备份。苹果的“时间机器”软件就是这样做的。

路径

现在我们有了定义,并且已经讨论了目录,我们遇到了路径的概念。路径是一系列目录,为用户提供了一个文件系统中的“路径”。然而,也有一些细微差别。可能存在一个名为 a/b/../c/./ 的路径。由于 . 和 .. 是目录中的特殊条目,这是一个有效的路径,实际上指的是 a/c。大多数文件系统功能都允许传递未压缩的路径。C 库提供了一个压缩路径或获取绝对路径的函数。为了简化,请记住,. 表示“父文件夹”,而 .. 表示“当前文件夹”。下面是一个示例,说明如何使用 shell 在文件系统中导航来简化 a/b/../c/.

  1. (在/a)

  2. (在/a/b)

  3. (在/a 中,因为 .. 代表“父文件夹”)

  4. (在/a/c)

  5. (在/a/c 中,因为 . 代表“当前文件夹”)

因此,这个路径可以简化为 .

元数据

我们如何区分常规文件和目录?就这个而言,文件还可能包含许多其他属性。我们通过区分文件类型——与文件扩展名不同,例如 png、svg、pdf——使用 inode 内的字段来区分。系统是如何知道文件类型的?

此信息存储在 inode 中。要访问它,请使用 stat 调用。例如,要找出我的“notes.txt”文件最后访问的时间。

struct stat s;
stat("notes.txt", &s);
printf("Last accessed %s", ctime(&s.st_atime));

实际上存在三种版本的 ;

int stat(const char *path, struct stat *buf);
int fstat(int fd, struct stat *buf);
int lstat(const char *path, struct stat *buf);

例如,如果程序已经与该文件关联了一个文件描述符,则可以使用 来了解文件元数据。

FILE *file = fopen("notes.txt", "r");
int fd = fileno(file); /* Just for fun - extract the file descriptor from a C FILE struct */
struct stat s;
fstat(fd, & s);
printf("Last accessed %s", ctime(&s.st_atime));

几乎与相同,但处理符号链接的方式不同。从 man 页面。

lstat() 函数与 stat() 函数相同,区别在于如果路径名是一个符号链接,则它返回关于链接本身的信息,而不是它所指向的文件。

stat 函数使用 。从 man 页面:

struct stat {
 dev_t     st_dev;         /* ID of device containing file */
 ino_t     st_ino;         /* Inode number */
 mode_t    st_mode;        /* File type and mode */
 nlink_t   st_nlink;       /* Number of hard links */
 uid_t     st_uid;         /* User ID of owner */
 gid_t     st_gid;         /* Group ID of owner */
 dev_t     st_rdev;        /* Device ID (if special file) */
 off_t     st_size;        /* Total size, in bytes */
 blksize_t st_blksize;     /* Block size for filesystem I/O */
 blkcnt_t  st_blocks;      /* Number of 512B blocks allocated */
 struct timespec st_atim;  /* Time of last access */
 struct timespec st_mtim;  /* Time of last modification */
 struct timespec st_ctim;  /* Time of last status change */
};

该字段可以用来区分常规文件和目录。为此,使用宏和 。

struct stat s;
if (0 == stat(name, &s)) {
 printf("%s ", name);
 if (S_ISDIR( s.st_mode)) puts("is a directory");
 if (S_ISREG( s.st_mode)) puts("is a regular file");
} else {
 perror("stat failed - are you sure we can read this file's metadata?");
}

权限和位

权限是 UNIX 系统在文件系统中提供安全性的关键部分。你可能已经注意到,该字段包含的不仅仅是文件类型。它还包括模式,这是一个详细说明用户可以使用给定文件做什么和不能做什么的描述。任何文件通常都有三组权限。对于用户、组和其他(不属于前两类用户的所有用户)。对于这三类中的每一类,我们需要跟踪用户是否被允许读取文件、向文件写入和执行文件。由于有三类和三种权限,权限通常表示为一个三位八进制数。对于每一位,最低有效位对应于读取权限,中间位对应于写入权限,最后一位对应于执行权限。它们总是以 用户其他(UGO)的形式呈现。以下是一些常见示例。以下是位约定:

  1. 表示一组人可以读取

  2. 表示一组人可以写入

  3. 表示一组人可以执行

权限表

八进制代码 用户 其他
755
644

值得注意的是,对于目录,位具有略微不同的含义。对目录的写入访问将允许程序在目录内部创建或删除新文件或目录。你可以将其视为对目录条目(dirent)映射的写入访问。对目录的读取访问将允许程序列出目录的内容。这是对目录条目(dirent)映射的读取访问。执行将允许程序使用 cd 进入目录。如果没有执行位,则任何创建或删除文件或目录的尝试都将失败,因为你无法访问它们。然而,你可以列出目录的内容。

有几个命令行实用程序可以与文件的模式交互。更改文件类型。接受一个数字和一个文件,并更改权限位。然而,在我们详细讨论 chmod 之前,我们还必须了解用户 ID () 和组 ID ()。

用户 ID / 组 ID

UNIX 系统中的每个用户都有一个用户 ID。这是一个可以识别用户的唯一数字。同样,用户可以被添加到称为组的集合中,每个组也有一个唯一的识别号。组在 UNIX 系统中有很多用途。它们可以分配能力 - 一种描述用户对系统控制级别的方式。例如,你可能遇到的一个组是组,一组被信任的用户,他们被允许使用命令来临时获得更高的权限。我们将在本章中更多地讨论如何工作。每个文件在创建时都有一个所有者,即文件的创建者。这个所有者的用户 ID () 可以通过在文件系统中使用系统调用 . 来找到。同样,组 ID () 也会被设置。

每个进程都可以使用和确定其和 . 当一个进程尝试以特定模式打开文件时,它的和与文件的和进行比较。如果匹配,则进程打开文件的请求将与文件权限的用户字段上的位进行比较。如果匹配,则进程的请求将与权限的组字段进行比较。如果没有任何 ID 匹配,则将应用其他字段。

读取/更改文件权限

在我们讨论如何更改权限位之前,我们应该能够读取它们。在 C 中,可以使用库调用系列。要从命令行读取权限位,请使用 . 注意,权限将以格式‘trwxrwxrwx’输出。第一个字符表示文件类型。第一个字符的可能值包括但不限于。

  1. (-) 普通文件

  2. (d) 目录

  3. (c) 字符设备文件

  4. (l) 符号链接

  5. (p) 命名管道(也称为 FIFO)

  6. (b) 块设备

  7. (s) 套接字

或者,使用程序,它展示了从库调用中可以检索到的所有信息。

要更改权限位,有一个系统调用,. 为了简化我们的示例,我们将使用同名的命令行实用程序,简称为“更改模式”。使用有两种常见方式,要么是八进制值,要么是符号字符串。

$ chmod 644 file1
$ chmod 755 file2
$ chmod 700 file3
$ chmod ugo-w file4
$ chmod o-rx file4

八进制(‘八进制’)数字描述了每个角色的权限:拥有文件的用户,组以及其他人。八进制数是分配给三种类型权限的三个值的总和:读(4)、写(2)、执行(1)

示例:

  1. r + w + x = 数字 * 用户有 4+2+1,完全权限

  2. 组有 4+0+1,读和执行权限

  3. 所有用户都有 4+0+1,读和执行权限

理解‘umask’

umask 减去(减少)权限位,并在使用 open、mkdir 等创建新文件和新目录时使用。默认情况下,umask 设置为(八进制),这意味着组和其他权限将仅限于可读。每个进程都有一个当前的 umask 值。当进行 fork 时,子进程会继承父进程的 umask 值。

例如,通过在 shell 中设置 umask,确保未来创建的文件和目录只能被当前用户访问,

$ umask 077
$ mkdir secretdir

作为一个代码示例,假设创建了一个新文件,其权限位为(用户、组和其他的读写位):

open("myfile", O_CREAT, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH);

如果 umask 是八进制,那么创建的文件的权限将是 & ~,例如。

S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH

“setuid”位

你可能已经注意到,具有执行权限的文件可能设置了额外的位。这个位是粘性位。它表示当运行程序时,程序将用户的 uid 设置为文件所有者的 uid。类似地,还有一个位将执行者的 gid 设置为所有者的 gid。具有 set 的程序的经典示例是。

通常是由 root 用户拥有的程序 - 拥有所有权限的用户。通过使用,一个原本无特权的用户可以访问系统的绝大部分。这对于运行可能需要提升权限的程序很有用,例如使用来更改文件的所有权,或者使用来挂载或卸载文件系统(我们将在本章后面讨论这一动作)。以下是一些示例:

$ sudo mount /dev/sda2 /stuff/mydisk
$ sudo adduser fred
$ ls -l /usr/bin/sudo
-r-s--x--x  1 root  wheel  327920 Oct 24 09:04 /usr/bin/sudo

当以设置了 setuid 位的进程执行时,仍然可以使用来确定用户的原始 uid。位的确切动作是设置有效用户 ID(),这可以通过来确定。和的动作描述如下。

  • 返回真实用户 ID(如果以 root 登录,则为零)

  • 返回有效用户 ID(如果以 root 身份操作,则为零,例如由于程序上设置了 setuid 标志)

这些函数可以允许编写一个只能由特权用户运行的程序,通过检查或更进一步,确保唯一可以运行代码的用户是 root,使用。

粘性位

我们今天使用的粘性位与最初的引入目的不同。粘性位是可以设置在可执行文件上的位,允许程序文本段在程序执行结束后仍然保留在交换空间中。这使得相同程序的后续执行更快。今天,这种行为不再被支持,粘性位仅在设置为目录时才有意义,

当目录的粘性位被设置时,只有文件的所有者、目录的所有者和 root 用户可以重命名或删除文件。这在多个用户对公共目录有写访问权限时很有用。粘性位的一个常见用途是共享和可写目录,其中可能存储了许多用户的文件,但用户不应能够访问其他用户的所有文件。

要设置粘性位,使用。

aneesh$ mkdir sticky
aneesh$ chmod +t sticky
aneesh$ ls -l
drwxr-xr-x  7 aneesh aneesh    4096 Nov  1 14:19 .
drwxr-xr-x 53 aneesh aneesh    4096 Nov  1 14:19 ..
drwxr-xr-t  2 aneesh aneesh    4096 Nov  1 14:19 sticky
aneesh$ su newuser
newuser$ rm -rf sticky
rm: cannot remove 'sticky': Permission denied
newuser$ exit
aneesh$ rm -rf sticky
aneesh$ ls -l
drwxr-xr-x  7 aneesh aneesh    4096 Nov  1 14:19 .
drwxr-xr-x 53 aneesh aneesh    4096 Nov  1 14:19 ..

注意,在上面的例子中,用户名被添加到提示符前面,并使用命令来切换用户。

虚拟文件系统和其他文件系统

POSIX 系统,如 Linux 和 Mac OS X(基于 BSD),包括几个作为文件系统一部分挂载(可用)的虚拟文件系统。这些虚拟文件系统中的文件可能是动态生成的或存储在内存中。Linux 提供了 3 个主要的虚拟文件系统。

虚拟文件系统列表

设备 用例
物理和虚拟设备列表(例如网卡、光盘、随机数发生器)
每个进程使用的资源列表和(传统上)系统信息集
内核内部实体的有序列表

如果我们想要一个连续的 0 流,我们可以运行 。

另一个例子是文件 ,一个存储你永远不会需要读取的位的好地方。发送到 的字节永远不会存储,只是简单地丢弃。 的一个常见用途是丢弃标准输出。例如,

$ ls . >/dev/null

管理文件和文件系统

考虑到您可以从文件系统执行的大量操作,让我们探索一些可以用来管理文件和文件系统的工具和技术。

一个例子是创建一个安全的目录。假设你在 /tmp 中创建了自己的目录,然后设置了权限,使得只有你可以使用该目录(见下文)。这是安全的吗?

$ mkdir /tmp/mystuff
$ chmod 700 /tmp/mystuff

在创建目录和更改其权限之间有一个机会窗口。这导致基于竞争条件的好几个漏洞。

另一个用户用指向第二个用户拥有的现有文件或目录的硬链接替换,然后他们就能够读取和控制目录的内容。哦不——我们的秘密不再是秘密了!

然而,在这个特定例子中,目录设置了粘滞位,所以只有所有者才能删除目录,上述简单攻击场景是不可能的。这并不意味着创建目录然后后来将其设置为私有就是安全的!更好的版本是从一开始就原子性地创建具有正确权限的目录。

$ mkdir -m 700 /tmp/mystuff

获取随机数据

是一个包含随机数发生器的文件,其熵由环境噪声确定。 随机会阻塞/等待直到从环境中收集到足够的熵。

它像随机一样,但不同之处在于它允许重复(较低的熵阈值),因此不会阻塞。

可以将这两个都视为程序可以从中读取字符流,而不是具有起始和结束位置的文件。为了触及一个误解,大多数时候应该使用 .。唯一的特定用例是当需要在启动时需要加密安全的数据并且系统应该阻止时。否则,有以下原因。

  1. 实际上,它们都产生了看起来足够随机的数字。

  2. 可能在不方便的时候阻塞。如果一个服务正在编程一个高可扩展性的服务并依赖于,攻击者可以可靠地耗尽熵池并导致服务阻塞。

  3. 手册页作者提出了一个假设攻击,其中攻击者耗尽熵池并猜测种子位,但这种攻击尚未实现。

  4. 一些操作系统没有真正的类似 MacOS 的cp命令。

  5. 安全专家会讨论计算安全与信息论安全,更多内容请参阅这篇文章关于 Urandom 的神话。大多数加密都是计算安全的,这意味着也是如此。

复制文件

使用通用的命令。例如,以下命令从文件复制 1 MiB 的数据到文件。数据以 1024 个大小为 1024 字节的块复制。

$ dd if=/dev/urandom of=/dev/null bs=1k count=1024

上面示例中的输入和输出文件都是虚拟的——它们不存在于磁盘上。这意味着传输速度不受硬件性能的影响。

也常用于复制磁盘或整个文件系统以创建可以烧录到其他磁盘或分发给其他用户的镜像。

更新修改时间

可执行文件如果不存在则创建文件,并更新文件的最后修改时间为当前时间。例如,我们可以使用当前时间创建一个新的私有文件:

 $ umask 077       # all future new files will mask out all r,w,x bits for group and other access
 $ touch file123   # create a file if it non-existant, and update its modified time
 $ stat file123
 File: `file123'
 Size: 0           Blocks: 0          IO Block: 65536  regular empty file
 Device: 21h/33d Inode: 226148      Links: 1
 Access: (0600/-rw-------)  Uid: (395606/ angrave)   Gid: (61019/     ews)
 Access: 2014-11-12 13:42:06.000000000 -0600
 Modify: 2014-11-12 13:42:06.001787000 -0600
 Change: 2014-11-12 13:42:06.001787000 -0600

touch的一个示例用法是强制make重新编译在makefile内部修改编译选项后未更改的文件。记住,make是“懒惰”的——它会比较源文件的修改时间与相应的输出文件,以查看文件是否需要重新编译。

 $ touch myprogram.c   # force my source file to be recompiled
 $ make

管理文件系统

要管理机器上的文件系统,使用mount。使用不带任何选项的mount会生成一个挂载的文件系统列表(每行一个文件系统),包括网络、虚拟和本地(旋转磁盘/基于 SSD)文件系统。以下是mount的典型输出。

 $ mount
 /dev/mapper/cs341--server_sys-root on / type ext4 (rw)
 proc on /proc type proc (rw)
 sysfs on /sys type sysfs (rw)
 devpts on /dev/pts type devpts (rw,gid=5,mode=620)
 tmpfs on /dev/shm type tmpfs (rw,rootcontext="system_u:object_r:tmpfs_t:s0")
 /dev/sda1 on /boot type ext3 (rw)
 /dev/mapper/cs341--server_sys-srv on /srv type ext4 (rw)
 /dev/mapper/cs341--server_sys-tmp on /tmp type ext4 (rw)
 /dev/mapper/cs341--server_sys-var on /var type ext4 (rw)rw,bind)
 /srv/software/Mathematica-8.0 on /software/Mathematica-8.0 type none (rw,bind)
 engr-ews-homes.engr.illinois.edu:/fs1-homes/angrave/linux on /home/angrave type nfs (rw,soft,intr,tcp,noacl,acregmin=30,vers=3,sec=sys,sloppy,addr=128.174.252.102)

注意,每一行都包括文件系统的类型、源和挂载点。为了减少输出,我们可以将其管道输入到grep,只查看匹配正则表达式的行。

 >mount | grep proc  # only see lines that contain 'proc'
 proc on /proc type proc (rw)
 none on /proc/sys/fs/binfmt_misc type binfmt_misc (rw)

文件系统挂载

假设您已从arch linux 下载页面下载了一个可启动的 Linux 磁盘镜像。

 $ wget $URL

在将文件系统放在 CD 上之前,我们可以将文件挂载为文件系统并探索其内容。注意:mount需要 root 权限,所以让我们使用sudo来运行它。

 $ mkdir arch
 $ sudo mount -o loop archlinux-2015.04.01-dual.iso ./arch
 $ cd arch

mount命令之前,arch目录是新的,显然是空的。挂载后,内容将从存储在文件系统内部的文件和目录中抽取。这个选项是必需的,因为我们想要挂载一个常规文件,而不是像物理磁盘这样的块设备。

循环选项将原始文件包装为块设备。在本例中,我们将在下面发现文件系统是在提供的。我们可以通过运行不带任何参数的 mount 命令来检查文件系统类型和挂载选项。我们将输出通过管道传输到,以便我们只看到包含‘arch’的相关输出行。

$ mount | grep arch
/home/demo/archlinux-2014.11.01-dual.iso on /home/demo/arch type iso9660 (rw,loop=/dev/loop0)

iso9660 文件系统是一个最初为光盘存储媒体(即 CDRoms)设计的只读文件系统。尝试更改文件系统的内容将失败

$ touch arch/nocando
touch: cannot touch `/home/demo/arch/nocando': Read-only file system

内存映射 I/O

虽然我们传统上认为从文件中读取和写入是一个通过使用和调用来发生的操作,但有一个替代方案,即使用映射文件到内存。这也可以用于 IPC,你可以在 IPC 章节中了解更多关于作为启用共享内存的系统调用的信息。在本章中,我们将简要探讨作为文件系统操作的使用。

将文件的内容映射到内存中。这使用户可以将整个文件作为内存中的缓冲区来处理,以便在编程时更容易理解语义,并避免显式地按离散块读取文件。

并非所有文件系统都支持使用它进行 I/O 操作。那些支持使用的文件系统行为各异。有些只是将和作为包装器来实现。而有些则会通过利用内核的页面缓存来添加额外的优化。当然,这种优化也可以用于和的实现,因此通常使用它们的性能是相同的。

用于执行一些操作,如将库和进程加载到内存中。如果许多程序只需要对同一文件进行读取访问,则可以在多个进程之间共享相同的物理内存。这用于常见的库,如 C 标准库。

将文件映射到内存的过程如下。

  1. 需要一个文件描述符,因此我们需要先打开文件

  2. 我们寻找所需的大小,并写入一个字节以确保文件长度足够。

  3. 完成后,调用 munmap 从内存中取消映射文件。

这里有一个快速示例。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

int fail(char *filename, int linenumber) {
 fprintf(stderr, "%s:%d %s\n", filename, linenumber, strerror(errno));
 exit(1);
 return 0; /*Make compiler happy */
}
#define QUIT fail(__FILE__, __LINE__ )

int main() {
 // We want a file big enough to hold 10 integers
 int size = sizeof(int) * 10;

 int fd = open("data", O_RDWR | O_CREAT | O_TRUNC, 0600); //6 = read+write for me!

 lseek(fd, size, SEEK_SET);
 write(fd, "A", 1);

 void *addr = mmap(0, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
 printf("Mapped at %p\n", addr);
 if (addr == (void*) -1 ) QUIT;

 int *array = addr;
 array[0] = 0x12345678;
 array[1] = 0xdeadc0de;

 munmap(addr,size);
 return 0;

}

仔细的读者可能会注意到,我们使用的是最低有效字节格式来编写整数,因为这是我们运行此示例的 CPU 的字节序。我们还分配了一个比一个字节多的文件!选项指定了虚拟内存保护。选项(在此未使用)可以设置为允许 CPU 执行内存中的指令。

可靠的单硬盘文件系统

大多数文件系统都会在物理内存中缓存大量的磁盘数据。在这方面,Linux 是极端的。所有未使用的内存都用作巨大的磁盘缓存。磁盘缓存可以对整体系统性能产生重大影响,因为磁盘 I/O 速度慢。这对于旋转磁盘上的随机访问请求尤其如此,其中磁盘读写延迟主要由将读写磁盘头移动到正确位置所需的寻址时间决定。

为了效率,内核缓存最近使用的磁盘块。对于写入,我们必须在性能和可靠性之间做出权衡。磁盘写入也可以被缓存(“写回缓存”),修改后的磁盘块存储在内存中,直到被移除。或者,可以采用“写入通过缓存”策略,其中磁盘写入立即发送到磁盘。后者更安全,因为文件系统修改迅速存储到持久介质中,但比写回缓存慢。如果写入被缓存,则可以根据每个磁盘块的物理位置延迟和高效地调度。注意,这是一个简化的描述,因为固态驱动器(SSD)可以用作二级写回缓存。

固态硬盘(SSD)和旋转硬盘在读取或写入顺序数据时都提高了性能。因此,操作系统通常可以使用预读策略来分摊读取请求的成本,并请求每个请求的几个连续磁盘块。通过在用户应用程序需要下一个磁盘块之前发出对下一个磁盘块的 I/O 请求,可以减少明显的磁盘 I/O 延迟。

如果你的数据很重要,需要强制写入磁盘,可以调用请求将文件系统的更改写入(刷新)到磁盘。然而,操作系统可能会忽略这个请求。即使数据被从内核缓冲区中移除,磁盘固件可能会使用内部磁盘缓存,或者可能尚未完成对物理介质的更改。注意,你也可以请求使用 . 将与特定文件描述符相关联的所有更改刷新到磁盘。关于这个调用是否无用的争论很激烈,由 PostgreSQL 团队发起 lwn.net/Articles/752063/

如果你的操作系统在操作过程中失败,大多数现代文件系统会采取一种称为日志记录的措施来解决这个问题。文件系统所做的是,在它完成一个可能昂贵的操作之前,将它的操作记录下来。在发生崩溃或故障的情况下,可以逐步检查日志,查看哪些文件已损坏并修复它们。这是一种在存在关键数据且没有明显备份的情况下挽救硬盘的方法。

即使你的计算机不太可能发生这种情况,为数据中心编程意味着磁盘每几秒钟就会失败。磁盘故障使用“平均故障时间(MTTF)”来衡量。对于大型阵列,平均故障时间可能出人意料地短。如果 MTTF(单个磁盘)= 30,000 小时,那么 MTTF(1000 个磁盘)= 30,000/1000=30 小时,大约是一天半!这也假设磁盘之间的故障是独立的,而它们通常不是。

RAID - 红 undant Array of Inexpensive Disks

防止这种情况的一种方法是将数据存储两次!这是“RAID-1”磁盘阵列的主要原则。通过将写入磁盘的数据与写入另一个备份磁盘的数据进行复制,数据恰好有两个副本。如果一个磁盘发生故障,另一个磁盘将作为唯一的副本,直到可以重新克隆。读取数据更快,因为可以从任一磁盘请求数据,但写入可能慢两倍,因为现在每个磁盘块写入都需要发出两个写入命令。与使用单个磁盘相比,每字节的存储成本翻倍。

另一个常见的 RAID 方案是 RAID-0,这意味着文件可以在两个磁盘之间分割,但如果任何磁盘发生故障,文件将无法恢复。这的好处是写入时间减半,因为文件的一部分可以写入硬盘一,另一部分写入硬盘二。

也常见将这些系统结合起来。如果你有很多硬盘,可以考虑 RAID-10。这是有两个 RAID-1 系统,但系统通过 RAID-0 相互连接。这意味着你会从减速中获得大致相同的速度,但现在任何一块磁盘都可以失败,你可以恢复那块磁盘。如果来自对立 RAID 分区的两块磁盘发生故障,有恢复的机会,尽管我们大多数时候并不指望它。

更高的 RAID 级别

RAID-3 使用奇偶校验码而不是镜像数据。对于每个写入的 N 位,我们将写入一个额外的位,即“奇偶校验位”,确保写入的 1 的总数是偶数。奇偶校验位写入到额外的磁盘。如果包括奇偶校验磁盘在内的任何磁盘丢失,则可以使用其他磁盘的内容计算其内容。

RAID-3 的一个缺点是,每当写入磁盘块时,奇偶校验块也总是会被写入。这意味着实际上有一个瓶颈在单独的磁盘上。在实践中,这更有可能导致故障,因为一个磁盘被 100%使用,一旦该磁盘发生故障,其他磁盘就更容易发生故障。

单个磁盘故障是可恢复的,因为剩余的磁盘中有足够的数据可以重建阵列。当两个磁盘无法使用时,将发生数据丢失,因为不再有足够的数据来重建阵列。我们可以根据修复时间来计算两个磁盘故障的概率,修复时间考虑了插入新磁盘的时间和重建整个阵列内容所需的时间。

MTTF = mean time to failure
MTTR = mean time to repair
N = number of original disks

p = MTTR / (MTTF-one-disk / (N-1))

使用典型数字(MTTR=1 天,MTTF=1000 天,N-1=9,p=0.009)

在重建过程中,有 1%的概率另一个驱动器会失败(此时你最好希望你的原始数据仍然有一个可访问的备份。在实践中,修复过程中第二次故障的概率可能更高,因为重建阵列是 I/O 密集型的(并且还在正常的 I/O 请求活动之上)。这种更高的 I/O 负载也会对磁盘阵列造成压力。

RAID-5 与 RAID-3 类似,但校验块(奇偶校验信息)分配给不同磁盘的不同块。校验块在磁盘阵列中“旋转”。RAID-5 比 RAID-3 提供了更好的读写性能,因为没有单校验磁盘的瓶颈。唯一的缺点是,你需要更多的磁盘来设置这个环境,并且需要使用更复杂的算法。

失败是常见的。谷歌报告称,每年有 2-10%的磁盘会损坏。在一个仓库中,有 60,000 多个磁盘,这个数字乘以去,服务必须能够承受单个磁盘、服务器机架或整个数据中心故障。

解决方案

简单冗余(每个文件的 2 或 3 个副本),例如,谷歌 GFS(2001)。更高效的冗余(类似于 RAID 3++),例如,谷歌 Colossus 文件系统(约 2010 年):可定制的复制,包括具有 1.5 倍冗余的里德-所罗门码

简单的文件系统模型

软件开发者需要不断实现文件系统。如果你对此感到惊讶,我们鼓励你看看 Hadoop、GlusterFS、Qumulo 等。截至 2018 年,文件系统是研究的热点领域,因为人们已经意识到我们设计的软件模型没有充分利用我们当前的硬件。此外,我们用于存储信息的硬件也在不断改进。因此,你可能会有一天自己设计文件系统。在本节中,我们将介绍一个假想的文件系统,并“走过”一些工作原理的例子。

那么,我们的假设文件系统看起来是什么样子呢?我们将基于它,这是一个简单的文件系统,碰巧是 Linux 运行的第一个文件系统。它在磁盘上顺序排列,第一部分是超级块。超级块存储有关整个文件系统的关键元数据。由于我们希望在了解磁盘上的其他数据之前能够读取此块,因此它需要位于一个已知的位置,因此磁盘的开始是一个不错的选择。在超级块之后,我们将保留一个记录哪些 inode 正在使用的映射。如果第 n 个 inode(inode 根为00)正在使用,则第 n 位被设置。同样,我们存储一个记录哪些数据块正在使用的映射。最后,我们有一个 inode 数组,然后是磁盘的其余部分 - 隐式地划分为数据块。从磁盘的硬件组件的角度来看,一个数据块可能和下一个数据块相同。将磁盘视为数据块的数组只是我们为了有一个描述文件在磁盘上位置的方法而做的事情。

下面是一个描述文件的 inode 可能看起来怎样的例子。请注意,为了简化,我们画了箭头,将 inode 中的数据块编号映射到磁盘上的位置。这些不是指针,更多的是数组中的索引。

样本文件填充

样本文件填充

我们将假设数据块的大小为 4 KiB。

注意,文件在请求额外的数据块之前,会完全填满其数据块。我们将把这个特性称为文件是紧凑的。上面展示的文件很有趣,因为它使用了所有的直接块,间接块中的一个条目,以及部分使用了另一个间接块。

以下小节将全部参考上面展示的文件。

文件大小与磁盘空间

我们文件的大小必须存储在 inode 中。文件系统不知道文件实际内容的细节——这些数据被认为是用户的,并且只能由用户操作。然而,我们可以通过查看文件使用的块的数量来计算文件大小的上下限。

有两个完整的直接块,它们共同存储 2sizeof(data_block)=24KiB=8KiB2sizeof(data_block)=24KiB=8KiB.

间接块引用了两个已使用的块,根据上述计算,可以存储高达 8KiB8KiB

我们现在可以将这些值相加,以得到文件大小的上限,即 16KiB16KiB.

那么下限是多少呢?我们知道我们必须使用两个直接块,一个由间接块引用的块,以及至少 1 字节由间接块引用的第二个块。有了这些信息,我们可以计算出下限为 24KiB+4KiB+1=12KiB+1B24KiB+4KiB+1=12KiB+1B.

注意,到目前为止,我们的计算是为了确定用户在磁盘上存储了多少数据。那么,使用该文件系统存储这些数据所产生 开销 呢?你会注意到我们使用间接块来存储超出两个直接块使用的磁盘块号。在我们上面的计算中,我们忽略了这块。这将作为文件的开销来计算,因此,在磁盘上存储此文件的总开销是 sizeof(indirect_block)=4KiB)sizeof(indirect_block)=4KiB)

考虑到开销,一个相关的计算可以是确定该文件系统中每个文件的最大/最小磁盘占用。

显然,大小为 00 的文件没有关联的数据块,并且在磁盘上不占用任何空间(忽略 inode 所需的空间,因为这些位于磁盘上的某个固定大小的数组中)。那么,最小非空文件占用的磁盘空间是多少呢?也就是说,考虑一个大小为 1B1B 的文件。请注意,当用户写入第一个字节时,会分配一个数据块。由于每个数据块的大小是 4KiB4KiB,我们发现 4KiB4KiB 是非空文件的最小磁盘占用。在这里,我们观察到文件大小将是 1B1B,尽管使用了 4KiB4KiB 的磁盘空间——文件大小和磁盘占用之间的区别是由于开销!

寻找最大值稍微复杂一些。正如我们在本章前面看到的,具有这种结构的文件系统可以在一个间接块中有10241024个数据块号。这意味着最大文件大小可以是24KiB+10244KiB=4MiB+8KiB24KiB + 10244KiB = 4MiB + 8KiB(在考虑直接块之后)。然而,在磁盘上我们也会存储间接块本身。这意味着还需要额外的4KiB4KiB开销来处理间接块,所以总磁盘使用量将是4MiB+12KiB4MiB + 12KiB

注意,当只使用直接块时,完全填满一个直接块意味着我们的文件大小和磁盘使用量是同一件事!虽然这似乎是我们总是想要的理想情况,但它对最大文件大小施加了限制性限制。通过增加直接块的数量来尝试解决这个问题似乎很有希望,但请注意,这需要增加 inode 的大小并减少存储用户数据的空间量——这是一个你必须自己评估的权衡。或者,始终尝试将数据分成永远不会使用间接块的块,可能会耗尽有限的 inode 池。

执行读取操作

在我们的文件系统中执行读取操作通常相当简单,因为我们的文件很紧凑。假设我们想要读取这个特定文件的全部内容。我们首先会去 inode 的直接结构中找到第一个直接数据块号。在我们的例子中,它是#7。然后我们从所有数据块的开始位置找到第 7 个数据块。然后我们读取所有这些字节。对于所有的直接节点,我们都会做同样的事情。接下来我们做什么?我们转到间接块并读取间接块。我们知道间接块中的每 4 个字节要么是一个哨兵节点(-1),要么是另一个数据块的编号。在我们的特定例子中,前四个字节计算出的整数是 5,这意味着我们的数据从开始的第 5 个数据块继续。我们对数据块#4 也做同样的处理,然后停止,因为我们已经超过了 inode 的大小。

现在,让我们考虑边缘情况。给定块大小为4KiBs4 KiBs,程序如何从任意偏移量nn字节开始读取?如果文件系统是正确的,应该有多少个间接块?(提示:考虑使用 inode 的大小

执行写入操作

写入文件

执行写入操作分为两类,写入文件和写入目录。首先,我们将关注文件,并假设我们要向文件的66th KiB 写入一个字节。要在特定偏移量处对文件执行写入操作,首先文件系统必须到达从该偏移量开始的数据块。在这个特定的例子中,我们必须到达第 2 个或索引编号为 1 的 inode 来执行我们的写入。我们再次从这个 inode 中获取这个数字,到达数据块的根,到达55th 数据块,并从这个块的22KiB 偏移量处执行写入操作,因为我们跳过了块 7 中的前四个 kibibytes 的文件。我们执行写入操作,然后继续我们的快乐之旅。

一些需要考虑的问题。

  • 程序如何跨数据块边界执行写入操作?

  • 在添加偏移量后,如果会扩展文件长度,程序将如何执行写入操作?

  • 如果偏移量大于原始文件长度,程序将如何执行写入操作?

写入目录

向目录写入意味着需要在目录中添加一个 inode。如果我们假设上面的例子是一个目录。我们知道我们每次最多只能添加一个目录条目。这意味着我们必须在我们的数据块中有足够的空间来容纳一个目录条目。幸运的是,我们拥有的最后一个数据块有足够的空闲空间。这意味着我们需要找到最后一个数据块的编号,就像上面做的那样,到达数据结束的地方,并写入一个目录条目。别忘了更新目录的大小,这样下一次创建就不会覆盖你的文件!

一些更多的问题:

  • 当最后一个数据块已经满时,程序将如何执行写入操作?

  • 当所有直接块都已填满且 inode 没有间接块时怎么办?

  • 当第一个间接条目(#4)已满时怎么办?

添加删除

如果 inode 是一个文件,那么通过将其标记为无效(可能使其指向 inode -1)来在父目录中删除目录条目,并在读取时跳过它。文件系统会减少 inode 的硬链接计数,如果计数达到零,则在 inode 映射中释放 inode,并释放所有相关数据块,以便它们被文件系统回收。在许多操作系统中,inode 中的几个字段会被覆盖。

如果 inode 是一个目录,文件系统会检查它是否为空。如果不为空,那么内核很可能会标记一个错误。

一定要查看附录,了解现代和前沿的文件系统。

主题

  • 超块

  • 数据块

  • Inode

  • 相对路径

  • 文件元数据

  • 硬链接和软链接

  • 权限位

  • 权限位

  • 与目录一起工作

  • 虚拟文件系统

  • 可靠的文件系统

  • 磁盘阵列(RAID)

问题

  • 在一个有 15 个直接块、2 个双间接块、3 个三间接块、4kb 块和 4 字节条目的文件系统中,文件能有多大?(假设有足够的无限块)

  • 超块是什么?inode?数据块?

  • 我们如何简化/

  • 在 ext2 中,inode 中存储了什么,目录条目中存储了什么?

  • /sys、/proc、/dev/random 和/dev/urandom 是什么?

  • 权限位是什么?

  • 如何使用 chmod 设置用户/组/所有者读/写/执行权限?

  • “dd”命令的作用是什么?

  • 硬链接和软链接有什么区别?文件需要存在吗?

  • “ls -l”显示目录中每个文件的大小。这个大小是存储在目录中还是在文件的 inode 中?

信号

信号是一种方便的方式,用于传递低优先级信息,并在其他方式不起作用时(例如标准输入被冻结)与用户程序交互。它们允许程序在事件发生时进行清理或执行操作。有时,程序可以选择忽略事件,这是受支持的。由于信号的处理方式,编写使用信号良好的程序是棘手的。因此,信号通常用于终止和清理。很少在编程逻辑中使用信号。

对于那些有建筑背景的你们来说,这里使用的中断并不是由硬件生成的中断。这些中断几乎总是由内核处理,因为它们需要更高级别的权限。相反,我们谈论的是由内核生成的软件中断——尽管它们可能是对硬件事件(如 SIGSEGV)的响应。

本章将介绍如何从已退出或被信号处理的进程读取信息。然后,它将深入探讨什么是信号,内核如何处理信号,以及进程如何处理信号的各种方式,无论是带线程还是不带线程。

信号深入探讨

信号允许一个进程异步地将事件或消息发送到另一个进程。如果该进程想要接受信号,它可以,然后,对于大多数信号,决定如何处理该信号。

首先,一些术语。信号处置是每个进程的属性,它决定了信号在传递后被如何处理。将其视为信号-动作对的表。完整的讨论在手册页中。动作包括

  1. ,终止进程

  2. ,忽略

  3. ,生成核心转储

  4. ,停止一个进程

  5. ,继续一个进程

  6. 执行自定义函数。

信号掩码确定特定信号是否被传递。内核发送信号的整体过程如下。

  1. 如果没有信号到达,进程可以安装自己的信号处理程序。这告诉内核,当进程收到信号 X 时,它应该跳转到函数 Y。

  2. 创建的信号处于“生成”状态。

  3. 从信号生成到内核可以应用掩码规则之间的时间称为挂起状态。

  4. 然后,内核会检查进程的信号掩码。如果掩码表明进程中的所有线程都在阻塞信号,那么信号目前被阻塞,直到某个线程解除阻塞之前不会发生任何事情。

  5. 如果单个线程可以接受信号,那么内核将执行处置表中的操作。如果操作是默认操作,则不需要暂停任何线程。

  6. 否则,内核通过停止特定线程当前正在执行的操作,并将该线程跳转到信号处理程序来传递信号。信号现在处于传递阶段。现在可以生成更多信号,但它们必须在信号处理程序完成之前传递,这时传递阶段才结束。

  7. 最后,我们考虑如果进程在信号传递后仍然完好无损,则认为该信号已被捕获。

作为流程图

信号生命周期图

信号生命周期图

这里有一些你可能会看到的常见信号。

|c|c|c| 名称 & 可移植编号 & 默认动作 & 常用用途

SIGINT & 2 & 终止(可捕获)& 优雅地停止一个进程

SIGQUIT & 3 & 终止(可捕获)& 严厉地停止一个进程

SIGTERM & 15 & 终止进程 & 更严厉地停止一个进程

SIGSTOP & N/A & 停止进程(无法捕获)& 暂停一个进程

SIGCONT & N/A & 继续进程 & 停止后启动

SIGKILL & 9 & 终止进程(无法捕获)& 你希望进程消失

我们最喜欢的一个轶事是,出于众多原因,永远不要使用它。以下是从链接到存档摘录的内容。

不,不,不。不要使用 kill -9。

它不会给进程一个干净的机会:

  1. 关闭套接字连接

  2. 清理临时文件

  3. 通知其子进程它即将离开

  4. 重置其终端特性

等等。

通常,发送 15,等待一秒钟或两秒钟,如果不起作用,发送 2,如果不起作用,发送 1。如果还不行,删除二进制文件,因为程序表现不佳!

不要使用 kill -9。不要为了整理花盆而拿出联合收割机。

我们仍然保留它,以应对极端情况,其中进程需要消失。

发送信号

信号可以通过多种方式生成。

  1. 用户可以发送一个信号。例如,你正在终端上,你按下了。也可以使用内置的命令来发送任何信号。

  2. 系统可以发送一个事件。例如,如果一个进程访问了它不应该访问的页面,硬件会生成一个中断,该中断被内核拦截。内核找到导致此中断的进程,并发送一个软件中断信号。还有其他内核事件,比如创建子进程或进程需要恢复。

  3. 最后,另一个进程可以发送消息。这可以用于进程之间低风险的事件通信。如果你依赖信号作为你程序中的驱动,你应该重新考虑你的应用程序设计。使用 POSIX/实时信号进行异步通信有很多缺点。处理进程间通信的最佳方式是使用,嗯,专门为你的任务设计的进程间通信方法。

你或另一个进程可以通过发送信号暂时暂停一个正在运行的进程。如果成功,它将冻结进程。进程将不再分配任何 CPU 时间。为了允许进程恢复执行,发送 SIGCONT 信号。例如,以下是一个程序,它每秒缓慢打印一个点,最多打印 59 个点。

#include <unistd.h>
#include <stdio.h>
int main() {
 printf("My pid is %d\n", getpid() );
 int i = 60;
 while(--i) {
 write(1, ".",1);
 sleep(1);
 }
 write(1, "Done!",5);
 return 0;
}

我们首先在后台启动进程(注意末尾的&)。然后,通过使用 kill 命令从 shell 进程向它发送信号。

$ ./program &
My pid is 403
...
$ kill -SIGSTOP 403
$ kill -SIGCONT 403
...

在 C 语言中,程序可以使用 POSIX 调用向子进程发送信号,

kill(child, SIGUSR1); // Send a user-defined signal
kill(child, SIGSTOP); // Stop the child process (the child cannot prevent this)
kill(child, SIGTERM); // Terminate the child process (the child can prevent this)
kill(child, SIGINT); // The equivalent to CTRL-C (by default closes the process)

如上所述,shell 中也有一个可用的命令。另一个命令的工作方式完全相同,但它不是通过 PID 查找,而是尝试匹配进程的名称。是一个重要的实用工具,可以帮助你找到进程的 PID。

# First let's use ps and grep to find the process we want to send a signal to
$ ps au | grep myprogram
angrave  4409   0.0  0.0  2434892    512 s004  R+    2:42PM   0:00.00 myprogram 1 2 3

#Send SIGINT signal to process 4409 (The equivalent of `CTRL-C`)
$ kill -SIGINT 4409

# Send SIGKILL (terminate the process)
$ kill -SIGKILL 4409
$ kill -9 4409
# Use kill all instead to kill a process by executable name
$ killall -l firefox

向正在运行的过程发送信号,使用或。

raise(int sig); // Send a signal to myself!
kill(getpid(), int sig); // Same as above

对于非 root 进程,信号只能发送到同一用户的进程。你不能对任何进程发送 SIGKILL!更多详情。

处理信号

在信号处理程序内部的可执行代码有严格的限制。大多数库和系统调用都是不可重入的,这意味着它们不能在信号处理程序内部使用,因为它们不是可重入的。可重入安全性意味着你的函数可以在任何点被冻结并再次执行,你能保证你的函数不会失败吗?让我们看看以下

int func(const char *str) {
 static char buffer[200];
 strncpy(buffer, str, 199);
 # Here is where we get paused
 printf("%s\n", buffer)
}
  1. 我们执行 func("Hello")

  2. 字符串被完全复制到缓冲区中 (strcmp(buffer, "Hello") == 0)

  3. 当一个信号被传递并且函数状态被冻结时,我们也会停止接受任何新的信号,直到处理程序之后(我们这样做是为了方便)

  4. 我们执行

  5. 现在 (strcmp(buffer, "World") == 0) 并且输出缓冲区为 "World"。

  6. 我们恢复中断的函数,现在再次打印出缓冲区 "World",而不是函数调用最初打算的 "Hello"

通过移除共享缓冲区来保证你的函数是信号处理程序安全的并不能解决问题。你还必须考虑多线程和同步——当我双重锁定互斥锁时会发生什么?你还得确保每个函数调用都是可重入安全的。假设你的原始程序在执行库代码时被中断。malloc 使用的内存结构将不一致。在信号处理程序中使用,作为信号处理程序的一部分,是不安全的,并会导致未定义行为。避免这种行为的一种安全方式是设置一个变量,并让程序继续运行。设计模式也有助于我们设计能够接收两次信号并正确操作的程序。

int pleaseStop ; // See notes on why "volatile sig_atomic_t" is better

void handle_sigint(int signal) {
 pleaseStop = 1;
}

int main() {
 signal(SIGINT, handle_sigint);
 pleaseStop = 0;
 while (!pleaseStop) {
 /* application logic here */
 }
 /* clean up code here */
}

上述代码在纸上可能看起来是正确的。然而,我们需要向编译器和将要执行循环的 CPU 核心提供提示。我们需要防止编译器优化。表达式在循环体中不会改变,因此一些编译器可能会将其优化为 TODO: 需要引用。其次,我们需要确保使用 CPU 寄存器来未缓存 的值,而不是总是从主内存中读取和写入。类型意味着变量的所有位都可以作为 - 单个不可中断操作来读取或修改。不可能读取由一些新位值和旧位值组成的值。

通过指定正确的类型 ,我们可以编写可移植的代码,其中主循环将在信号处理程序返回后退出。在大多数现代平台上,类型可以与一样大,但在嵌入式系统中可以小到 ,只能表示 (-127 到 127) 的值。

volatile sig_atomic_t pleaseStop;

这种模式的两个例子可以在基于终端的 1Hz 4bit 计算机中找到(Šorn 2015)。使用两个布尔标志。一个用于标记(CTRL-C)的传递,并优雅地关闭程序,另一个用于标记信号以检测终端大小调整并重新绘制整个显示。

你也可以选择异步或同步地处理挂起的信号。要异步处理信号,请使用 。要同步捕获挂起的信号,请使用 ,它阻塞直到信号传递或它也阻塞并提供一个文件描述符,可以从中检索挂起的信号。

Sigaction

你应该使用 而不是 ,因为它具有更好的语义定义。在不同的操作系统上做不同的事情是 不好的。 更具有可移植性,并且对线程来说定义得更好。你可以使用系统调用来设置信号或读取特定信号的当前处理程序和处置。

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

sigaction 结构体包括两个回调函数(我们只看‘handler’版本),一个信号掩码和一个标志字段 -

struct sigaction {
 void     (*sa_handler)(int);
 void     (*sa_sigaction)(int, siginfo_t *, void *);
 sigset_t   sa_mask;
 int        sa_flags;
};

假设你遇到了使用 . 的遗留代码。以下代码片段将其安装为 SIGALRM 处理程序。

signal(SIGALRM, myhandler);

相当的代码是:

struct sigaction sa;
sa.sa_handler = myhandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGALRM, &sa, NULL)

然而,我们通常也可以设置掩码和标志字段。掩码是在信号处理程序执行期间使用的临时信号掩码。如果服务信号的线程在系统调用中间被中断,标志将自动重新启动一些否则会因 EINTR 错误而提前返回的系统调用。后者意味着我们可以稍微简化一些代码,因为可能不再需要重启循环。

sigfillset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; /* Restart functions if interrupted by handler */

由于标志的选择性,通常更好的做法是让代码检查错误并自行重启。

阻塞信号

要阻塞信号,请使用 ! 使用 sigprocmask,您可以设置新的掩码,将新的被阻塞信号添加到进程掩码中,并取消当前被阻塞的信号。您还可以通过传递非空值给 oldset 来确定现有的掩码(并用于以后)。

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

从 sigprocmask 的 Linux 手册页中,以下是可能的值 TODO: 引用。

  • . 被阻塞的信号集合是当前集合和参数集合的并集。

  • . 从集合中移除的信号将不再属于当前被阻塞信号的集合。尝试取消一个未阻塞的信号是允许的。

  • . 被阻塞的信号集合设置为参数集合。

sigset 类型表现得像一个集合。在向集合中添加之前忘记初始化信号集合是一个常见的错误。

sigset_t set, oldset;
sigaddset(&set, SIGINT); // Ooops!
sigprocmask(SIG_SETMASK, &set, &oldset)

正确的代码将集合初始化为全开或全关。例如,

sigfillset(&set); // all signals
sigprocmask(SIG_SETMASK, &set, NULL); // Block all the signals which can be blocked

sigemptyset(&set); // no signals
sigprocmask(SIG_SETMASK, &set, NULL); // set the mask to be empty again

如果您使用 或 阻塞信号,则除非明确地传递给 TODO: 引用,否则注册给 的处理程序不会传递。

Sigwait

Sigwait 可以一次读取一个挂起信号。用于同步等待信号,而不是在回调中处理信号。在多线程程序中 sigwait 的典型用法如下。请注意,首先设置了线程信号掩码(并且将被新线程继承)。掩码阻止信号被 传递,因此它们将保持挂起状态,直到 sigwait 被调用。请注意,sigwait 使用相同的集合变量 - 除了而不是设置被阻塞信号的集合外,它用作 sigwait 可以捕获并返回的信号集合。

与回调函数相比,编写自定义信号处理线程(如下面的示例所示)的一个优点是,您现在可以安全地使用更多 C 库和系统函数。

基于 sigmask 代码 (n.d.)

static sigset_t signal_mask; /* signals to block */

int main(int argc, char *argv[]) {
 pthread_t sig_thr_id; /* signal handler thread ID */
 sigemptyset (&signal_mask);
 sigaddset (&signal_mask, SIGINT);
 sigaddset (&signal_mask, SIGTERM);
 pthread_sigmask (SIG_BLOCK, &signal_mask, NULL);

 /* New threads will inherit this thread's mask */
 pthread_create (&sig_thr_id, NULL, signal_thread, NULL);

 /* APPLICATION CODE */
 ...
}

void *signal_thread(void *arg) {
 int sig_caught;

 /* Use the same mask as the set of signals that we'd like to know about! */
 sigwait(&signal_mask, &sig_caught);
 switch (sig_caught) {
 case SIGINT:
 ...
 break;
 case SIGTERM:
 ...
 break;
 default:
 fprintf (stderr, "\nUnexpected signal %d\n", sig_caught);
 break;
 }
}

子进程和线程中的信号

这是对进程章节的回顾。在派生之后,子进程继承了父进程的信号处理和信号掩码的副本。如果您在派生之前安装了 SIGINT 处理程序,那么如果向子进程发送 SIGINT,子进程也将调用处理程序。如果父进程中阻塞了 SIGINT,它将在子进程中同样被阻塞。请注意,在派生期间,子进程的挂起信号不会被继承。然而,之后,只有信号掩码和挂起信号会被携带过来(“执行文件”,n.d.)。信号处理程序被重置为其原始操作,因为原始处理程序代码可能随着旧进程一起消失。

每个线程都有自己的掩码。新线程会继承调用线程的掩码副本。在初始化时,调用线程的掩码与进程掩码完全相同。然而,在创建新线程之后,进程的信号掩码变成了灰色区域。相反,内核喜欢将进程视为线程的集合,每个线程都可以设置自己的信号掩码并接收信号。要开始设置您的掩码,您可以使用,

pthread_sigmask(...); // set my mask to block delivery of some signals
pthread_create(...); // new thread will start with a copy of the same mask

在多线程程序中阻塞信号与单线程程序中的阻塞信号类似,以下是对应的翻译。

  1. 使用代替

  2. 在所有线程中阻塞一个信号以防止其异步传递

确保在所有线程中阻塞信号的最简单方法是在创建新线程之前在主线程中设置信号掩码。

sigemptyset(&set);
sigaddset(&set, SIGQUIT);
sigaddset(&set, SIGINT);
pthread_sigmask(SIG_BLOCK, &set, NULL);

// this thread and the new thread will block SIGQUIT and SIGINT
pthread_create(&thread_id, NULL, myfunc, funcparam);

正如我们所看到的,包括一个‘how’参数,它定义了信号集的使用方式:

pthread_sigmask(SIG_SETMASK, &set, NULL) - replace the thread's mask with given signal set
pthread_sigmask(SIG_BLOCK, &set, NULL) - add the signal set to the thread's mask
pthread_sigmask(SIG_UNBLOCK, &set, NULL) - remove the signal set from the thread's mask

然后,可以将信号传递给任何愿意接受该信号的信号线程。如果有两个或更多线程可以接收该信号,那么哪个线程将被中断是任意的!常见的做法是有一个可以接收所有信号的线程,或者如果有特定的信号需要特殊逻辑,为多个信号拥有多个线程。即使外部程序不能向特定线程发送信号,你仍然可以使用 . 在下面的示例中,新创建的执行线程将被

pthread_create(&tid, NULL, func, args);
pthread_kill(tid, SIGINT);
pthread_kill(pthread_self(), SIGKILL); // send SIGKILL to myself

作为警告,将杀死整个进程。尽管个别线程可以设置信号掩码,但信号处理是按进程而不是按线程的。这意味着可以从任何线程调用,因为你会为进程中的所有线程设置信号处理程序。

Linux 手册页在第二部分讨论了信号系统调用。在第七部分还有一个更长的文章(尽管不在 OSX/BSD 中):

man -s7 signal

主题

  • 信号

  • 信号处理程序安全性

  • 信号处理

  • 信号状态

  • 在 Fork/Exec 时的挂起信号

  • Fork/Exec 时的信号处理

  • 在 C 中提升信号

  • 在多线程程序中提升信号

问题

  • 什么是信号?

  • 在 UNIX 下如何处理信号?(加分:Windows 下又是如何?)

  • 函数是信号处理程序安全性的含义是什么?关于可重入性又是如何的?

  • 进程信号处理是什么?它与掩码有何不同?

  • 在单线程程序中,哪个函数会改变信号处理?在多线程程序中又是如何的?

  • 使用信号有哪些缺点?

  • 异步和同步捕获信号的方法有哪些?

  • Fork 后、exec 后挂起的信号会发生什么?我的信号掩码呢?信号处理呢?

  • 内核从创建到传递/阻塞的过程是什么?

安全

计算机安全是保护硬件和软件免受未经授权的访问或修改。即使你并没有直接在计算机安全领域工作,这些概念也是重要的学习内容,因为所有系统在足够的时间下都会遇到攻击者。尽管这部分内容被介绍为不同的章节,但重要的是要注意,这些概念和代码示例已经在课程的不同部分介绍过了。我们不会深入探讨所有常见的攻击和防御方式,也不会详细介绍如何在任意系统中执行这些攻击。我们的目标是让你了解使程序按照你的意愿运行的领域。

安全术语和道德规范

有一些术语需要解释,以便让那些在计算机安全领域几乎没有经验的人能够跟上进度

  1. 攻击者通常是试图闯入系统的用户。闯入系统意味着执行开发者未曾意图的行为。这也可能意味着访问你不应该访问的系统。

  2. 防御者通常是防止攻击者闯入系统的用户。这可能是系统的开发者。

  3. 存在着不同类型的攻击者。有白帽黑客,他们会在同意的情况下尝试攻击防御者。这通常是一种预防性测试的形式——以防出现不太友好的攻击。黑帽黑客是未经许可进行黑客攻击的人,他们意图将获取的信息用于任何目的。灰帽黑客不同,因为黑客的意图是通知防御者存在的漏洞——尽管有时这很难判断。

危险,罗宾逊在我们让你继续前进之前,重要的是我们要谈谈道德规范。在你跳过这一部分之前,要知道你的职业生涯可能会因为一个不道德的决定而直接被终止。计算机欺诈和安全法案是一项广泛且可能非常糟糕的法律,将任何未经授权使用“受保护计算机”的行为视为重罪。由于大多数计算机都涉及一些州际/国际商业(互联网),因此大多数计算机都属于这一类别。在执行任何攻击或防御之前,重要的是要考虑你的行为,并在执行之前有一些责任阶梯。更具体地说,在尝试执行攻击之前,确保你的组织中的主管已经给予了他们的许可。

首先如果可能的话,从你的上级之一那里获得书面许可。我们确实意识到这是一种逃避责任的做法,并且将责任推到了更高的层次,但冒着听起来愤世嫉俗的风险,组织往往会将责任推给个别员工以避免损害 TODO: 需要引用。如果不可能,尝试通过工程步骤进行

  1. 确定你试图解决的问题是什么。你不能解决一个你不完全理解的问题。

  2. 确定你是否需要“破解”系统。一般来说,破解是指试图以非预期的方式使用系统。首先,你应该确定你的使用是预期的、非预期的,还是介于两者之间——为他们做出决定。如果你无法做出决定,就做出合理的判断,确定预期的使用方式。

  3. 合理估计“破解”系统的成本。让几个工程师检查这个合理的估计,以便他们可以指出你可能忽略的事情。试图让某人批准这个计划。

  4. 谨慎执行计划。如果在任何时刻感觉有问题,权衡风险并执行计划。

如果当前应用程序没有特定的道德准则,那么就创建一些。这通常被称为。这可能看起来像是繁琐的工作,更多地与“商业方面”有关,而不是计算机科学家所习惯的,但你的职业生涯就在这里。作为计算专业人士,评估风险并决定是否执行取决于你。法院通常喜欢遵循先例,但你可以说你不是法律学者。作为替代,你必须能够说你会像“合理的”工程师那样反应。

TODO:链接到一些真实工程师必须做出决定的案例研究

CIA 三元组

有三个普遍接受的目标有助于理解系统是否安全。

  1. 信息机密性意味着只有授权方才能查看信息。

  2. 信息完整性意味着只有授权方才能修改信息,无论他们是否被允许查看它。它确保信息在传输过程中保持完整。

  3. 信息可用性意味着信息或服务在需要时可用。

  4. 上述三元组构成了机密性、完整性和可用性(CIA)三元组,通常还会增加真实性。

如果其中任何一个被破坏,系统的安全性(无论是服务还是信息)就已被破坏。

C 程序中的安全性

栈溢出

考虑以下代码片段

void greeting(const char *name) {
 char buf[32];
 strcpy(buf, name);
 printf("Hello, %s!\n", buf);
}

int main(int argc, char *argv[]) {
 if (argc < 2){
 return 1;
 }
 greeting(argv[1]);
 return 0;
}

strcpy 函数没有边界检查!这意味着我们可能传递一个大的字符串,导致程序执行非预期的操作,通常是通过将函数的返回地址替换为恶意代码的地址。大多数字符串会导致程序因段错误而退出

$ ./a.out john
Hello, john!
$ ./a.out JohnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
Program received signal SIGSEGV, Segmentation fault.
...

如果我们以某种方式操纵字节,并且程序是用正确的标志编译的,我们实际上可以获取对 shell 的访问权限!考虑如果该文件属于 root 用户,我们输入一些有效的字节码(二进制指令)作为字符串。会发生的事情是我们会尝试执行编译成操作系统字节码的内容,并将其作为我们字符串的一部分传递。如果有幸,我们将获得对 root shell 的访问权限。

$ ./a.out <payload>
root#

问题是,这个三联组中的哪些部分被破坏了?试着回答这个问题。那么我们该如何解决这个问题呢?我们可以在 C 级别将使用或 on OpenBSD 系统内化到大多数程序员中。如后文所述,开启栈看门狗可以解决这个问题。

缓冲区溢出

大多数人已经熟悉缓冲区溢出了!很多时候它们相当温和,导致简单的程序崩溃或有趣的错误。这里有一个完整的例子

> cat main.c
#include <stdio.h>

int main() {
    char out[10];
    char in[10];
    fscanf(stdin, "%s", in);
    out[0] = 'a';
    out[9] = '\0';
    printf("%s\n", out);

    return 0;
}
> gcc main.c -fno-stack-protector # need the special flag otherwise won't work
# Stack protectors are explained later.
> ./a.out
hello
a
> ./a.out
hellloooooooo
aoo
>

如果你能回忆起 C 内存模型,这里发生的事情应该是清晰的。出和入在内存中都是相邻的。如果你从标准输入读取一个字符串到溢出的入,那么你最终会打印出 aoo。如果片段一开始就是

int main() {
 char pass_hash[10];
 char in[10];
 read_user_password(pass_hash, 10);
 // ...
}

无序指令 & Spectre

无序执行是一个惊人的发展,最近被许多硬件供应商采用(想想 20 世纪 90 年代)TODO:需要引用。处理器现在不再执行一系列指令(比如说分配一个变量然后另一个变量),而是在当前指令完成之前执行指令(指南 2011 第 45 页)。这是因为现代处理器花费大量时间等待内存访问和其他 I/O 驱动应用程序。这意味着处理器在等待操作完成时,将执行接下来的几个操作。如果有任何操作可能会改变最终结果,那么就有障碍,或者如果重新排序违反了指令的数据依赖性,处理器将保持指令的既定顺序(指南 2011 第 296 页)。

自然地,这使得 CPU 在实时执行更多指令的同时变得更加节能,同时也增加了复杂架构带来的安全风险。系统程序员担心的是,线程之间的互斥锁操作是无序的——这意味着没有大量内存屏障的纯软件实现互斥锁将失败。因此,程序员必须承认,在没有屏障的情况下,一系列线程中可能会错过更新。

与此相关的最突出的错误之一是 Spectre(Kocher 等人 2018)。Spectre 是一种由于无序指令执行而推测性地执行了本不应执行的指令的 bug。以下是一个高级概念证明的片段。

char *a[10];
for (int i = 10; i != 1; --i) {
 a[i] = calloc(1, 1);
}
a[0] = 0xCAFE;
int val;
int j = 10; // This will be in a register
int i = 10; // This will be in main memory
for (int i = 10; i != 0; --i, --j) {
 if (i) {
 val = *a[j];
 }
}

让我们分析一下这段代码。第一个循环通过有效的 malloc 分配了 9 个元素。最后一个元素是,意味着解引用应该导致 SEGFAULT。在前 9 次迭代中,分支被采取,并被分配给一个有效的值。有趣的部分发生在最后一次迭代。程序的结果是跳过最后一次迭代。因此,永远不会被分配到最后一个值。

但在适当的编译条件和编译器标志下,指令将被推测执行。处理器认为分支将被采取,因为它在最后的 9 次迭代中已经被采取。因此,处理器将获取这些指令。由于指令乱序执行,当从内存中获取i的值时,我们必须强制它不在寄存器中。然后,处理器将尝试解引用该地址。这应该导致 SEGFAULT。由于该地址从未被程序逻辑上访问到,结果是丢弃的。

现在是技巧所在。尽管计算的值会导致 SEGFAULT,但该错误不会清除指向 0xCAFE 所在物理内存的缓存。这是一个不精确的解释,但基本上就是这样工作的。由于它仍然在缓存中,如果你再次欺骗处理器使用它从缓存中读取,你将读取到通常无法读取的内存值。这可能包括重要信息,如密码、支付信息等。

操作系统安全

  1. 权限。在 POSIX 系统中,我们到处都有权限。有你可以访问和不能访问的目录,有你可以访问和不能访问的文件。每个用户账户通过读写执行(RWX)位访问每个文件和目录。用户与所有者、组或“其他人”匹配,并使用这些位限制对文件的访问。请注意,与文件相比,目录的权限工作方式略有不同。

  2. 权限。除了对文件的操作权限外,每个用户都有一定可以执行的操作权限。要查看完整列表,您可以检查capabilities(7)。简而言之,允许权限意味着允许用户执行一系列操作。一些例子包括控制网络设备、创建特殊文件以及窥视 IPC 或进程间通信。

  3. 地址空间布局随机化(ASLR)。ASLR 会导致进程重要部分的地址空间,包括可执行文件的基址以及栈、堆和库的位置,在每次运行时都从随机值开始。这样,一个拥有正在运行的可执行文件的攻击者必须随机猜测敏感信息可能被隐藏的位置。例如,攻击者可能利用这一点轻松地执行攻击。

  4. 栈保护器。假设你已经像上面那样编写了一个缓冲区溢出程序。在大多数情况下会发生什么?除非特别关闭,编译器会放入栈保护器或栈看门狗。这是一个位于栈中的值,在函数调用期间必须保持不变。如果在函数调用结束时覆盖了该保护器,运行时将终止并向用户报告检测到栈破坏。

  5. 写入或执行,也称为(DEP)。这是在 IPC 部分中介绍的保护措施,用于区分代码和数据。一个页面要么可以写入,要么可以执行,但不能同时进行。这是为了防止缓冲区溢出,攻击者通常会写入任意代码,通常存储在堆栈或堆中,并使用用户的权限执行。

  6. 防火墙。Linux 内核提供了 netfilter 模块,作为决定是否允许传入连接以及连接的各种限制的方式。这有助于防御 DDoS 攻击(稍后解释)。

  7. AppArmor. AppArmor 是一套用户空间级别的操作系统工具,用于限制应用程序执行特定操作。

OpenBSD 是一个在安全性方面可能更好的系统。它具有许多面向安全性的功能。其中一些功能在前面已经提到过。一个详尽的功能列表可以在 www.openbsd.org/innovations.html 找到。

  1. pledge. pledge 是一个强大的命令,用于限制系统调用。这意味着如果你有一个简单的程序,例如只从文件中读取和写入的程序,你可以合理地限制所有网络访问、管道访问和文件写入访问。这被称为“加固”可执行文件或系统的过程,即给予运行系统所需的最少可执行文件以最小的权限。pledge 还在尝试执行注入攻击时非常有用。

  2. unveil. unveil 是一个系统调用,用于限制当前程序对几个目录的访问。这些权限也适用于所有派生的程序。这意味着如果你有一个你想运行的可疑可执行文件,其描述为“创建一个新文件并输出随机单词”,你可以使用这个调用来限制对安全子目录的访问,并监视它是否在尝试访问根目录中的系统文件时接收 SIGKILL 信号,例如。这可能对你的程序也有用。如果你想确保在更新过程中不会丢失任何用户数据(例如 Steam 系统更新中发生的情况),那么系统只能揭示程序的安装目录。如果攻击者设法在可执行文件中找到漏洞,它只能危害安装目录。

  3. sudo. sudo 是一个在所有地方都运行的 openBSD 项目!在以 root 用户身份运行命令之前,你不得不切换到 root shell。有时这也意味着赋予用户令人恐惧的系统权限。sudo 允许你在不向所有用户授予长列表的权限的情况下,执行作为 root 的命令。

虚拟化安全

虚拟化是指为程序运行创建一个虚拟环境的行为。尽管随着新世代裸金属虚拟机的出现,这个定义可能有所变化,但抽象层仍然存在。可以想象每个主板都对应一个操作系统。在软件层面,虚拟化提供“虚拟”主板功能,如 USB 端口或显示器,这些功能通过另一个程序(即桥梁)与实际硬件通信以执行任务。一个简单的例子是在您的宿主桌面上运行虚拟机!可以启动一个完全不同的操作系统,其指令通过另一个程序传入并在宿主系统上执行。我们今天使用了许多种虚拟化形式。以下我们将讨论两种流行的形式。一种形式是虚拟机。这些程序模拟所有主板外设以创建一个完整的机器。另一种形式是容器。虚拟机很好,但通常体积庞大,程序只需要一定级别的保护。容器是不模拟所有主板外设的虚拟机,而是与宿主操作系统共享,增加了额外的安全层。

现在,没有安全措施就无法进行适当的虚拟化。拥有虚拟化的一个原因是为了确保虚拟化环境不会恶意泄露回宿主环境。我们说恶意,因为我们希望保持检查的通信方式是有意为之。以下是虚拟化提供的一些简单安全示例。

  1. chroot 是一种创建虚拟化环境的方法。chroot 是 change root 的缩写。这改变了程序认为(/)在系统上挂载的位置。例如,使用 chroot,可以使一个 hello world 程序相信它实际上是根目录。这很有用,因为不会暴露其他文件。这是人为的,因为 Linux 仍然需要额外的工具(如 c 标准库)来自不同的目录,这意味着这些目录仍然可能存在漏洞。

  2. 命名空间是 Linux 创建虚拟化环境的一种更好的方式。我们不会过多地介绍这一点,只需知道它们存在即可。

  3. 硬件虚拟化技术。硬件供应商越来越意识到在模拟指令时需要物理保护。因此,用户可以启用某些开关,使操作系统切换到虚拟化模式,其中指令以正常方式运行,但会监控恶意活动。这有助于提高性能并增加虚拟化环境的安全性。

网络安全

网络安全可以说是最受欢迎的安全领域。我们的系统越来越多地通过网络遭受黑客攻击,了解如何抵御这些攻击变得尤为重要。

TCP 层的安全

  1. 加密。TCP 是未加密的!这意味着通过 TCP 连接发送的任何数据都是明文。如果需要发送加密数据,就需要使用更高层次的协议,如 HTTPs 或开发自己的协议。

  2. 身份验证。在 TCP 中,无法验证程序连接到的身份。没有检查或联邦数据库。只能相信 DNS 服务器给出了合理的响应,这几乎总是错误的答案。除了有批准的白名单或“秘密”连接协议的系统外,在 TCP 层面上几乎无法做些什么来阻止。

  3. Syn-Ack 序列号。这是一个安全改进。TCP 有我们所说的序列号。这意味着在 SYN-SYN/ACK-ACK 舞蹈中,连接从一个随机整数开始。这很重要,因为如果攻击者试图伪造数据包(假装这些数据包来自你的程序),这意味着攻击者必须正确猜测——这很困难——或者位于你的数据包到达目的地的路由上——可能性更大。ISP 通过发送连接通过不同的路由器来帮助解决这个问题,这使得攻击者很难坐在任何地方并确信他们会收到你的数据包——这就是为什么安全专家通常建议不要在咖啡店 Wi-Fi 上进行敏感任务。

  4. Syn-Flood。在第一个同步数据包被确认之前,没有连接。这意味着恶意攻击者可以编写一个糟糕的 TCP 实现,向一个不幸的服务器发送大量的 SYN 数据包。通过使用 IPTABLES 或另一个 netfilter 模块,在达到一定量的流量后,可以在一定时间内丢弃来自某个 IP 地址的所有传入连接,从而轻松缓解 SYN 洪水攻击。

  5. 拒绝服务,分布式拒绝服务是最难阻止的攻击形式。今天,公司仍在寻找好的方法来减轻这些攻击。这涉及到将各种网络流量发送到服务器,希望流量会堵塞它们并减慢服务器的速度。在大系统中,这可能导致级联故障。如果系统设计不当,一个服务器的故障会导致其他服务器承担更多的工作,从而增加了它们失败的概率,如此类推。

DNS 层面的安全

截至 2019 年,美国国土安全部发布了一项指令,要求将所有服务从 DNS 切换到 DNSSec cyber.dhs.gov/assets/report/ed-19-01.pdf。这项指令是 DNS 系统的一个固有缺陷。首先,DNS 不提供任何形式的域名请求验证。也就是说,很容易欺骗 DNS 名称服务器,使它们将你的浏览器指向可能恶意的服务器。记住,DNS 请求是以未加密的 UDP 数据包发送的,这些数据包容易受到篡改。这意味着如果攻击者截获了对 DNS 服务器的纯文本请求,那么该攻击者现在可以向请求者发送结果。更常见的是,他们不会仅仅攻击一个人,而是连接到公共 Wi-Fi 热点并毒化路由器的缓存——这意味着当请求域名时,所有连接的人都会得到一个错误的 IP 地址。如果有人试图假装自己是主要银行,这可能会变成严重的欺骗攻击。

主题

  1. 安全术语

  2. 本地 C 程序中的安全

  3. 网络空间安全

复习

  1. 什么 chmod 语句可以仅破坏你数据的机密性?

  2. 什么 chmod 语句可以仅破坏你数据的机密性?

  3. 一个攻击者获得了你用来存储私人信息的 Linux 系统的 root 权限。这会影响你信息的机密性、完整性或可用性,还是全部三个?

  4. 黑客暴力破解你的 git 用户名和密码。谁会受到影响的?

  5. 为什么在 RPC 应用中使用权限分离是有用的?

  6. 是伪造 UDP 或 TCP 数据包更容易,以及为什么?

  7. 为什么 TCP 序列号初始化为随机数?

  8. 如果用于存储共享库(例如 C 标准库)的 RAM 可以被任何进程写入,这会有什么影响?

  9. 创建和实施安全且对恶意攻击者免疫的客户端-服务器协议容易吗?

  10. 哪个更难防御:SYN 洪水攻击或分布式拒绝服务攻击?

  11. 死锁是否会影响服务的可用性?

  12. 缓冲区溢出/下溢是否会影响数据的完整性?

  13. 为什么堆栈内存不应该可执行?

  14. HeartBleed 是哪种安全问题的例子?它破坏了三要素中的哪一个?

  15. Meltdown 和 Spectre 是哪种安全问题的例子?它破坏了三要素中的哪一个?

复习

以下是一个非详尽的主题列表。

C

内存和字符串

  1. 在下面的示例中,哪些变量保证打印出零的值?

    int a;
    static int b;
    
    void func() {
     static int c;
     int d;
     printf("%d %d %d %d\n",a,b,c,d);
    }
    
  2. 在下面的示例中,哪些变量保证打印出零的值?

    void func() {
     int* ptr1 = malloc(sizeof(int));
     int* ptr2 = realloc(NULL, sizeof(int));
     int* ptr3 = calloc(1, sizeof(int));
     int* ptr4 = calloc(sizeof(int), 1);
    
     printf("%d %d %d %d\n",*ptr1,*ptr2,*ptr3,*ptr4);
    }
    
  3. 解释以下尝试复制字符串的错误。

    char* copy(char*src) {
     char*result = malloc( strlen(src) );
     strcpy(result, src);
     return result;
    }
    
  4. 为什么以下尝试复制字符串有时工作有时失败?

    char* copy(char*src) {
     char*result = malloc( strlen(src) +1 );
     strcat(result, src);
     return result;
    }
    
  5. 解释以下尝试复制字符串的代码中的两个错误。

    char* copy(char*src) {
     char result[sizeof(src)];
     strcpy(result, src);
     return result;
    }
    
  6. 以下哪个是合法的?

    char a[] = "Hello"; strcpy(a, "World");
    char b[] = "Hello"; strcpy(b, "World12345", b);
    char* c = "Hello"; strcpy(c, "World");
    
  7. 完成以下函数指针 typedef,以声明一个接受 void* 参数并返回 void* 的指针。将你的类型命名为“pthread_callback”

    typedef ______________________;
    
  8. 除了函数参数外,线程的堆栈上还存储了什么?

  9. 仅使用和指针算术实现一个版本。

    char* mystrcat(char*dest, const char*src) {
    
     ? Use strcpy strlen here
    
     return dest;
    }
    
  10. 使用循环和没有函数调用实现 size_t strlen(const char*) 的版本。

    size_t mystrlen(const char*s) {
    
    }
    
  11. 以下实现中存在三个错误,请识别出来。

    char* strcpy(const char* dest, const char* src) {
     while(*src) { *dest++ = *src++; }
     return dest;
    }
    

打印

  1. 找出两个错误!

    fprintf("You scored 100%");
    
  2. 完成以下代码以打印到文件。将姓名、逗号和分数打印到文件“result.txt”

    char* name = .....;
    int score = ......
    FILE *f = fopen("result.txt",_____);
    if(f) {
     _____
    }
    fclose(f);
    
  3. 你会如何将变量、、和的值打印到字符串中?将打印为整数,mesg 作为 C 字符串,val 作为 double val,ptr 作为十六进制指针。你可以假设 mesg 指向一个短 C 字符串(<50 个字符)。加分:你将如何使这段代码更健壮或能够应对?

    char* toString(int a, char*mesg, double val, void* ptr) {
     char* result = malloc( strlen(mesg) + 50);
     _____
     return result;
    }
    

输入解析

  1. 为什么你应该检查 sscanf 和 scanf 的返回值?## Q 5.2 为什么‘gets’是危险的?

  2. 编写一个完整的程序,使用 。确保你的程序没有内存泄漏。

  3. 你会在什么情况下使用 calloc 而不是 malloc?realloc 何时有用?

  4. 在以下代码中,程序员犯了什么错误?是否可以修复它?

    i) 使用堆内存?ii) 使用全局(静态)内存?

    static int id;
    
    char* next_ticket() {
     id ++;
     char result[20];
     sprintf(result,"%d",id);
     return result;
    }
    

进程

  1. 什么是进程?

  2. 在 fork 时,哪些属性会从进程继承过来?在成功 exec 调用时呢?

  3. 什么是 fork 炸弹?我们如何避免它?

  4. wait 系统调用用于什么?

  5. 什么是僵尸进程?我们如何避免它们?

  6. 什么是孤儿进程?它们会发生什么?

  7. 我们如何检查已退出的进程的状态?

  8. 进程的常见模式是什么?

内存

  1. C 语言中分配内存的调用有哪些?

  2. malloc 分配的内存必须对齐到什么?为什么这很重要?

  3. Knuth 分配方案是什么?

  4. 你会如何处理伙伴分配方案中的请求?

  5. 什么是空闲列表?

  6. 有哪些不同的方法可以将元素插入到空闲列表中?

  7. 首次适配、最差适配、最佳适配有什么优点和缺点?

  8. 何时一个简单的 malloc 实现是足够的?

    void *malloc(int size) {
     return (void *)sbrk(size);
    }
    

    是否可接受?

线程和同步

  1. 什么是线程?线程共享什么?

  2. 如何创建一个线程?

  3. 线程的堆栈在内存中的位置在哪里?

  4. 互斥锁(mutex)是什么?它解决了什么问题?

  5. 什么是条件变量?它解决了什么问题?

  6. 编写一个线程安全的链表,支持插入前、插入后、弹出前和弹出后。确保它不要忙等待!

  7. Peterson 的临界区问题解决方案是什么?Dekker 的呢?

  8. 以下代码是否线程安全?重新设计以下代码以使其线程安全。提示:如果消息内存对每个调用都是唯一的,则不需要互斥锁。

    static char message[20];
    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    
    void *format(int v) {
     pthread_mutex_lock(&mutex);
     sprintf(message, ":%d:" ,v);
     pthread_mutex_unlock(&mutex);
     return message;
    }
    
  9. 以下哪个可能导致进程处于运行状态?

    1. 在最后一个运行的线程中从 pthread 的启动函数返回。

    2. 原线程从 main 返回。

    3. 任何导致段错误的线程。

    4. 任何调用。

    5. 在主线程中调用,而其他线程仍在运行。

  10. 为以下程序打印的“W”字符数量写一个数学表达式。假设 a、b、c、d 是小的正整数。你的答案可以使用返回其最低值参数的“min”函数。

    unsigned int a=...,b=...,c=...,d=...;
    
    void* func(void* ptr) {
     char m = * (char*)ptr;
     if(m == 'P') sem_post(s);
     if(m == 'W') sem_wait(s);
     putchar(m);
     return NULL;
    }
    
    int main(int argv, char** argc) {
     sem_init(s,0, a);
     while(b--) pthread_create(&tid, NULL, func, "W");
     while(c--) pthread_create(&tid, NULL, func, "P");
     while(d--) pthread_create(&tid, NULL, func, "W");
     pthread_exit(NULL);
     /*Process will finish when all threads have exited */
    }
    
  11. 完成以下代码。以下代码本应打印交替的和。它代表两个交替执行的线程。向添加条件变量调用,以便等待的线程不需要不断检查变量。问题:pthread_cond_broadcast 是否必要,还是 pthread_cond_signal 足够?

    pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
    pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
    
    void* turn;
    
    void* func(void* mesg) {
     while(1) {
     // Add mutex lock and condition variable calls ...
    
     while(turn == mesg) {
     /* poll again ... Change me - This busy loop burns CPU time! */
     }
    
     /* Do stuff on this thread */
     puts( (char*) mesg);
     turn = mesg;
    
     }
     return 0;
    }
    
    int main(int argc, char** argv){
     pthread_t tid1;
     pthread_create(&tid1, NULL, func, "A");
     func("B"); // no need to create another thread - use the main thread
     return 0;
    }
    
  12. 识别给定代码中的临界区。添加互斥锁以使代码线程安全。添加条件变量调用,以便永远不会变为负数或超过 1000。相反,调用应该阻塞,直到可以安全进行。解释为什么是必要的。

    int total;
    void add(int value) {
     if(value < 1) return;
     total += value;
    }
    void sub(int value) {
     if(value < 1) return;
     total -= value;
    }
    
  13. 一个线程不安全的数据结构有和方法。使用条件变量和互斥锁来完成线程安全的、阻塞版本。

    void enqueue(void* data) {
     // should block if the size() would become greater than 256
     enq(data);
    }
    void* dequeue() {
     // should block if size() is 0
     return deq();
    }
    
  14. 你的初创公司提供使用最新交通信息的路径规划。你的高薪实习生创建了一个线程不安全的数据结构,其中包含两个函数:(使用但不修改图)和(修改图)。

    graph_t* create_graph(char* filename); // called once
    
    // returns a new heap object that is the shortest path from vertex i to j
    path_t* shortest(graph_t* graph, int i, int j);
    
    // updates edge from vertex i to j
    void set_edge(graph_t* graph, int i, int j, double time);
    

    为了性能,多个线程必须能够同时调用,但图只能由一个线程在没有任何其他线程在或内部执行时修改。

  15. 使用互斥锁和条件变量来实现读者-写者解决方案。下面展示了一个不完整的尝试。尽管这个尝试是线程安全的(因此对于演示日来说足够了!),但它不允许多个线程同时计算路径,并且吞吐量将不足。

    path_t* shortest_safe(graph_t* graph, int i, int j) {
     pthread_mutex_lock(&m);
     path_t* path = shortest(graph, i, j);
     pthread_mutex_unlock(&m);
     return path;
    }
    void set_edge_safe(graph_t* graph, int i, int j, double dist) {
     pthread_mutex_lock(&m);
     set_edge(graph, i, j, dist);
     pthread_mutex_unlock(&m);
    }
    
  16. 对于读者-写者问题,以下哪些陈述是正确的?

    • 可以有多个活跃的读者

    • 可以有多个活跃的写者

    • 当有活跃的写者时,活跃的读者数必须为零

    • 如果有活跃的读者,则活跃的写者数必须为零

    • 写者必须等待当前活跃的读者完成

死锁

  1. 每个 Coffman 条件是什么,它们意味着什么?你能提供每个条件的定义以及使用互斥锁打破它们的例子吗?

  2. 给出一个现实生活中的例子,依次打破每个 Coffman 条件。一个需要考虑的情况:画家、颜料和画笔。

    1. 保持和等待

    2. 循环等待

    3. 无抢占

    4. 互斥

  3. 确定何时 Dining Philosophers 代码导致死锁(或没有)。例如,如果你看到了以下代码片段,哪个 Coffman 条件没有得到满足?

    // Get both locks or none.
    pthread_mutex_lock( a );
    if( pthread_mutex_trylock( b ) ) { /*failed*/
     pthread_mutex_unlock( a );
     ...
    }
    
  4. 有多少进程被阻塞?

    • P1 获取 R1

    • P2 获取 R2

    • P1 获取 R3

    • P2 等待 R3

    • P3 获取 R5

    • P1 获取 R4

    • P3 等待 R1

    • P4 等待 R5

    • P5 等待 R1

  5. 以下 dining philosophers 解决方案的优缺点是什么?

    1. 仲裁者

    2. Dijkstra

    3. Stalling’s

    4. Trylock

IPC

  1. 以下是什么以及它们的目的?

    1. 传输查找缓冲区

    2. 物理地址

    3. 内存管理单元

    4. 污点位

  2. 你如何确定页面偏移使用了多少位?

  3. 在上下文切换后的 20 毫秒内,TLB 包含了执行主内存访问 100% 的数值代码所使用的所有逻辑地址。与单级页表相比,两级页表的开销(减速)是多少?

  4. 解释为什么在发生上下文切换时必须刷新 TLB(即 CPU 被分配到处理不同进程)。

  5. 填空以使以下程序打印 123456789。如果没有提供参数,它将简单地打印其输入直到 EOF。加分:解释为什么下面的调用是必要的。

    int main() {
     int i = 0;
     while(++i < 10) {
     pid_t pid = fork();
     if(pid == 0) { /* child */
     char buffer[16];
     sprintf(buffer, ______,i);
     int fds[ ______];
     pipe(fds);
     write(fds[1], ______,______ ); // Write the buffer into the pipe
     close(______);
     dup2(fds[0], ______);
     execlp("cat", "cat",  ______);
     perror("exec"); exit(1);
     }
     waitpid(pid, NULL, 0);
     }
     return 0;
    }
    
  6. 使用 POSIX 调用来实现自动评分程序。将子进程的标准输出捕获到管道中。子进程应该不带任何额外参数(除了进程名称)来运行程序。在父进程中从管道读取:一旦捕获的输出包含 ! 字符,则退出父进程。在退出父进程之前,向子进程发送 SIGKILL 信号。如果输出包含 !,则退出 0。否则,如果子进程退出导致管道的写端关闭,则退出值为 1。确保在父进程和子进程中关闭未使用的管道端。

  7. 这个高级挑战使用管道来让一个“AI 玩家”自己玩游戏,直到游戏完成。程序接受一行输入 - 到目前为止的回合顺序,打印相同的顺序后跟另一个回合,然后退出。一个回合由两个字符指定。例如,“A1”和“C3”是两个对角位置。字符串是 3 个回合/走法的游戏。一个有效的响应是(C1 响应阻止了 B2 A3 对角威胁)。输出行还可以包括后缀或使用管道来控制每个创建的子进程的输入和输出。当输出包含 , 时,打印最终输出行(整个游戏顺序和结果)并退出。

  8. 编写一个使用 fseek 和 ftell 将文件中间字符替换为 'X' 的函数。

    void xout(char* filename) {
     FILE *f = fopen(filename, ____ );
    
     // Your code here ...
    }
    
  9. 什么是 MMU?与直接内存系统相比,使用它的缺点是什么?

  10. 什么是管道?

  11. 命名管道和无名管道之间的优缺点是什么?

文件系统

  1. 文件 API 是什么?

  2. 文件名存储在哪里?

  3. inode 中包含什么?

  4. 每个目录中的两个特殊文件名是什么?

  5. 你如何解析以下路径

  6. rwx 组是什么?

  7. UID 是什么?GID 是什么?UID 与有效 UID 之间的区别是什么?

  8. 什么是 umask?

  9. 什么是粘性位?

  10. 什么是虚拟文件系统?

  11. 什么是 RAID?

  12. 在一个文件系统中,为了访问文件的第一字节,需要从磁盘读取多少个 inode?假设根目录中的目录名和 inode 号已经在内存中(但不是 inode 本身)。

  13. 在一个文件系统中,为了访问文件的第一字节,必须从磁盘读取多少个磁盘块?假设根目录中的目录名和 inode 号以及所有 inode 都已经存储在内存中。

  14. 在一个 32 位地址和 4KiB 磁盘块的文件系统中,inode 可以存储 10 个直接磁盘块号。需要多少最小文件大小才能需要一个单级间接表?ii)一个双级间接表?

  15. 修正以下 shell 命令以设置文件的权限,使得所有者可以读写执行权限,组可以读,其他人没有访问权限。

    $ chmod 000 secret.txt
    

网络连接

  1. 什么是套接字?

  2. 互联网的不同层次是什么?

  3. IP 是什么?IP 地址是什么?

  4. TCP 是什么?UDP 是什么?它们有什么区别?

  5. 创建一个 TCP 客户端,向服务器发送“Hello”。

  6. 创建一个简单的 TCP 回显服务器。这是一个读取客户端字节直到它关闭并回显字节到客户端的服务器。

  7. 创建一个 UDP 客户端,它会向 argv[1]指定的主机发送大量数据包。

  8. 什么是 HTTP?

  9. 什么是 DNS?

  10. 为什么我们在网络中使用非阻塞 I/O?

  11. 什么是 RPC?

  12. 监听端口 1000 与端口 2000 有什么特别之处?

    • 端口 2000 比端口 1000 慢一倍

    • 端口 2000 比端口 1000 快一倍

    • 端口 1000 需要 root 权限

  13. 描述 IPv4 和 IPv6 之间一个显著的区别?

  14. 在什么情况下以及为什么你会使用 ntohs?

  15. 如果主机地址是 32 位,我最有可能是使用哪种 IP 方案?128 位?

  16. 哪个常见的网络协议是基于数据包的,可能无法成功交付数据?

  17. 哪个常见的协议是基于流的,如果数据包丢失会重新发送数据?

  18. SYN ACK ACK-SYN 握手是什么?

  19. 以下哪个不是 TCP 的特性?

    1. 数据包重排序

    2. 流控制

    3. 数据包重传

    4. 简单的错误检测

    5. 加密

  20. 哪个协议使用序列号?它们的初始值是什么?为什么?

  21. 构建 TCP 服务器需要哪些最小网络调用?它们的正确顺序是什么?

  22. 构建 TCP 客户端需要哪些最小网络调用?它们的正确顺序是什么?

  23. 在 TCP 客户端上何时调用 bind?

  24. socket bind listen accept 的目的是什么?

  25. 哪个调用可能会阻塞,等待新的客户端连接?

  26. 什么是 DNS?它为你做了什么?CS241 网络调用中的哪个会为你使用它?

  27. 对于 getaddrinfo,你如何指定服务器套接字?

  28. 为什么 getaddrinfo 可能会生成网络数据包?

  29. 哪个网络调用指定了允许的 backlog 的大小?

  30. 哪个网络调用返回一个新的文件描述符?

  31. 何时使用被动套接字?

  32. 在什么情况下 epoll 比 select 更好?在什么情况下 select 比 epoll 更好?

  33. 总是发送 5000 字节数据吗?什么时候可能会失败?

  34. 网络地址转换(NAT)是如何工作的?

  35. 假设网络在客户端和服务器之间有一个 20 毫秒的单向传输时间,建立 TCP 连接需要多少时间?

    1. 20ms

    2. 40ms

    3. 100ms

    4. 60ms

  36. HTTP 1.0 和 HTTP 1.1 之间有哪些不同之处?如果网络有 20 毫秒的传输时间,从服务器传输 3 个文件到客户端需要多少毫秒?HTTP 1.0 和 HTTP 1.1 之间的时间差异是如何的?

  37. 向网络套接字写入可能不会发送所有字节,并且可能会因为信号而中断。检查返回值以实现将反复调用任何剩余数据的函数。如果返回-1,则立即返回-1,除非是-,在这种情况下,重复最后尝试。您将需要使用指针算术。

    // Returns -1 if write fails (unless EINTR in which case it recalls write
    // Repeated calls write until all of the buffer is written.
    ssize_t write_all(int fd, const char *buf, size_t nbyte) {
     ssize_t nb = write(fd, buf, nbyte);
     return nb;
    }
    
  38. 实现一个监听端口 2000 的多线程 TCP 服务器。每个线程应从客户端文件描述符读取 128 字节,并将其回显给客户端,然后关闭连接并结束线程。

  39. 实现一个监听端口 2000 的 UDP 服务器。预留一个 200 字节的缓冲区。监听到达的数据包。有效的数据包长度为 200 字节或更少,并以四个字节 0x65 0x66 0x67 0x68 开始。忽略无效的数据包。对于有效的数据包,将第五个字节的值作为无符号值加到运行总和中,并打印到目前为止的总和。如果运行总和大于 255,则退出。

安全性

  1. 数据安全的三种措施是什么?

  2. 什么是堆栈溢出?

  3. 什么是缓冲区溢出?

  4. 操作系统是如何提供安全性的?从网络和文件系统中举一些例子。

  5. TCP 提供了哪些安全功能?

  6. DNS 是否安全?

信号

  1. 提供两个通常由内核生成的信号名称

  2. 提供一个无法被信号捕获的信号名称

  3. 为什么在信号处理程序中调用任何函数(不是信号处理程序安全的函数)是不安全的?

  4. 编写一段使用 SIGACTION 和 SIGNALSET 创建 SIGALRM 处理器的简短代码。

  5. 处置、掩码和挂起信号集之间有什么区别?

  6. 将哪些属性传递给子进程?对于执行进程呢?

荣誉主题

本章包含了一些荣誉讲座的内容(CS 296-41)。这些主题针对希望深入了解 CS 341 主题的学生。

Linux 内核

在 CS 341 的整个过程中,你将熟悉系统调用 - 与内核交互的用户空间接口。这个内核实际上是如何工作的?什么是内核?在本节中,我们将更详细地探讨这些问题,并为你在这个课程中遇到的各种黑盒提供一些启示。在本章中,我们将主要关注 Linux 内核,所以请假设除非另有说明,所有示例都适用于 Linux 内核。

有哪些类型的内核?

如此看来,你们大多数人可能对 Linux 内核很熟悉,至少是通过系统调用来与之交互。有些人可能也探索过 Windows 内核,我们将在本章中不会过多讨论。或者,macOS(BSD 的衍生品)的类 UNIX 内核。那些可能做过更多挖掘的人可能也遇到过类似的项目,例如 或 。

内核通常可以分为两大类,单内核或微内核。单内核基本上是一个内核及其所有相关服务作为一个单一程序。另一方面,微内核旨在有一个主要组件,它提供内核所需的最基本功能。这包括设置重要的设备驱动程序、根文件系统、分页或其他对实现其他高级功能至关重要的功能。然后,高级功能(如网络堆栈、其他文件系统和非关键设备驱动程序)作为可以与内核通过某种形式的 IPC(通常为 RPC)交互的独立程序实现。由于这种设计,微内核传统上比单内核慢,这是由于 IPC 开销。

从现在开始,我们将专注于讨论单内核,除非另有说明,特别是 Linux 内核。

系统调用揭秘

系统调用使用一个可以在用户空间运行的程序执行的指令,该指令会将控制权传递给内核(通过使用信号),以完成调用。这包括将数据写入磁盘、直接与硬件交互(通常)或与获取或放弃特权相关的操作(例如,成为 root 用户并获取所有能力)。

为了满足用户的需求,内核将依赖于 . 内核调用本质上内核的“公共”函数 - 由其他开发者为在其他内核部分中使用而实现的函数。以下是一个内核调用手册页的片段:

Name

kmalloc — allocate memory
Synopsis
void * kmalloc (	size_t size,
 	gfp_t flags);

Arguments

size_t size

    how many bytes of memory are required.
gfp_t flags

    the type of memory to allocate.

Description

kmalloc is the normal method of allocating memory for objects smaller than page size in the kernel.

The flags argument may be one of:

GFP_USER - Allocate memory on behalf of user. May sleep.

GFP_KERNEL - Allocate normal kernel ram. May sleep.

GFP_ATOMIC - Allocation will not sleep. May use emergency pools. For example, use this inside interrupt handlers.

你会注意到一些标志被标记为可能引起休眠。这告诉我们是否可以在特殊场景中使用这些标志,比如中断上下文,在这些场景中速度至关重要,可能阻塞或等待其他进程的操作可能永远不会完成。

容器化

随着我们进入一个前所未有的规模时代,到 2018 年,大约有 200 亿个设备连接到互联网,我们需要帮助我们在可扩展性方面开发和维护软件的技术。此外,随着软件的复杂性增加,设计安全软件变得更加困难,我们发现我们在开发应用程序时面临新的约束。似乎这还不够,简化软件分发和开发的努力,如包管理器系统,常常会导致自己的头痛,导致损坏的包、无法解决的依赖关系以及其他成为今天非常普遍的环境噩梦。虽然这些最初看起来像是相互独立的问题,但所有这些问题以及更多的问题都可以通过向问题投掷来解决。

什么是容器?

容器几乎就像是一个虚拟机。在某些方面,容器与虚拟机的关系就像线程与进程的关系。容器是一个轻量级的环境,它与主机机器共享资源和内核,同时将自己与其他容器或主机上的进程隔离开来。你可能在工作过程中遇到过容器,比如 ,可能是最著名的容器实现之一。

Linux 命名空间

从零开始构建容器

野外的容器:软件分发变得简单快捷

附录

Shell

实际上,shell 是您与系统交互的方式。在用户友好的操作系统出现之前,当计算机启动时,您所能访问的只有 shell。这意味着您所有的命令和编辑都必须以这种方式完成。如今,我们的计算机以桌面模式启动,但您仍然可以通过终端访问 shell。

(Stuff) $

它已准备好接收您的下一个命令!您可以在 shell 中输入许多 Unix 工具,如lsgrep,shell 将执行它们并给出结果。其中一些是所谓的内建命令,即代码在 shell 程序本身中。还有一些是编译后的程序,您运行它们。shell 只通过一个名为 path 的特殊变量进行查找,该变量包含一系列以冒号分隔的路径,用于搜索具有您名称的可执行文件,以下是一个示例路径。

$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:
/usr/bin:/sbin:/bin:/usr/games:/usr/local/games

因此,当 shell 执行.时,它会遍历所有这些目录,找到并执行它。

$ ls
...
$ /bin/ls

您始终可以通过完整路径来调用。这就是为什么在过去的课程中,如果您想在终端上运行某些东西,您通常必须这样做,因为您正在工作的目录通常不在变量中。.代表您的当前目录,而 shell 执行的是一条有效命令。

Shell 技巧和提示

  • 上箭头会获取您最近的命令

  • 将搜索您之前运行的命令

  • 将中断您的 shell 进程

  • 将执行最后一条命令

  • 回退那么多命令并运行它们

  • 运行具有该前缀的最后一条命令

  • 是上一个命令的最后一个参数

  • 是上一个命令的所有参数

  • 将最后一个命令中的模式pat替换为替换sub

  • 进入上一个目录

  • 将当前目录推送到栈上并cds

  • cds到栈顶的目录

什么是终端?

终端是一个显示 shell 输出的应用程序。您可以使用默认的终端、基于 quake 的终端、terminator 等,选项无穷无尽!

常用工具

  1. 连接多个文件。它通常用于将文件内容打印到终端,但其原始用途是连接。

    $ cat file.txt
    ...
    $ cat shakespeare.txt shakespeare.txt > two_shakes.txt
    
  2. 告诉您两个文件之间的差异。如果没有打印任何内容,则返回零,表示文件在每个字节上都是相同的。否则,将打印出最长公共子序列的差异。

    $ cat prog.txt
    hello
    world
    $ cat adele.txt
    hello
    it's me
    $ diff prog.txt prog.txt
    $ diff shakespeare.txt shakespeare.txt
    2c2
    < world
    ---
    > it's me
    
  3. 告诉您文件或标准输入中的哪些行与 POSIX 模式匹配。

    $ grep it adele.txt
    it's me
    
  4. 告诉您当前目录中有哪些文件。

  5. 这是一个 shell 内建命令,但它会改变到相对或绝对目录

    $ cd /usr
    $ cd lib/
    $ cd -
    $ pwd
    /usr/
    
  6. 每个系统程序员的 favorite 命令会告诉您更多关于您所有 favorite 函数的信息!

  7. 根据 makefile 执行程序。

语法

壳有很多有用的工具,比如使用重定向将一些输出保存到文件。这会从文件开头覆盖文件。如果你只想追加到文件,你可以使用。Unix 还允许文件描述符交换。这意味着你可以将一个文件描述符的输出取走,并使其看起来像是从另一个文件描述符输出的。最常见的一个是,这意味着取走 stderr 并使其看起来像是从标准输出输出的。这很重要,因为当你使用时,它们只写入文件的标准输出。下面有一些例子。

$ ./program > output.txt # To overwrite
$ ./program >> output.txt # To append
$ ./program 2>&1 > output_all.txt # stderr & stdout
$ ./program 2>&1 > /dev/null # don't care about any output

管道运算符有着迷人的历史。UNIX 哲学是编写小的程序并将它们连接起来以完成新的和有趣的事情。在早期,硬盘空间有限,写入速度慢。Brian Kernighan 想要保持这种哲学,同时省略掉占用硬盘空间的中间文件。因此,UNIX 管道应运而生。管道将左侧程序的输出取走并喂给右侧程序的输入。考虑命令 ls | grep file。它可以作为重定向运算符的替代品,因为 tee 会同时写入文件并输出到标准输出。它还有额外的优点,即它不需要是列表中的最后一个命令。这意味着,你可以写入一个中间结果并继续你的管道操作。

$ ./program | tee output.txt # Overwrite
$ ./program | tee -a output.txt # Append
$ head output.txt | wc | head -n 1 # Multi pipes
$ ((head output.txt) | wc) | head -n 1 # Same as above
$ ./program | tee intermediate.txt | wc

&&|| 是按顺序执行命令的运算符。&& 只有在之前的命令成功时才会执行命令,并且总是执行下一个命令。

$ false && echo "Hello!"
$ true && echo "Hello!"
$ false || echo "Hello!"

环境变量是什么?

每个进程都有自己的环境变量字典,这些变量会被复制到子进程中。这意味着,如果父进程更改了它们的变量,这些变量不会传递给子进程,反之亦然。这在 fork-exec-wait 三部曲中很重要,如果你想要以与父进程(或任何其他进程)不同的环境变量执行程序。

例如,你可以编写一个 C 程序,遍历所有时区并执行打印所有本地日期和时间的命令。环境变量被用于各种程序,因此修改它们很重要。

结构打包

结构可能需要一种叫做填充(教程)的东西。我们并不期望你在本课程中打包结构,但要知道编译器会执行它。这是因为早期(甚至现在)在内存中加载地址是在 32 位或 64 位块中发生的。这也意味着请求的地址必须是块大小的倍数。

struct picture{
 int height;
 pixel** data;
 int width;
 char* encoding;
}

你认为图片看起来像这样。一个盒子是四个字节。

六个盒子的结构

六个盒子的结构

[图:clean_struct]

然而,使用结构打包,从概念上看起来是这样的:

struct picture{
 int height;
 char slop1[4];
 pixel** data;
 int width;
 char slop2[4];
 char* encoding;
}

从视觉上看,我们会在我们的图中添加两个额外的盒子

八个盒子的结构,两个盒子的冗余

八个盒子的结构,两个盒子的冗余

[图:sloppy_struct]

这种填充在 64 位系统上很常见。在其他时候,如果处理器支持非对齐访问,那么编译器就可以打包结构体。这意味着什么?我们可以让变量从一个非 64 位边界开始。处理器将处理其余部分。为了启用此功能,设置一个属性。

struct __attribute__((packed, aligned(4))) picture{
 int height;
 pixel** data;
 int width;
 char* encoding;
}

现在我们的图将看起来像图[fig:clean_struct]中的干净结构体。但现在,每次处理器需要访问或时,都需要两次内存访问。一个可能的替代方案是重新排序结构体。

struct picture{
 int height;
 int width;
 pixel** data;
 char* encoding;
}

栈溢出

每个线程使用一个栈内存。栈‘向下增长’ - 如果一个函数调用另一个函数,那么栈就会扩展到较小的内存地址。栈内存包括非静态自动(临时)变量、参数值和返回地址。如果缓冲区太小,某些数据(例如用户输入的值),那么其他栈变量甚至返回地址被覆盖的可能性是真实的。栈内容的精确布局和自动变量的顺序取决于架构和编译器。通过一点调查工作,我们可以了解如何故意针对特定架构破坏栈。

下面的示例演示了返回地址是如何存储在栈上的。对于特定的 32 位架构Live Linux Machine,我们确定返回地址存储在自动变量地址之上的两个指针(8 字节)位置。代码故意更改栈值,以便当输入函数返回时,而不是在主方法内部继续执行,它将跳转到漏洞函数。

// Overwrites the return address on the following machine:
// http://cs-education.github.io/sys/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void breakout() {
 puts("Welcome. Have a shell...");
 system("/bin/sh");
}
void input() {
 void *p;
 printf("Address of stack variable: %p\n", &p);
 printf("Something that looks like a return address on stack: %p\n", *((&p)+2));
 // Let's change it to point to the start of our sneaky function.
 *((&p)+2) = breakout;
}
int main() {
 printf("main() code starts at %p\n",main);

 input();
 while (1) {
 puts("Hello");
 sleep(1);
 }

 return 0;
}

计算机绕过这种限制的方法有很多种。

编译和链接

这是从你编译程序到运行程序的高层次概述。我们通常知道编译程序是容易的。你通过 IDE 或终端运行程序,它就正常工作了。

$ cat main.c
#include <stdio.h>

int main() {
 printf("Hello World!\n");
 return 0;
}
$ gcc main.c -o main
$ ./main
Hello World!
$

这里是使用 gcc 编译的粗略阶段。

  1. 预处理:预处理器扩展所有预处理指令。

  2. 解析:编译器解析文本文件以查找函数声明、变量声明等。

  3. 生成汇编代码:如果启用了优化,编译器随后为所有函数生成汇编代码。

  4. 汇编:汇编器将汇编代码转换为 0 和 1,并创建一个目标文件。这个目标文件将名称映射到代码片段。

  5. 静态链接:链接器随后会处理一系列对象和静态库,并解决从一个对象文件到另一个对象文件的变量和函数引用。链接器接着找到主方法,将其作为函数的入口点。链接器还会注意到某个函数打算进行动态链接。编译器还会在可执行文件中创建一个部分,告诉操作系统这些函数在运行前需要地址。

  6. 动态链接:当程序准备执行时,操作系统会查看程序需要的库,并将这些函数链接到动态库中。

  7. 程序开始运行。

以后的课程将教你关于解析和汇编——预处理是解析的扩展。大多数课程不会教你关于两种不同类型的链接。静态链接库类似于合并对象文件。要创建静态库,编译器将不同的对象文件组合成一个可执行文件。静态库实际上是对象文件的归档。这些库在你希望可执行文件安全、你知道被包含到可执行文件中的所有代码,以及可移植时很有用,这意味着所有代码都捆绑到你的可执行文件中,不需要额外安装。

另一种类型是动态库。通常,动态库是在用户范围或系统范围内安装的,并且大多数程序都可以访问。动态库的函数在运行前填充。这种方式有许多好处。

  • 对于像 C 标准库这样的常用库,代码占用空间更小。

  • 晚期绑定意味着更通用的代码和更少依赖于特定行为。

  • 区分意味着在保持可执行文件不变的情况下,可以更新共享库。

同时也存在一些缺点。

  • 所有代码不再捆绑到你的程序中。这意味着用户必须安装其他东西。

  • 其他代码中可能存在安全漏洞,导致你的程序出现安全漏洞。

  • 标准 Linux 允许你“替换”动态库,这可能导致可能的社会工程攻击。

  • 这给应用程序增加了额外的复杂性。两个具有不同共享库的二进制文件可能会导致不同的结果。

关于 Fork-FILE 问题的解释

要解析POSIX 文档,我们必须深入研究术语。设定期望的句子如下

函数调用涉及任何单个句柄(“活动句柄”)的结果在本卷 POSIX.1-2008 的其他地方定义,但如果使用两个或更多句柄,并且其中任何一个是一个流,则应用程序应确保它们的行为协调,如下所述。如果没有这样做,结果是不确定的。

这意味着,如果我们使用两个文件描述符时没有完全遵循 POSIX 规范,而这些文件描述符跨进程引用相同的描述符,那么我们得到的是未定义的行为。从技术角度讲,文件描述符必须有一个“位置”的含义,这意味着它需要有一个开始和结束,就像文件一样,而不是像任意字节流一样。POSIX 然后引入了活动句柄的概念,其中句柄可以是文件描述符或指针。文件句柄没有名为“活动”的标志。一个活动文件描述符是指当前正在用于读取、写入和其他操作(如)的文件描述符。标准指出,在执行之前,应用程序或您的代码必须执行一系列步骤来准备文件的状态。用简单的话说,描述符需要关闭、刷新或读取到其全部内容——详细的细节将在后面解释。

为了使句柄变为活动句柄,应用程序应确保在句柄的最后一次使用(当前活动句柄)和第二次句柄(未来的活动句柄)的第一次使用之间执行以下操作。然后第二个句柄变为活动句柄。在第一个句柄再次成为活动文件句柄之前,应用程序影响第一个句柄上的文件偏移量的所有活动都应暂停。(如果流函数的底层函数会影响文件偏移量,则流函数应被视为影响文件偏移量。)

总结来说,如果两个文件描述符正在被积极使用,其行为是未定义的。另一个注意事项是,在 fork 之后,库代码必须将文件描述符准备成好像其他进程可能在任何时间将其激活一样。最后一个要点关注的是在我们的情况下,进程如何准备文件描述符。

如果流以允许读取的模式打开,并且底层打开的文件描述符引用的设备能够进行定位,则应用程序必须执行 fflush(),或者关闭流。

文档说明,子进程需要执行一个 fflush 或关闭流,因为文件描述符需要准备,以防父进程需要使其活跃。如果 glibc 关闭了父进程可能期望保持打开的文件描述符,那么它将陷入一个无法赢的局面,因此它会在退出时选择执行 fflush,因为在 POSIX 术语中,退出被视为访问文件。这意味着对于我们的父进程,这个条款会被触发。

如果任何先前活动句柄已被一个明确更改文件偏移量的函数使用,除了如上所述为第一个句柄所要求之外,应用程序应执行 lseek() 或 fseek()(根据句柄类型适当选择)到适当的位置。

由于孩子调用了 fflush 而父进程没有准备,操作系统会选择将文件重置的位置。不同的文件系统会执行不同的操作,这些操作都得到了标准的支持。操作系统可能会查看修改时间并得出结论,文件没有变化,因此不需要重置,或者得出退出表示变化的结论,需要将文件回滚到开始位置。

银行家算法

我们可以从单个资源银行家算法开始。考虑一个银行家,她拥有有限数量的金钱。拥有有限数量的金钱,她想要发放贷款并最终收回她的钱。假设我们有一组 nn 个人,其中每个人都需要获得一定数量的或限制的 aia_i (ii 是第 ii 个进程) 才能开始工作。银行家跟踪她给每个人的金额 lil_i。她始终保留一定数量的金钱 pp。人们为了请求金钱,会执行以下操作:考虑系统在时间 tt 的状态 (A={a1,a2,...},Lt={lt,1,lt,2,...},p)(A={a_1, a_2, ...}, L_t={l_{t,1}, l_{t,2}, ...}, p)。一个先决条件是,我们拥有 pmin(A)p \geq min(A),或者我们有足够的钱来满足至少一个人。此外,每个人将工作一段有限的时间并归还我们的钱。

  • 一个人 jj 向我请求 mm

    • 如果 mpm \geq p,他们将被拒绝。

    • 如果 m+lj>aim + l_j > a_i 他们将被拒绝

    • 假设我们处于一个新的状态 (A,Lt+1={..,lt+1,j=lt,j+m,...},pm)(A, L_{t+1}={.., l_{t+1, j} = l_{t, j} + m, ...}, p - m) 其中进程被赋予了资源。

  • 如果现在的人 jj 已经满足(lt+1,j==ajl_{t+1,j} == a_j)或者 min(ailt+1,i)pmin(a_i - l_{t+1, i}) \leq p。换句话说,我们还有足够的钱来满足另一个人。如果满足任何一个条件,则认为交易安全并给予他们钱。

为什么这行得通呢?好吧,一开始我们处于一个安全状态——定义为我们有足够的钱至少满足一个人。这些“贷款”中的每一个都导致一个安全状态。如果我们用尽了储备,一个人在工作并会给我们比我们之前的“贷款”更多的或相等的钱,从而再次让我们处于安全状态。由于我们总是可以做出一个额外的动作,系统永远不会发生死锁。现在,没有保证系统不会发生活锁。如果我们希望请求某事的进程永远不会这样做,那么就不会有任何工作完成——但这不是由于死锁。这个类比可以扩展到更高的数量级,但要求一个进程可以完全完成其工作,或者存在一个进程,其资源的组合可以满足,这使得算法稍微复杂一些(一个额外的循环),但没有什么太大的问题。有一些显著的缺点。

  • 程序首先需要知道每个进程需要多少每种资源。很多时候这是不可能的,或者进程请求了错误数量的资源,因为程序员没有预见这一点。

  • 系统可能会发生活锁。

  • 我们知道在大多数系统中资源是变化的,比如管道和套接字。这意味着算法的运行时间可能会对拥有数百万资源的系统很慢。

  • 此外,这个算法无法跟踪资源的来去。一个进程可能作为副作用删除资源或创建资源。算法假设静态分配,并且每个进程执行非破坏性操作。

清洁/脏叉子(Chandy/Misra 解决方案)

还有更多高级解决方案。其中一种解决方案是由 Chandy 和 Misra 提出的(Chandy and Misra 1984)。这不是真正解决就餐哲学家问题的方案,因为它要求哲学家之间可以相互交谈。这是一个确保某种公平性的解决方案。本质上,它定义了一系列哲学家必须在一个回合中吃完,然后才能进入下一个回合的回合。

我们在这里不会详细说明证明过程,因为它稍微复杂一些,但您可以随时阅读更多内容。

行为模型

行为模型是另一种形式的同步,它不需要进行锁协商或等待。这个想法很简单。每个行为者可以执行工作、创建更多行为者、发送消息或响应消息。每当一个行为者需要从另一个行为者那里得到某些东西时,它会发送一个消息。最重要的是,一个行为者只负责一件事情。如果我们正在实现一个现实世界应用,我们可能有一个处理数据库的行为者,一个处理传入连接的行为者,一个服务连接的行为者等。这些行为者会相互传递消息,比如“有一个新的连接”从传入连接行为者到服务行为者。服务行为者可能向数据库行为者发送数据请求消息,并返回数据响应消息。

虽然这似乎是一个完美的解决方案,但也有一些缺点。第一个缺点是实际的通信库需要同步。如果你没有现成的框架来做这件事——比如消息传递接口(Message Passing Interface,简称 MPI)或高性能计算中的 MPI——那么框架将不得不被构建,而且很可能构建一个高效框架的工作量与直接同步相当。此外,现在消息在序列化和反序列化时遇到了额外的开销,至少是额外的开销。最后一个缺点是,一个演员可能需要任意长的时间来响应一条消息,这促使需要影子演员来处理相同的工作。

正如之前提到的,有一些框架,如基于演员模型的消息传递接口,它允许高性能计算中的分布式系统有效地工作,但效果可能会有所不同。如果你想进一步了解这个模型,请随意浏览以下列出的维基百科页面。关于演员模型的进一步阅读

包含和条件

另一个预处理器包含是指令和条件。包含指令通过示例进行解释。

// foo.h
int bar();

这是我们的未预处理文件。

#include "foo.h"
int bar() {
}

预处理之后,编译器看到的是这个。

// foo.c unpreprocessed
int bar();

int bar() {

}

另一个工具是预处理器条件。如果一个宏被定义或为真值,那么就选择这个分支。

int main() {
 #ifdef __GNUC__
 return 1;
 #else
 return 0;
 #endif
}

使用你的编译器会对源代码进行预处理,生成以下内容。

int main() {
 return 1;
}

使用你的编译器会对源代码进行预处理。

int main() {
 return 0;
}

线程调度

有几种方法可以将工作分割开来。这些方法在 OpenMP 框架中很常见(Silberschatz, Galvin, 和 Gagne 2005)。

  • 将问题分解成固定大小的块(预先确定的),并且让每个线程处理每个块。当每个子问题花费的时间大致相同的时候,这种方法很有效,因为没有额外的开销。你只需要写一个循环,并将映射函数给每个子数组。

  • 当一个新问题出现需要线程来处理时,这是很有用的。当你不知道调度需要多长时间时,这种方法特别有用。

  • 这是一种结合了上述方法,并混合了优点和权衡的方法。你从静态调度开始,如果需要的话,慢慢过渡到动态调度。

  • 你根本不知道问题需要多长时间。与其自己决定,不如让程序决定该做什么!

虽然不需要记住任何调度例程。OpenMP 是一个标准,是 pthreads 的替代品。例如,以下是如何并行化一个 for 循环的示例。

#pragma omp parallel for
for (int i = 0; i < n; i++) {
 // Do stuff
}

// Specify the scheduling as follows
// #pragma omp parallel for scheduling(static)

静态调度会将问题分解成固定大小的块。动态调度会在循环结束后分配一个任务。引导调度是动态的,带有块。运行时间是整个一锅杂烩。

threads.h

我们在额外部分讨论了许多线程库。我们有标准的 POSIX 线程,OpenMP 线程,我们还有一个新的 C11 线程库,它是标准的一部分。这个库提供了受限功能。

为什么使用受限功能?关键在于名称。由于这是 C 标准库,它必须在所有兼容的操作系统(几乎所有的操作系统)中实现。这意味着在使用线程时具有一等可移植性。

我们不会对函数进行冗长的讨论。它们大多数只是 pthread 函数的重命名。如果你问为什么我们不教授这些,有几个原因

  1. 它们相当新。尽管标准大约在 2011 年发布,但 POSIX 线程已经存在很长时间了。它们的大多数怪癖已经被消除。

  2. 你会失去表达性。这是一个我们将在后续章节中讨论的概念,但当你使某物可移植时,你会失去一些与主机硬件的表达性。这意味着 threads.h 库相当基础。很难设置 CPU 亲和性。一起调度线程。为了性能原因,有效地查看内部结构。

  3. 许多遗留代码已经考虑了 POSIX 线程。其他库如 OpenMP、CUDA、MPI 将要么使用 POSIX 进程或 POSIX 线程,对 Windows 的移植则有些勉强。

现代文件系统

尽管大多数文件系统的 API 在 POSIX 上多年来保持不变,但实际的文件系统本身提供了许多重要的方面。

  • 数据完整性。文件系统使用日志记录和有时使用校验和来确保写入的数据是有效的。日志记录是一种简单的发明,其中文件系统将操作写入日志。如果在操作完成之前文件系统崩溃,它可以在再次启动时使用部分日志恢复操作。

  • 缓存。Linux 在缓存文件系统操作方面做得很好,比如查找 inode。这使得磁盘操作看起来几乎是瞬间的。如果你想看到一个慢速的系统,看看使用 FAT/NTFS 的 Windows。磁盘操作需要由应用程序缓存,否则它将耗尽 CPU。

  • 速度。在旋转磁盘机器上,位于金属盘末尾的数据将旋转得更快(角速度离中心更远)。程序利用这一点来减少在视频编辑软件中加载大型文件(如电影)的时间。SSD 没有这个问题,因为没有旋转磁盘,但它们会从它们的空间中划分出一部分作为“交换空间”用于文件。

  • 并行性。具有多个头(用于物理硬盘)或多个控制器(用于 SSD)的文件系统可以通过复用 PCIe 插槽中的数据来利用并行性,始终在可能的情况下为应用程序提供服务。

  • 加密。数据可以使用一个或多个密钥进行加密。苹果的 APFS 文件系统是一个很好的例子。

  • 冗余。有时数据可以复制到块中,以确保数据始终可用。

  • 高效备份。我们中许多人由于各种原因无法将数据存储在云上。当文件系统被用作备份介质或作为备份的来源时,能够高效地计算更改内容、压缩文件以及在外部驱动器之间同步是非常有用的。

  • 完整性和可启动性。文件系统需要能够抵御位翻转。大多数读者都将操作系统安装在与其用于不同操作的文件系统相同的分区上。文件系统需要确保随机的读取或写入不会破坏引导扇区——这意味着你的计算机无法再次启动。

  • 碎片化。就像内存分配器一样,为文件分配空间会导致内部和外部碎片化。当单个文件的磁盘块相邻时,也会出现相同的缓存优势。文件系统需要在低、高以及可能的碎片化使用下表现良好。

  • 分布式。有时,文件系统应该能够容忍单机故障。Hadoop 和其他分布式文件系统允许你做到这一点。

切边文件系统

现在有一些文件系统硬件确实是真正的前沿技术。我们简要想提到的就是 AMD 的 StoreMI。我们并不是在试图推销 AMD 芯片组,但 StoreMI 的功能集值得提及。

StoreMI 是一个硬件微控制器,它分析操作系统如何访问文件,并将文件/块移动到一起以加快加载时间。一个常见的用法可以想象为拥有一个快速但容量小的 SSD 和一个较慢但容量大的 HDD。为了让所有文件看起来都存储在 SSD 上,StoreMI 会匹配文件访问模式。如果你正在启动 Windows,Windows 通常会按相同的顺序访问许多文件。StoreMI 会注意到这一点,当微控制器发现它正在启动时,它会在操作系统请求之前将文件从 HDD 驱动器移动到 SSD。到操作系统需要它们的时候,它们已经在 SSD 上了。StoreMI 也会对其他应用程序做同样的事情。这项技术还有很多需要改进的地方,但它是一个有趣的数据和模式匹配与文件系统的交汇点。

Linux 调度

截至 2016 年 2 月,Linux 默认使用完全公平调度器进行 CPU 调度,以及预算公平调度“BFQ”进行 I/O 调度。适当的调度可以显著影响吞吐量和延迟。延迟对于交互式和软实时应用(如音频和视频流)非常重要。有关更多信息,请参阅此处的讨论和比较基准。

这里是 CFS 如何安排的

  • CPU 创建一个红黑树,包含进程的虚拟运行时间(运行时间/优先级值)和睡眠公平性标志——如果进程正在等待某物,当它完成等待时给它 CPU。

  • 优先级值是内核为某些进程提供优先级的方式,优先级值越低,优先级越高。

  • 内核根据这个指标选择最低的值,并将该进程调度为下一个运行,将其从队列中移除。由于红黑树是自平衡的,这个操作保证了 O(log(n))O(log(n))(选择最小进程的运行时间相同)

尽管它被称为公平调度器,但确实存在不少问题。

  • 被调度的进程组可能会有不平衡的负载,因此调度器大致分配负载。当另一个 CPU 空闲时,它只能查看一个组调度平均负载,而不是单个核心。因此,空闲 CPU 可能不会从平均负载良好的 CPU 那里获取工作。

  • 如果一组进程在非相邻的核心上运行,则存在一个错误。如果两个核心相距超过一个跳数,负载均衡算法甚至不会考虑该核心。这意味着如果有一个 CPU 空闲,而另一个正在做更多工作的 CPU 距离超过一个跳数,它将不会接受工作(可能已经被修复)。

  • 在一个线程在核心子集上休眠后,当它醒来时,它只能被调度在它休眠的核心上。如果这些核心现在正忙,线程将不得不等待它们,从而浪费了使用其他空闲核心的机会。

  • 要了解更多关于公平调度器的问题,请阅读这里

实现软件互斥锁

是的,通过一些搜索,今天在特定的简单移动处理器上找到它在生产中的使用是可能的。彼得森算法用于实现 Nvidia 的 Tegra 移动处理器(Nvidia 的系统级芯片 ARM 进程和 GPU 核心)的低级 Linux 内核锁锁源链接

现在,一般来说,CPU 和 C 编译器可以重新排序 CPU 指令或使用 CPU 核心特定的本地缓存值,如果另一个核心更新了共享变量,这些值就会过时。因此,一个简单的伪代码到 C 的实现对于大多数平台来说过于天真。警告,这里可能有龙!考虑这个高级而复杂的话题,但(剧透警告)有一个快乐的结局。考虑以下代码,

while(flag2) { /* busy loop - go around again */

一个高效的编译器会推断出变量在循环内部从未改变,因此该测试可以被优化为使用,这有助于防止编译器进行此类优化。

假设我们通过告诉编译器不要优化来解决这个问题。优化编译器可以重新排序独立指令,或者 CPU 通过乱序执行优化在运行时重新排序指令。

相关的挑战是 CPU 核心包含一个数据缓存来存储最近读取或修改的主内存值。修改后的值可能不会立即写回主内存或从内存中重新读取。因此,如上述示例中的标志和转换变量的状态等数据变化可能不会在两个 CPU 代码之间共享。

但有一个美好的结局。现代硬件使用“内存栅栏”也称为内存屏障来解决这些问题。这防止了指令在屏障之前或之后被排序。这会损失性能,但对于正确的程序来说是必需的!

此外,还有 CPU 指令来确保主内存和 CPU 缓存处于合理且一致的状态。高级同步原语,例如,将把这些 CPU 指令作为它们实现的一部分。因此,在实践中,在关键部分周围使用互斥锁和解锁调用就足以忽略这些低级问题。

对于进一步阅读,我们建议以下网络帖子,讨论在 x86 进程上实现彼得森算法以及 Linux 文档中的内存屏障。

  1. 内存栅栏

  2. 内存屏障

假唤醒的奇怪案例

条件变量需要互斥锁有几个原因。其中一个是,需要一个互斥锁来同步线程间条件变量的变化。想象一下,条件变量需要提供自己的内部同步来确保其数据结构正常工作。通常,我们使用互斥锁来同步代码的其他部分,那么为什么还要增加使用条件变量的成本。另一个例子与高优先级系统相关。让我们看看一个代码片段。

// Thread 1
while (answer < 42) pthread_cond_wait(cv);

// Thread 2
answer = 42
pthread_cond_signal(cv);

无互斥锁的信号

线程 1 线程 2
while(answer < 42)
answer++
pthread_cond_signal(cv)
pthread_cond_wait(cv)

这里的问题是程序员期望信号唤醒等待的线程。由于指令可以在没有互斥锁的情况下交错,这导致了对应用程序设计者来说令人困惑的交错。请注意,从技术上讲,条件变量的 API 已经满足。等待调用在信号调用之后发生,并且信号只需要释放最多一个在等待调用之前发生的线程。

另一个问题是需要满足实时调度关注点,我们在这里只是概述。在一个时间关键的应用程序中,具有最高优先级的等待线程应该首先被允许继续。为了满足这一要求,在调用或之前也必须锁定互斥锁。对于好奇的人,这里有一个更长的、历史性的讨论

条件等待示例

该调用执行三个操作:

  1. 解锁互斥锁。互斥锁必须被锁定。

  2. 等待直到在同一个条件变量上调用。

  3. 在返回之前,锁定互斥锁。

条件变量总是与互斥锁一起使用。在调用wait之前,必须锁定互斥锁,并且wait必须被循环包裹。

pthread_cond_t cv;
pthread_mutex_t m;
int count;

// Initialize
pthread_cond_init(&cv, NULL);
pthread_mutex_init(&m, NULL);
count = 0;

// Thread 1
pthread_mutex_lock(&m);
while (count < 10) {
 pthread_cond_wait(&cv, &m);
 /* Remember that cond_wait unlocks the mutex before blocking (waiting)! */
 /* After unlocking, other threads can claim the mutex. */
 /* When this thread is later woken it will */
 /* re-lock the mutex before returning */
}
pthread_mutex_unlock(&m);

//later clean up with pthread_cond_destroy(&cv); and mutex_destroy

// Thread 2:
while (1) {
 pthread_mutex_lock(&m);
 count++;
 pthread_cond_signal(&cv);
 /* Even though the other thread is woken up it cannot not return */
 /* from pthread_cond_wait until we have unlocked the mutex. This is */
 /* a good thing! In fact, it is usually the best practice to call */
 /* cond_signal or cond_broadcast before unlocking the mutex */
 pthread_mutex_unlock(&m);
}

这是一个相当简单的例子,但它表明我们可以以标准化的方式告诉线程唤醒。在下一节中,我们将使用这些来实现高效的阻塞数据结构。

仅使用互斥锁实现 CV

仅使用互斥锁实现条件变量并不简单。以下是我们如何做到这一点的大致草图。

typedef struct cv_node_ {
 pthread_mutex_t *dynamic;
 int is_awoken;
 struct cv_node_ *next;
} cv_node;

typedef struct {
 cv_node_ *head
} cond_t

void cond_init(cond_t *cv) {
 cv->head = NULL;
 cv->dynamic = NULL;
}

void cond_destroy(cond_t *cv) {
 // Nothing to see here
 // Though may be useful for the future to put pieces
}

static int remove_from_list(cond_t *cv, cv_node *ptr) {
 // Function assumes mutex is locked
 // Some sanity checking
 if (ptr == NULL) {
 return
 }

 // Special case head
 if (ptr == cv->head) {
 cv->head = cv->head->next;
 return;
 }

 // Otherwise find the node previous
 for (cv_node *prev = cv->head; prev->next; prev = prev->next) {
 // If we've found it, patch it through
 if (prev->next == ptr) {
 prev->next = prev->next->next;
 return;
 }
 // Otherwise keep walking
 prev = prev->next;
 }

 // We couldn't find the node, invalid call

}

这都是一些无聊的定义性内容。有趣的内容在下面。

void cond_wait(cond_t *cv, pthread_mutex_t *m) {
 // See note (dynamic) below
 if (cv->dynamic == NULL) {
 cv->dynamic = m
 } else if (cv->dynamic != m) {
 // Error can't wait with a different mutex!
 abort();
 }
 // mutex is locked so we have the critical section right now
 // Create linked list node _on the stack_
 cv_node my_node;
 my_node.is_awoken = 0;
 my_node.next = cv->head;
 cv->head = my_node.next;
 pthread_mutex_unlock(m);

 // May do some cache busting here
 while(my_node == 0) {
 pthread_yield();
 }

 pthread_mutex_lock(m);
 remove_from_list(cv, &my_node);

 // The dynamic binding is over
 if (cv->head == NULL) {
 cv->dynamic = NULL;
 }
}

void cond_signal(cond_t *cv) {
 for (cv_node *iter = cv->head; iter; iter = iter->next) {
 // Signal makes sure one thread that has not woken up
 // is woken up
 if (iter->is_awoken == 0) {
 // DON'T remove from the linked list here
 // There is no mutual exclusion, so we could
 // have a race condition
 iter->is_awoken = 1;
 return;
 }
 }

 // No more threads to free! No-op
}

void cond_broadcast(cond_t *cv) {
 for (cv_node *iter = cv->head; iter; iter = iter->next) {
 // Wake everyone up!
 iter->is_awoken = 1;
 }
}

这是如何工作的呢?我们不是分配可能导致死锁的空间。我们保持数据结构或链表节点在每个线程的栈上。等待函数中的链表是在线程拥有互斥锁锁定的时候创建的。这很重要,因为我们可能在插入和删除时遇到竞态条件。一个更健壮的实现将每个条件变量都有一个互斥锁。

关于(动态)的注释是什么?在 pthread man 页面上,wait 创建了一个运行时绑定到互斥锁。这意味着在第一次调用之后,一个互斥锁与一个条件变量相关联,同时还有线程在该条件变量上等待。每个新进入的线程必须具有相同的互斥锁,并且它必须被锁定。因此,wait 的开始和结束(除了 while 循环之外的所有内容)是互斥的。当最后一个线程离开时,即当 head 为 NULL 时,绑定就会丢失。

信号和广播函数只是分别告诉一个线程或所有线程它们应该被唤醒。它不会修改链表,因为没有互斥锁来防止两个线程同时调用 signal 或 broadcast 时的损坏。

现在是一个高级点。你看到广播在这种情况下如何可能导致虚假唤醒吗?考虑以下一系列事件。

  1. 有多于 2 个线程开始等待

  2. 另一个线程调用广播。

  3. 那个调用广播的线程在唤醒任何线程之前被停止。

  4. 另一个线程在条件变量上调用 wait 并将自己添加到队列中。

  5. 广播遍历并释放所有线程。

在高性能互斥锁中,无法保证广播调用和线程添加的确切时间。防止这种行为的方法是包含 Lamport 时间戳或要求以互斥锁调用广播。这样,在广播调用之前发生的事情就不会被信号化。同样的论点也适用于信号。

你也注意到了其他什么吗?这就是为什么我们要求你在解锁之前发出信号或广播。如果你在解锁后广播,广播所需的时间可能是无限的!

  1. 在等待线程队列上调用广播

  2. 首个线程被释放,广播线程被冻结。由于互斥锁被解锁,它被锁定并继续。

  3. 它持续了如此长的时间,以至于再次调用了广播。

  4. 使用我们的条件变量实现,这将终止。如果你有一个将元素追加到列表尾部并从头部到尾部迭代的实现,这可能会无限次地继续。

在高性能系统中,我们想要确保调用 wait 的每个线程不会被调用 wait 的另一个线程超越。根据我们当前的 API,我们无法保证这一点。我们可能需要要求用户传递一个互斥锁或使用全局互斥锁。相反,我们告诉程序员在解锁之前总是发出信号或广播。

高阶同步模型

当使用原子时,你需要指定正确的同步模型以确保程序正确运行。你可以在gcc wiki上了解更多关于它们的信息。这些例子是从那些例子改编的。

顺序一致性

顺序一致性是最简单、最不易出错且最昂贵的模型。这个模型表示,任何发生的变化,其之前所有的变化都将被同步到所有线程之间。

 Thread 1                    Thread 2
    1.0 atomic_store(x, 1)
    1.1 y = 10                  2.1 if (atomic_load(x) == 0)
    1.2 atomic_store(x, 0);     2.2    y != 10 && abort();

永远不会退出。这是因为要么在线程 2 中存储操作发生在 if 语句之前,且 y 等于 1,要么存储操作发生在之后,且 x 不等于 2。

Relaxed

Relaxed 是一种简单的内存顺序,提供了更多的优化。这意味着只需要特定的操作是原子的。可以存在过时的读取和写入,但在读取新值之后,它不会变得过时。

 -Thread 1-              -Thread 2-
    atomic_store(x, 1);     printf("%d\n", x) // 1
    atomic_store(x, 0);     printf("%d\n", x) // could be 1 or 0
                            printf("%d\n", x) // could be 1 or 0

但这意味着之前的加载和存储不需要影响其他线程。在先前的例子中,代码现在可能会失败。

Acquire/Release

原子变量的顺序不需要一致——这意味着如果原子变量 y 被赋值为 10,而原子变量 x 为 0,这些值不需要传播,线程可能会得到过时的读取。不过,非原子变量必须在所有线程中更新。

消费

想象一下与上面相同的情况,除了非原子变量不需要在所有线程中更新。引入这种模型是为了能够在不混合 Relaxed 的情况下有一个 Acquire/Release/Consume 模型,因为 Consume 类似于 Relaxed。

Actor 模型和 Goroutines

除了本书中描述的并发方法之外,还有很多其他方法。Posix 线程是最细粒度的线程结构,允许对线程和 CPU 进行紧密控制。其他语言也有它们的抽象。我们将讨论一种类似于 C 的简单性和设计语言的 go 或 golang。要获得 5 分钟入门,请随意阅读learn x in y 指南中的 go。以下是我们在 Go 中创建“线程”的方法。

func hello(out) {
    fmt.Println(out);
}

func main() {
    to_print := "Hello World!"
    go hello(to_print)
}

这实际上创建了一个被称为 goroutine 的东西。goroutine 可以被视为一个轻量级的线程。内部,它是一个线程池,执行所有运行 goroutine 的指令。当一个 goroutine 需要停止时,它将被冻结,并“上下文切换”到另一个线程。上下文切换加引号,因为这是在运行时级别完成的,而不是在操作系统级别完成的实际上下文切换。

gofuncs 的优势相当直观。没有样板代码,没有连接,也没有奇特的类型转换。

我们仍然可以在 Go 中使用互斥锁来执行我们的最终结果。考虑之前的计数器示例。

var counter = 0;
var mut sync.Mutex;
var wg sync.WaitGroup;

func plus() {
  mut.Lock()
  counter += 1
  mut.Unlock()
  wg.Done()
}

func main() {
  num := 10
  wg.Add(num);
  for i := 0; i < num; i++ {
    go plus()
  }

  wg.Wait()

  fmt.Printf("%d\n", counter);

}

但这很无聊且容易出错。相反,让我们使用演员模型。让我们指定两个演员。一个是主要演员,将执行主要的指令集。另一个演员将是计数器。计数器负责向一个内部变量添加数字。当我们想要添加并查看值时,我们将在线程之间发送消息。

const (
  addRequest = iota;
  outputRequest = iota;
)

func counterActor(requestChannel chan int, outputChannel chan int) {
  counter := 0

  for {
    req := <- requestChannel;
    if req == addRequest {
      counter += 1
    } else if req == outputRequest {
      outputChannel <- counter
    }
  }
}

func main() {
  // Set up the actor
  requestChannel := make(chan int)
  outputChannel := make(chan int)
  go counterActor(requestChannel, outputChannel)

  num := 10
  for i := 0; i < num; i++ {
    requestChannel <- addRequest
  }
  requestChannel <- outputRequest
  new_count := <- outputChannel
  fmt.Printf("%d\n", new_count);
}

虽然有更多的样板代码,但我们不再需要互斥锁!如果我们想扩展这个操作并做其他事情,比如按数字增加,或写入文件,我们可以让特定的演员来处理。这种责任区分对于确保你的设计能够很好地扩展非常重要。甚至还有库可以处理所有的样板代码。

概念上调度

本节可能对喜欢从数学角度分析这些算法的人有所帮助

如果你的同事问你该使用哪种调度算法,你可能没有分析每个算法的工具。那么,让我们从高层次思考调度算法,并按它们的执行时间来分解它们。我们将在这个随机过程调度的背景下进行评估,这意味着每个进程需要随机但有限的时间来完成。

只是一个提醒,以下是一些术语。

调度变量

概念 含义
开始时间 调度器首次开始工作的时间
结束时间 调度器完成进程的时间
到达时间 当作业首次到达调度器时
运行时间 如果没有抢占,进程需要多长时间才能运行

以及我们试图优化的度量。

调度效率度量

度量 公式
响应时间 开始时间减去到达时间
周转时间 结束时间减去到达时间
等待时间 结束时间减去到达时间减去运行时间

之后将讨论不同的用例。让一个进程运行的最大时间等于 SS。我们还将假设在任何给定时间都有有限数量的进程在运行 cc。以下是您需要了解的一些排队论概念,这将有助于简化理论。

  1. 排队论涉及一个随机变量控制着到达间隔时间——或者说两个不同进程到达之间的时间。我们不会命名这个随机变量,但我们将假设(1)它有一个平均值为 λ\lambda,并且(2)它服从泊松分布。这意味着在得到另一个进程后,得到一个进程 tt 单位时间的概率是 λt*exp(λ)t!\lambda^t * \frac{\exp(-\lambda)}{t!},其中 t!t! 在处理实数时可以近似为伽马函数。

  2. 我们将表示服务时间SS,并推导出等待时间WW和响应时间RR;更具体地说,所有这些变量的期望值E[S]E[S]推导出周转时间是简单的S+WS + W。为了清晰起见,我们将引入另一个变量NN,它是当前队列中的人数。排队论中的一个著名结果是 Little 定律,它指出E[N]=λE[W]E[N] = \lambda E[W],这意味着等待的人数是到达率乘以期望等待时间(假设队列处于稳定状态)。

  3. 我们不会对每个过程运行所需的时间做出太多假设,除了它将需要有限的时间——否则这几乎无法评估。我们将表示两个变量,其中1μ\frac{1}{\mu}是等待时间的平均值,而变异系数CC定义为C2=var(S)E[S]2C² = \frac{var(S)}{E[S]²},以帮助我们控制那些需要较长时间完成的过程。一个重要的注意事项是,当C>1C > 1时,我们说该过程的运行时间是可变的。我们将在下面指出,这会使得 FCFS 的等待和响应时间呈二次方增长。

  4. ρ=λμ<1\rho = \frac{\lambda}{\mu} < 1 否则,我们的队列将变得无限长

  5. 我们将假设只有一个处理器。这在排队论中被称为 M/G/1 队列。

  6. 我们将把服务时间作为期望值 SS 否则,我们可能会在代数中出现过度简化的情况。此外,使用服务时间作为共同因素更容易比较不同的排队规则。

先到先得

所有结果均来自 Jorma Virtamo 关于该主题的讲座(Virtamo, n.d.)。

  1. 第一项是预期等待时间。 E[W]=(1+C2)2ρ(1ρ)*E[S]E[W] = \frac{(1 + C²)}{2}\frac{\rho}{(1 - \rho)} * E[S]

    这是什么意思?当给定 ρ1\rho \rightarrow 1 或者平均作业到达率等于平均作业处理率时,等待时间会变长。此外,随着作业的方差增加,等待时间也会上升。

  2. 接下来是预期响应时间

    E[R]=E[N]E[S]=λE[W]*E[S]E[R] = E[N] * E[S] = \lambda * E[W] * E[S] 响应时间计算简单,它是队列中等待处理的人数乘以每个处理过程的预期服务时间。从上面的 Little 定律中,我们可以用这个来替换。因为我们已经知道了等待时间,所以也可以对响应时间进行推理。

  3. 对结果的讨论显示了康威和阿尔(Conway, Maxwell, and Miller 1967)发现的一些有趣的东西。任何非抢占式调度策略,如果不考虑进程的运行时间或优先级,将会有相同的等待时间、响应时间和周转时间。我们经常会将其作为基准。

轮询调度或处理器共享

从概率的角度分析轮询调度(Round Robin)是困难的,因为它非常依赖于状态。调度器安排的下一个工作需要它记住之前的工作。队列理论开发者做出了一个假设,即时间量子(time quanta)大约为零——忽略了上下文切换等因素。这引出了处理器共享的概念。许多不同的任务可以同时进行,但会经历速度下降。所有这些证明都将改编自 Harchol-Balter 的书籍(Harchol-Balter 2013)。如果你对此感兴趣,我们强烈建议你查阅这些书籍。对于没有队列理论背景的人来说,这些证明是直观的。

  1. 在我们跳到答案之前,让我们先对此进行推理。有了我们新发现的抽象,我们实际上有一个先来先服务(FCFS)队列,我们将比以前慢一点地处理每个工作。因为我们总是在处理一个工作

    E[W]=0E[W] = 0

    然而,在非严格分析处理器共享的情况下,调度器等待的时间最好近似为调度器需要等待的次数。你需要E[S]Q\frac{E[S]}{Q}个服务周期,其中QQ是量子,你还需要大约E[N]QE[N] * Q的时间在这些周期之间。导致平均时间为E[W]=E[S]E[N]E[W] = E[S] * E[N]

    这个证明非严格的原因是我们不能假设在循环之间平均总会有E[N]*QE[N] * Q时间,因为这取决于系统的状态。这意味着我们需要考虑处理延迟的各种变化。我们也不能在这种情况下使用 Little’s Law,因为没有真正的系统稳态。否则,我们就能证明一些奇怪的事情。

    有趣的是,我们不必担心车队效应或任何新进程的到来。总等待时间仍然由队列中的人数所限制。对于那些熟悉尾不等式的人来说,由于进程按照泊松分布到达,我们得到许多进程的概率会因 Chernoff 界限(所有到达都是相互独立的)而指数下降。这意味着我们大致可以假设进程数量的方差较低。只要平均服务时间是合理的,等待时间也会是合理的。

  2. 预期响应时间是E[R]=0E[R] = 0

    在严格的处理器共享下,它是 0,因为所有作业都在处理中。在实践中,响应时间是E[R]=E[N]*QE[R] = E[N] * Q

    其中QQ是量子。再次使用 Little’s Law,我们可以找出E[R]=λE[W]*QE[R] = \lambda E[W] * Q

  3. 另一个变量是服务时间,处理器共享的服务时间可以定义为 SPSS_{PS}。减速比是 E[SPS]=E[S]1ρE[S_{PS}] = \frac{E[S]}{1 - \rho} 这意味着当平均到达率等于平均处理时间时,作业完成所需的时间将趋于无穷大。在处理器共享的非严格分析中,我们假设 E[SRR]=E[S]+Q*ϵ,ϵ>0E[S_{RR}] = E[S] + Q * \epsilon, \epsilon > 0 ϵ\epsilon 是上下文切换所需的时间量。

  4. 这自然引出了比较,哪个更好?与非严格版本相比,响应时间大致相同,等待时间也大致相同,但请注意,关于作业变化的任何信息都没有被考虑进去。这是因为轮转调度(RR)不需要处理车队效应及其相关差异,否则在严格意义上先来先服务(FCFS)会更快。完成作业所需的时间也更多,但在高方差负载下,整体周转时间会更低。

非抢占优先级

我们将介绍存在 kk 个不同优先级的记号,并且 ρi>0\rho_i > 0 表示优先级 ii 的平均负载贡献。我们受到以下约束 i=0kρi=ρ\sum\limits_{i=0}^k \rho_i = \rho。我们还将表示 ρ(x)=i=0xρi\rho(x) = \sum\limits_{i=0}^x \rho_i,这是所有高于和类似优先级过程到 xx 的负载贡献。最后一个记号是,我们将假设获得优先级 ii 的过程的概率是 pip_i,并且自然地 j=0kpj=1\sum\limits_{j=0}^k p_j = 1

  1. 如果 E[Wi]E[W_i] 是优先级 ii 的等待时间,E[Wx]=(1+C)2ρ(1ρ(x))(1ρ(x1))E[Si]E[W_x] = \frac{(1 + C)}{2}\frac{\rho}{(1 - \rho(x))*( 1 - \rho(x-1))} * E[S_i] 的完整推导过程如书中所述。一个更有用的不等式是。

    E[Wx]1+C2ρ(1ρ))2E[Si]E[W_x] \leq \frac{1 + C}{2}* \frac{\rho}{(1 - \rho(x))²} * E[S_i] 因为添加ρx\rho_x只能增加总和,减少分母或增加整体函数。这意味着如果一个进程的优先级是 0,那么它只需要等待其他所有 P0 进程,这些进程应该在 FCFS 顺序中先到达。然后下一个优先级必须等待所有其他进程,依此类推。

    现在期望的总等待时间是

    E[W]=i=0kE[Wi]*piE[W] = \sum\limits_{i=0}^k E[W_i] * p_i

    现在我们有了符号混乱,让我们提取出重要的项。

    i=0kpi(1ρ(i))2\sum\limits_{i=0}^k \frac{p_i}{(1-\rho(i))²}

    我们将其与 FCFS 模型的

    11ρ\frac{1}{1-\rho}

    用话来说——你可以通过实验分布来解决这个问题——如果系统中有很多低优先级的进程,它们对平均负载的贡献不大,那么平均等待时间会大大降低。

  2. 每个进程的平均响应时间是

    E[Ri]=j=0iE[Nj]*E[Sj]E[R_i] = \sum\limits_{j = 0}^i E[N_j] * E[S_j]

    这意味着调度器需要等待所有优先级更高且相同的作业完成,然后一个进程才能开始。想象一下,进程需要等待轮到自己的一个 FCFS(先来先服务)队列序列。使用 Little 定律对不同颜色的作业和上述公式,我们可以简化这一点

    E[Ri]=j=0iλjE[Wj]*E[Sj]E[R_i] = \sum\limits_{j=0}^i \lambda_j E[W_j] * E[S_j]

    我们可以通过查看作业的分布来找到平均响应时间

    E[R]=i=0kpi[j=0kλjE[Wj]*E[Sj]]E[R] = \sum\limits_{i=0}^k p_i [\sum\limits_{j=0}^k \lambda_j E[W_j] * E[S_j] ]

    这意味着我们与所有其他进程的等待时间和服务时间绑定。如果我们分解这个方程,我们会再次看到,如果我们有很多高优先级的工作,而这些工作对负载的贡献不大,那么我们的总和就会下降。我们不会对工作的服务时间做出太多假设,因为这会干扰我们从 FCFS 分析中留下的分析,其中我们将服务时间作为一个表达式。

  3. 对于与 FCFS 在平均情况下的比较,如果我们假设有一个平滑的概率分布——即获得任何特定优先级的概率为零,那么它通常表现得更好。在我们所有的公式中,我们仍然有一些概率质量可以放在低优先级进程中,从而降低期望值。这个陈述并不适用于所有平滑分布,但对于大多数现实世界的平滑分布(它们往往很平滑)来说,它们是这样的。

  4. 更不用说效用这个概念了。效用意味着,如果我们通过完成某些工作获得一定量的快乐,优先级和抢占优先级将最大化这一点,同时平衡其他效率指标。

短作业优先

这是一个将优先级降低得很好的例子。我们不会引入离散的优先级,而是会引入一个需要 StS_t 时间来获得服务的过程。 TT 是一个进程可以运行的最大时间,我们的进程不能无限期地运行。这意味着以下定义成立,覆盖了优先级中的先前定义。

  1. ρ(x)=0xρudu\rho(x) = \int_0^x \rho_u du 表示到目前为止的平均负载贡献。

  2. 0kpudu=1\int_0^k p_u du = 1 概率约束。

  3. 等等,将上述所有求和替换为积分

  4. 唯一的区别是我们不必对工作的服务时间做出任何假设,因为它们由服务时间下标表示,所有其他分析都是相同的。

  5. 这意味着,如果你想在平均情况下比 FCFS 有更低的等待时间,你的分布需要是右偏斜的。

抢占优先级

我们将在同一部分描述优先级和 SJF 的抢占版本,因为它本质上与我们上面展示的是相同的。我们将使用之前相同的符号。我们还将引入一个额外的术语 CiC_i,它表示特定类别之间的变化

Ci=var(Si)E[Si]C_i = \frac{var(S_i)}{E[S_i]}

  1. 响应时间。请注意,这不会很美观。 E[Ri]=j=0i(1+Cj)2(1ρ)(1ρ)E[Si]E[R_i] = \frac{\sum\limits_{j=0}^i\frac{(1 + C_j)}{2}}{(1 - \rho(x))*( 1 - \rho(x-1))} * E[S_i]

    如果这看起来很熟悉,那应该是的。这是非抢占情况下的平均等待时间,只是略有变化。我们不是使用整个分布的方差,而是查看每个进入的作业的方差。整个响应时间是

    E[R]=i=0kpi*E[Ri]E[R] = \sum\limits_{i = 0}^k p_i * E[R_i]

    如果低优先级的工作在更高的服务时间方差下到来,这意味着我们的平均响应时间可能会下降,除非它们构成了大部分到来的工作。考虑极端情况。如果 99%的工作是高优先级,其余的构成了其他百分比,那么其他工作将经常被中断,但高优先级的工作将构成大部分工作,因此期望值仍然很低。另一种极端情况是,如果 1%的工作是高优先级,并且它们以低方差到来。这意味着系统获得需要长时间处理的高优先级工作的可能性很低,从而使我们的平均响应时间降低。我们只有在高优先级工作占相当大比例,并且它们在服务时间上有高方差时才会遇到麻烦。这会降低响应时间以及等待时间。

  2. 等待时间 E[Wi]=E[Ri]+E[Si]1ρ(i)E[W_i] = E[R_i] + \frac{E[S_i]}{1 - \rho(i)}

    在所有过程中取期望值,我们得到

    E[W]=i=0kpi(E[Ri]+E[Si]1ρ(i))E[W] = \sum\limits_{i = 0}^k p_i (E[R_i] + \frac{E[S_i]}{1 - \rho(i)})

    我们可以简化为

    E[W]=E[R]+i=0kE[Si]pi(1ρ)E[W] = E[R] + \sum\limits_{i=0}^k \frac{E[S_i]p_i}{(1 - \rho(i))}

    我们在响应时间上承担相同的成本,然后我们必须根据低优先级作业进入并占用这个作业的概率来承担额外的成本。这就是我们所说的平均中断时间。这遵循之前的相同规律。由于我们有一个可变长度的金字塔求和,如果我们有很多服务时间短的作业,那么对于加法部分,等待时间都会下降。可以通过分析证明,在一定的概率分布下,这种方法更好。例如,尝试使用均匀分布与先来先服务(FCFS)或非抢占版本。会发生什么?像往常一样,证明留给读者。

  3. 周转时间遵循相同的公式 E[T]=E[S]+E[W]E[T] = E[S] + E[W]。这意味着,给定一个具有低等待时间分布的作业,我们将得到低周转时间——我们无法控制服务时间的分布。

抢占式最短作业优先

不幸的是,我们无法使用之前的方法,因为无穷小点没有受控的方差。尽管如此,想象一下与之前章节相同的比较。

网络额外内容

深入 IPv4 规范

互联网协议处理路由、分片和重组分片。数据报格式如下

IP 数据报可分性

IP 数据报可分性

  1. 第一个八位字节是版本号,要么是 4,要么是 6

  2. 下一个八位字节表示头部有多长。尽管看起来头部的大小是固定的,但你仍然可以包含可选参数来增强所采取的路径或其他指令。

  3. 接下来的两个八位字节指定了数据报的总长度。这意味着这是头部、数据、尾部和填充。这是以八位字节为单位的,意味着值为 20 表示 20 个八位字节。

  4. 接下来的两个是标识号。IP 处理将太大而无法通过物理线发送的数据包,并将它们分成块。因此,这个数字标识了这个数据报最初属于哪个数据报。

  5. 接下来的一个八位字节是各种可以设置的位标志。

  6. 接下来的一个八位字节和半个字节是分片号。如果这个数据包被分片了,这个数字代表了这个分片。

  7. 接下来的一个八位字节是生存时间。这意味着数据包被允许经过的“跳数”(通过线的旅行)。这是设置的,因为不同的路由协议可能导致数据包在某个点进入循环,数据包必须在此处丢弃。

  8. 接下来的一个八位字节是协议号。尽管 OCI 模型不同层之间的协议应该是黑盒,但这里也包括了,这样硬件可以有效地查看底层协议。以 IP over IP 为例(是的,你可以这样做!)!你的 ISP 将来自你的计算机发送到 ISP 的 IPv4 数据包包裹在另一个 IP 层中,并发送数据包以交付到网站。在反向行程中,数据包被“解包”,原始 IP 数据报被发送到你的计算机。这样做是因为我们用完了 IP 地址,这增加了额外的开销,但这是一个必要的修复。其他常见协议包括 TCP、UDP 等。

  9. 接下来的两个八位字节是互联网校验和。这是一个计算出来的 CRC,用于确保检测到各种位错误。

  10. 源地址是人们通常所说的 IP 地址。对此没有验证,所以一个主机可以假装成任何可能的 IP 地址。

  11. 目的地址是你希望数据包发送到的位置。目的地对于路由过程至关重要。

  12. 附加选项:附加选项的主机,这是可变大小的。

  13. 尾部:一些填充以确保数据是 4 个八位字节的倍数。

  14. 之后:您的数据!所有高于层协议的数据都放在头部之后。

路由

互联网协议路由是理论与应用的惊人交汇点。我们可以想象整个互联网就像一组图。大多数对等点连接到我们所说的“对等点”——这些是在家里、工作和公共场所找到的 WiFi 路由器和以太网端口。这些对等点然后连接到一个由路由器、交换机和服务器组成的有线网络,所有这些设备都进行路由。在高级别上,有两种类型的路由。

  1. 内部路由协议。内部协议是为 ISP 网络内部设计的路由。这些协议旨在快速且更信任,因为所有计算机、交换机和路由器都是 ISP 的一部分。两个路由器之间的通信。

  2. 外部路由协议。这些通常发生在 ISP 之间。某些路由器被指定为边界路由器。这些路由器与采用不同策略接受或接收数据包的 ISP 的路由器进行通信。如果一个邪恶的 ISP 试图将所有网络流量倾倒到你的 ISP 上,这些路由器将处理这种情况。这些协议还处理收集关于外部世界的每个路由器的信息。在大多数使用链路状态或 OSPF 的路由协议中,路由器必须必然计算到目的地的最短路径。这意味着它需要关于“外部”路由器的信息,这些信息根据这些协议进行传播。

这两个协议必须很好地相互作用,以确保数据包大部分都能成功送达。此外,ISP 之间也需要相互友好。理论上,一个 ISP 可以通过将所有数据包转发给另一个 ISP 来处理较小的负载。如果每个人都这样做,那么将没有任何数据包被送达,这肯定不会让客户感到满意。这两个协议需要公平,以确保结果有效。

如果你想了解更多关于这方面的内容,请查看维基百科上的路由页面 路由

分片/重组

低于 WiFi 和以太网等层的最大传输大小。原因在于

  1. 一个主机不应该长时间占用介质。

  2. 如果发生错误,我们希望有一种“进度条”来显示通信已经进行到哪一步,而不是重新传输整个流。

  3. 存在物理限制,保持激光束在光学中连续工作可能会导致位错误。

如果互联网协议收到一个比最大大小还要大的数据包,它必须将其分片。TCP 计算需要构建一个数据包的多少个数据报,并确保它们在最终接收端全部传输和重建。我们几乎不使用这个功能的原因是,如果任何分片丢失,整个数据包都会丢失。这意味着,假设每个分片丢失的概率是独立的百分比,随着数据包大小的增加,成功发送数据包的概率会指数级下降。

因此,TCP 将数据包分片,使其适合在 IP 数据报中。这仅适用于发送太大的 UDP 数据包的情况,但大多数使用 UDP 的人都会优化并设置相同的包大小。

IP 多播

一个鲜为人知的功能是,使用 IP 协议,可以向连接到路由器的所有设备发送一个数据报,这被称为多播。多播也可以配置为组,因此可以有效地分割所有连接的路由器,并高效地向它们发送一条信息。要在高层协议中访问此功能,您需要使用 UDP 并指定一些更多选项。请注意,这将对网络造成不必要的压力,因此一系列的多播可能会快速淹没网络。

kqueue

当谈到事件驱动 IO 时,游戏的名字就是要快。一个额外的系统调用被认为是慢的。OpenBSD 和 FreeBSD 从 kqueue 模型中有一个可以说是更好的异步 IO 模型。Kqueue 是 BSD 和 MacOs 专有的系统调用。它允许你在统一的接口下在一个调用中修改文件描述符事件和读取文件描述符。那么,它的好处是什么?

  1. 没有更多区分文件描述符和内核对象。在 epoll 部分,我们不得不讨论这个区别,否则你可能想知道为什么关闭的文件描述符会在 epoll 中返回。这里没有问题。

  2. 你多久调用一次 epoll 来读取文件描述符、获取服务器套接字,并需要添加另一个文件描述符?在一个高性能服务器中,这可能会每秒发生 1000 次。因此,有一个系统调用来注册和获取事件可以节省系统调用的开销。

  3. 所有类型的统一系统调用。kqueue 是真正的底层描述符无关。你可以向其中添加文件、套接字、管道,并获得完整或接近完整的性能。你也可以向 epoll 添加相同的,但 Linux 的整个生态系统由于异步文件输入输出而被搞乱,这意味着由于没有统一的接口,你可能会遇到奇怪的边缘情况。

各种手册页

Malloc

Copyright (c) 1993 by Thomas Koenig (ig25@rz.uni-karlsruhe.de)
%%%LICENSE_START(VERBATIM)
Permission is granted to make and distribute verbatim copies of this
manual provided the copyright notice and this permission notice are
preserved on all copies.

Permission is granted to copy and distribute modified versions of this
manual under the conditions for verbatim copying, provided that the
entire resulting derived work is distributed under the terms of a.
permission notice identical to this one.

Since the Linux kernel and libraries are constantly changing, this
manual page may be incorrect or out-of-date.  The author(s) assume no
responsibility for errors or omissions, or for damages resulting from
the use of the information contained herein.  The author(s) may not
have taken the same level of care in the production of this manual,
which is licensed free of charge, as they might when working
professionally.

Formatted or processed versions of this manual, if unaccompanied by
the source, must acknowledge the copyright and authors of this work.
%%%LICENSE_END

MALLOC(3)            Linux Programmer's Manual                MALLOC(3) 

NAME
       malloc, free, calloc, realloc - allocate and free dynamic memory

SYNOPSIS
       #include <stdlib.h>

       void *malloc(size_t size);
       void free(void *ptr);
       void *calloc(size_t nmemb, size_t size);
       void *realloc(void *ptr, size_t size);
       void *reallocarray(void *ptr, size_t nmemb, size_t size);

   Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

       reallocarray():
           _GNU_SOURCE

DESCRIPTION
       The malloc() function allocates size bytes and returns a
       pointer to the allocated memory. The memory is not initialized.
       If size is 0, then malloc() returns either NULL, or     
       a unique pointer value that can later be successfully passed
       to free().

       The free() function frees the memory space pointed to by ptr,
       which must have been returned by a previous call to malloc(),
       calloc(), or realloc().  Otherwise, or if free(ptr)     
       has already been called before, undefined behavior occurs.
       If ptr is NULL, no operation is performed.

       The calloc() function allocates memory for an array of nmemb
       elements of size bytes each and returns a pointer to the
       allocated memory. The memory is set to zero. If nmemb or size
       is 0, then calloc() returns either NULL, or a unique pointer
       value that can later be successfully passed to free().

       The realloc() function changes the size of the memory block
       pointed to by ptr to size bytes. The contents will be unchanged
       in the range from the start of the region up to the minimum of
       the old and new sizes. If the new size is larger than the old
       size, the added memory will not be initialized. If ptr is NULL,
       then the call is equivalent to malloc(size), for all values of
       size; if size is equal to zero, and ptr is not NULL, then the
       call is equivalent to free(ptr). Unless ptr is NULL, it must
       have been returned by an earlier call to malloc(), calloc(), or
       realloc(). If the area pointed to was moved, a free(ptr) is done.

       The reallocarray() function changes the size of the memory block
       pointed to by ptr to be large enough for an array of nmemb
       elements, each of which is size bytes. It is equivalent to
       the call

               realloc(ptr, nmemb * size);

       However, unlike that realloc() call, reallocarray() fails
       safely in the case where the multiplication would overflow.
       If such an overflow occurs, reallocarray() returns NULL,
       sets errno to ENOMEM, and leaves the original block of memory
       unchanged.

RETURN VALUE
       The  malloc()  and  calloc() functions return a pointer to the
       allocated memory, which is suitably aligned for any built-in
       type. On error, these functions return NULL. NULL may also be
       returned by a successful call to malloc() with a size of zero,
       or by a successful call to calloc() with nmemb or size equal
       to zero.

       The free() function returns no value.

       The realloc() function returns a pointer to the newly allocated
       memory, which is suitably aligned for any built-in type and may
       be different from ptr, or NULL if the request fails. If size
       was equal to 0, either NULL or a pointer suitable to be passed
       to free() is returned. If realloc() fails, the original block is
       left untouched; it is not freed or moved.

       On success, the reallocarray() function returns a pointer to the
       newly allocated memory. On failure, it returns NULL and the
       original block of memory is left untouched.

ERRORS
       calloc(), malloc(), realloc(), and reallocarray() can fail with
       the following error:

       ENOMEM Out of memory. Possibly, the application hit the
       RLIMIT_AS or RLIMIT_DATA limit described in getrlimit(2).

ATTRIBUTES
       For an explanation of the terms used in this section, see
       attributes(7).

       +---------------------+---------------+---------+
       |Interface            | Attribute     | Value   |
       |-----------------------------------------------|
       |malloc(), free(),    | Thread safety | MT-Safe |
       |calloc(), realloc()  |               |         |
       +---------------------+---------------+---------+

CONFORMING TO
       malloc(), free(), calloc(), realloc(): POSIX.1-2001,
       POSIX.1-2008, C89, C99.

       reallocarray() is a nonstandard extension that first appeared in
       OpenBSD 5.6 and FreeBSD 11.0.

NOTES
       By default, Linux follows an optimistic memory allocation
       strategy. This means that when malloc() returns non-NULL there
       is no guarantee that the memory is available. In case it
       turns out that the system is out of memory, one or more
       processes will be killed by the OOM killer. For more
       information, see the description of /proc/sys/vm/over-    
       commit_memory and /proc/sys/vm/oom_adj in proc(5), and the
       Linux kernel source file Documentation/vm/overcommit-accounting.

       Normally, malloc() allocates memory from the heap, and adjusts
       the size of the heap as required, using sbrk(2). When
       allocating blocks of memory larger than MMAP_THRESHOLD bytes,
       the glibc malloc() implementation allocates the memory as a
       private anonymous mapping using mmap(2).  MMAP_THRESHOLD is 128
       kB by default, but is adjustable using mallopt(3). Prior to
       Linux 4.7 allocations performed using mmap(2) were unaffected
       by the RLIMIT_DATA resource limit; since Linux 4.7, this limit
       is also enforced for allocations performed using mmap(2). 

       To avoid corruption in multithreaded applications, mutexes are
       used internally to protect the memory-management data structures
       employed by these functions. In a multithreaded application in
       which threads simultaneously allocate and free memory, there
       could be contention for these mutexes. To scalably handle
       memory allocation in multithreaded applications, glibc creates
       additional memory allocation arenas if mutex contention is
       detected. Each arena is a large region of memory that is
       internally allocated by the system (using brk(2) or mmap(2)),
       and managed with its own mutexes. 

       SUSv2 requires malloc(), calloc(), and realloc() to set errno to
       ENOMEM upon failure. Glibc assumes that this is done (and the
       glibc versions of these routines do  this); if you use a private
       malloc implementation that does not set errno, then certain
       library routines may fail without having a reason in errno.

       Crashes in malloc(), calloc(), realloc(), or free() are almost
       always related to heap corruption, such as overflowing an
       allocated chunk or freeing the same pointer twice. 

       The malloc() implementation is tunable via environment
       variables; see mallopt(3) for details. 

SEE ALSO
       valgrind(1), brk(2), mmap(2), alloca(3), malloc_get_state(3),
       malloc_info(3), malloc_trim(3), malloc_usable_size(3),
       mallopt(3), mcheck(3), mtrace(3), posix_memalign(3)

系统编程笑话

警告:作者不对由这些“笑话”引起的任何神经细胞凋亡负责。- 笑话是允许的。

节能灯笑话

Q. 改变一个灯泡需要多少个系统程序员?

A. 只有一个,但他们不断改变它,直到它返回零。

A. 他们更喜欢一个空的套接字。

A. 好吧,你开始时有一个,但实际上它等待一个子进程来完成所有工作。

笑话

为什么婴儿系统程序员喜欢他们新彩色的毛毯?因为它多线程。

为什么你的程序这么好,这么柔和?我只使用 400 支或更高的程序。

坏学生 shell 进程死后去哪里?分叉地狱。

为什么 C 程序员这么杂乱?他们把所有东西都存储在一个大堆里。

系统程序员(定义)

系统程序员是…

明知是坏主意但仍然梦想找到一个借口去使用它的人。

从不让他们代码死锁的人…但一旦发生,它造成的问题比所有人加起来还要多。

相信僵尸是真实存在的人。

不信任他们的进程在没有使用相同的数据、内核、编译器、RAM、文件系统大小、文件系统格式、磁盘品牌、核心数量、CPU 负载、天气、磁通量、方向、精灵粉末、星座、墙壁颜色、墙壁光泽和反射率、主板、振动、照明、备用电池、一天中的时间、温度、湿度、月相、日-月、共位…

一个系统程序…

不断进化直到能够发送电子邮件。

不断进化,直到它具有创建、连接和杀死其他程序以及消耗所有可能的设备上的所有可能的 CPU、内存、网络……资源的潜力,但今天它选择不这么做。

死后报告(Post Mortems)

本章旨在作为一个大型的“我们为什么要学习所有这些”的解答。在你之前的所有课程中,你都在学习如何去做。如何编程一个数据结构,如何编写一个 for 循环,如何证明某事。这是第一堂主要关注做什么的课程。因此,我们从过去的经历中获得了真正的经验。坐下来,随着我们讲述过去程序员的难题,浏览这一章。即使你处理的是像网页开发这样更高级别的事情,一切最终都关联到系统。

壳击(Shell Shock)

必需条件:附录/Shell

这是一条进入大多数壳的后门。这个漏洞允许攻击者利用环境变量来执行任意代码。

$ env x='() { :;}; echo vulnerable' bash -c "echo this is a test"
vulnerable...

这意味着在任何一个使用环境变量且不对其输入进行清理(提示:没有人清理环境变量输入,因为他们认为它是安全的)的系统上,你都可以在其他人的机器上执行你想要的任何代码,包括设置一个网络服务器。

经验教训:在生产机器上,确保有一个最小的操作系统(例如带有 DietLibc 的 BusyBox),这样你就可以理解系统中的大部分代码及其有效性。添加多层抽象和检查,以确保数据不会泄露。例如,上述问题在于如果允许与攻击者通信,信息就会返回给攻击者。这意味着你可以通过只允许少数端口的连接来加固你的机器端口。此外,你可以加固你的系统,使其永远不会执行 exec 调用以执行任务(即执行 exec 调用以更新值),而是使用 C 或你喜欢的编程语言来完成。虽然你没有灵活性,但你对自己的用户可以做什么有了安全感。

心脏出血(Heartbleed)

必需条件:C 语言入门

简单来说,缓冲区检查没有限制。SSL 心跳非常简单。服务器发送一个特定长度的字符串,第二个服务器应该发送相同长度的字符串回来。问题是有人可以恶意地改变请求的大小,使其大于他们发送的大小(例如发送“cat”但请求 500 字节),从而从服务器获取关键信息,如密码。关于这个问题的相关 XKCD

经验教训:检查你的缓冲区!了解缓冲区和字符串之间的区别。

污点牛(Dirty Cow)

必需条件:进程/虚拟内存

污点牛(Dirty Cow)

通常,进程可以访问一组只读的内存映射,如果它们尝试写入,则会得到一个段错误。脏 COW 是一种漏洞,其中许多线程试图同时访问同一块内存,希望其中一个线程翻转 NX 位和可写位。之后,攻击者可以修改页面。这可以做到有效用户 ID 位,进程可以假装它以 root 身份运行并启动 root shell,从而允许从普通 shell 访问系统。

学到的教训:内核中的自旋锁很难。

熔毁

在背景部分有一个这样的例子

漏洞

在安全部分进行检查。

火星路径探测器

必要部分:同步和一点调度

Pathfinder Link

火星路径探测器是一项试图收集火星气候数据的任务。探测器使用单个总线与不同部分通信。由于这是 1997 年,硬件本身没有像高效锁定这样的高级功能,因此操作系统开发者必须使用互斥锁来规范。架构相当简单。有一个线程控制信息总线上的数据,通信线程和数据收集线程,根据调度的高、常规和低优先级。另一个注意事项是,如果某个间隔发生中断,一个任务正在运行,而另一个任务需要调度,那么具有更高优先级的任务获胜。

导致一切开始失败的模式是数据收集线程开始向总线写入,信息总线线程正在等待数据。然后通信线程进来抢占其他低优先级线程而低优先级线程仍然持有互斥锁。这意味着当常规优先级线程尝试锁定总线时,漫游车会死锁。经过一段时间后,系统会重置,但不好留给机会。

这个教训是什么?不要让应用程序本身处理同步。定义一个处理互斥锁定的模块,并让该模块通过文件、IPC 等方式进行通信。

火星再次

必要部分:内存分配

火星

简而言之,他们用完了内存。详细来说,他们用完了内存、磁盘空间和交换空间。这个故事告诉我们什么?确保编写能够处理文件故障,并在关闭和内存不足时处理文件的代码,这样操作系统就可以热交换文件以释放内存。还要清理文件,假设你的临时目录大约是总大小的百分之一或千分之一,并使用它。

2038 年

必要部分:C 语言简介

2038

这是一个尚未发生的问题。Unix 时间戳被保存为从特定一天(1970 年 1 月 1 日)开始的秒数。这被存储为一个 32 位有符号整数。到 2038 年 3 月,这个数字将溢出。对于大多数现代操作系统来说,这不是问题,因为它们存储 64 位有符号整数,这足以让我们持续到时间的尽头,但对于我们无法更改内部硬件的嵌入式设备来说,这是一个问题。请继续关注,看看会发生什么。

得到的教训:像你的应用程序有一天会变得很大一样进行规划。

2003 年东北大停电

必须包含的章节:同步

2003

一个竞争条件触发了一系列未定义的事件,导致北美东北大部分地区停电了相当长一段时间。这个错误还导致备份系统和日志系统关闭或失败,以至于人们一个小时都不知道有这个错误。确切翻转的位未知,但已经实施了补丁。

得到的教训:将代码模块化以定位故障(即保持进程间的竞争条件不同)。如果你需要在进程间进行同步,确保你的故障检测系统不会与你的系统交织在一起。

苹果 iOS Unicode 处理

必须包含的章节:C 语言入门

使用文本让你的 iPhone 崩溃

为什么我们要教授字符串解析?因为即使是专业的软件开发者,这也是一件很难做到的事情。这个错误在尝试解析一系列 Unicode 字符时允许了大量的未定义行为。苹果可能知道为什么会发生这种情况,但我们的猜测是字符串解析发生在内核内部,并达到了段错误。当你内核中出现段错误时,你的内核会崩溃,整个设备会重新启动。未定义行为意味着任何事情,而且这个错误导致了大量不同的事情发生。

得到的教训:模糊你的内核

苹果 SSL 验证

必须包含的章节:C 语言入门

苹果 Bug

由于苹果代码中的一个 goto 错误,一个函数总是返回 SSL 证书有效。自然,黑客能够使用一些相当疯狂的网站名称逃脱。

得到的教训:始终括号括起 if 语句,谨慎使用 goto。如果你需要使用 goto,写另一个函数或带有贯穿的 switch 语句(仍然不好)。

索尼 Rootkit 安装

必须包含的章节:C 语言入门/进程

Root Kit 丑闻

想象一下。那是 2005 年,Limewire 在几年前出现,互联网是一个不断增长的非法活动池——当然,现在并不是说这个问题已经解决了。索尼知道它没有足够的计算能力来监控整个互联网或绕过人们用来绕过版权保护的各种技术。那么他们做了什么?拥有 2200 万张音乐 CD,他们要求用户在操作系统上安装一个 rootkit,这样索尼就可以监控设备上的不道德行为。

除了隐私问题之外,相信我,有很多,最大的问题是如果 rootkit 编程不正确,它将成为每个人系统的后门。rootkit 是一段通常安装在内核侧的代码,它跟踪用户几乎做的任何事情。访问了哪些网站,点击了什么或按了什么键等。如果黑客发现这一点,并且有从用户空间级别访问该 API 的方法,这意味着任何程序都可以发现有关你的设备的重要信息。不用说,人们都很愤怒。

经验教训:安装一个杀毒软件和/或 apparmor,并确保应用程序只请求合理的权限。如果你犹豫不决,可以尝试使用 Windows 沙盒或保留一个牺牲的虚拟机来测试安装它是否会使你的电脑变得糟糕。不要信任证书,信任代码。

文明与甘地

必需章节:C 语言简介

甘地的侵略性

这可能是游戏玩家都知道的,为什么像甘地这样在现实生活中非暴力的角色在文明视频游戏中会表现得如此具有侵略性。在原始游戏中,游戏将侵略性保持为无符号整数。在游戏过程中,整数可以递减,然后由于甘地已经为零,问题就出现了。这导致他成为游戏中最具侵略性的角色。

经验教训:从这个教训中得出的结论是永远不要使用未签名的数字,除非你有明确的书面理由(理由包括你需要了解溢出行为,你正在进行位操作,你正在进行位掩码)。在其他所有情况下,进行类型转换。

Shell 脚本之苦

必需章节:C 语言简介/Appending

Steam

Steam 中有一个简单的错误,导致 Steam 以类似以下方式删除了你的所有文件

$ ROOT=$(cd $0/; echo $PWD);
$ rm -rf $ROOT

如果$0 或传递给脚本的第一个参数不存在会发生什么?你会变成 root 用户,并删除你的整个电脑。

经验教训:在脚本上始终进行参数检查,如果你预期某个命令会失败,请明确列出它。你还可以将 rm 别名设置为 mv,然后稍后删除垃圾文件。

Appnexus 双重释放

必需章节:C 语言简介/Malloc

双重释放

Appnexus 使用异步垃圾回收器,当它认为对象未被使用时,会回收堆的不同部分。其架构是,一个元素在不可用列表中,然后被移到待释放列表中。如果在一定时间内该元素未被使用,它就会被释放并添加到空闲列表中。这很好,直到两个线程同时尝试删除同一个对象,将其添加到列表中两次。在更短的时间内,其中一个对象被删除,删除操作被通知到其他计算机。

经验教训:如果你需要,避免制作黑客软件。模块化,设置内存限制,并监控代码的不同部分,然后手动优化。没有通用的万能垃圾回收器适合所有人。即使是高度测试的 JVM,如果你想要从中获得性能,也需要一些推动。

ATT 级联故障 - 1990

必要章节:C 语言简介

解释

错误在上面的链接中解释得很好。我们建议阅读以了解更多信息。一系列网络延迟导致全国一些电话交换机认为其他交换机在它们不可用时是可操作的。当交换机重新上线时,它们意识到它们有大量的待路由电话,并开始这样做。其他路由故障和重启只是使问题更加复杂。

经验教训:实际上在这里不使用 C 语言可能会有所帮助,因为更严格的模糊测试(尽管在当今时代,C++由于其语言结构可能会更糟)。这个故事真正的教训是网络是随机的,并且预期代码中的任何一点都可能发生跳跃。这意味着编写模拟并在它们发生之前通过随机延迟运行它们来找出错误。

“4. Memcheck:内存错误检测器。”不详。Valgrindvalgrind.org/docs/manual/mc-manual.html

“断言。”不详。Cplusplus.com. cplusplus.com. www.cplusplus.com/reference/cassert/assert/

Bovet, Daniel,和 Marco Cesati. 2005. 理解 Linux 内核. O'Reilly & Associates Inc.

Chandy, K. M.,和 J. Misra. 1984. “饮酒者问题。” ACM 程序设计语言系统(ACM Trans. Program. Lang. Syst.) 6 (4): 632–46。doi.org/10.1145/1780.1804

“第三章:硬件中断。”不详。第三章:硬件中断。Red Hat。access.redhat.com/documentation/en-US/Red_Hat_Enterprise_MRG/1.3/html/Realtime_Reference_Guide/chap-Realtime_Reference_Guide-Hardware_interrupts.html

Coffman, Edward G,Melanie Elphick,和 Arie Shoshani. 1971. “系统死锁。” ACM 计算调查(CSUR) 3 (2): 67–78.

Cohen, Danny. 1980. “关于圣战和和平呼吁。” IETF。IETF。 www.ietf.org/rfc/ien/ien137.txt.

Conway, R.W., W.L. Maxwell, and L.W. Miller. 1967. 调度理论。Addison-Wesley 出版公司。 books.google.com/books?id=CSozAAAAMAAJ.

“DEC Pdp-10 Ka10 控制面板。” n.d. RICM。RICM。 www.ricomputermuseum.org/Home/interesting_computer_items/dec-pdp-ka10.

“定义。” 2018. 开放组基础规范第 7 版,2018 年版。开放组/IEEE。 pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_210.

Dekker, T.J., and Edsgar Dijkstra. 1965. “关于进程描述的顺序性。” E.W.Dijkstra 档案:关于进程描述的顺序性 (EWD 35)。德克萨斯大学奥斯汀分校。 www.cs.utexas.edu/users/EWD/transcriptions/EWD00xx/EWD35.html.

Dijkstra, Edsger W. n.d. “顺序进程的分层排序。” www.cs.utexas.edu/users/EWD/ewd03xx/EWD310.PDF.

Duff, Tom. n.d. “Tom Duff 关于 Duff 设备。” Tom Duff 关于 Duff 设备www.lysator.liu.se/c/duffs-device.html.

“环境变量。” 2018. 环境变量。开放组/IEEE。 pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html.

Evans, Julia. 2018. “文件描述符。” Julia 的绘画。Julia Evans。 drawings.jvns.ca/file-descriptors/.

“Exec。” 2018. Exec。开放组/IEEE。 pubs.opengroup.org/onlinepubs/9699919799/functions/exec.html.

“执行文件。” n.d. 执行文件 (GNU C 库)。GNU 项目。 www.gnu.org/software/libc/manual/html_node/Executing-a-File.html#Executing-a-File.

Fielding, Roy T., and Julian Reschke. 2014. “超文本传输协议 (HTTP/1.1): 语义和内容。” 请求评论。RFC 7231;RFC 编辑。 doi.org/10.17487/RFC7231.

“Fork。” 2018. Fork。开放组/IEEE。 pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html.

“FORTRAN IV 程序员参考手册.” 1972. 手册. 马萨诸塞州梅纳德: 数字设备公司. www.bitsavers.org/www.computer.museum.uq.edu.au/pdf/DEC-10-AFDO-D%20decsystem10%20FORTRAN%20IV%20Programmer%27s%20Reference%20Manual.pdf.

加雷,M. R.,R. L. 格拉汉姆,和 J. D. 乌尔曼. 1972. “内存分配算法的最坏情况分析.” 在 第四届年度 ACM 计算机理论研讨会论文集,143–50. STOC ‘72. 纽约,纽约,美国: ACM. doi.org/10.1145/800152.804907.

加尔,马努. 2006. “Linux 2.6 中的 Sysenter 基于的系统调用机制.” 马努的公开文章和项目. articles.manugarg.com/systemcallinlinux2_6.html.

“GDB: GNU 项目调试器.” 2019. GDB: GNU 项目调试器. 自由软件基金会. www.gnu.org/software/gdb/.

指南,部分. 2011. “Intel 64 和 IA-32 架构软件开发者手册.” 第 3B 卷:系统编程指南,部分 2.

哈尔科尔-巴尔特,M. 2013. 计算机系统性能建模与设计:排队论的实际应用. 计算机系统性能建模与设计:排队论的实际应用. 剑桥大学出版社. books.google.com/books?id=75SbigDGK0kC.

海曼,哈里斯. 1966. “关于并发程序控制中问题的评论.” Commun. ACM 9 (1): 45. doi.org/10.1145/365153.365167.

(IBM),国际商业机器公司. 1958 年 8 月. IBM 709 数据处理系统参考手册. 国际商业机器公司 (IBM). archive.computerhistory.org/resources/text/Fortran/102653991.05.01.acc.pdf.

“IEEE 浮点运算标准.” 2008. IEEE Std 754-2008, 八月, 1–70. doi.org/10.1109/IEEESTD.2008.4610935.

Inc., 苹果公司. 2017. “XNU 内核.” GitHub 仓库. github.com/apple/darwin-xnu; GitHub.

英特尔,CAT. 2015. “通过利用缓存分配技术提高实时性能.” 英特尔公司,四月.

“国际.” 不定日期. IEC. IEC. www.iec.ch/si/binary.htm.

“ISO C 标准.” 2005. 标准. 日内瓦,瑞士: 国际标准化组织. www.open-std.org/jtc1/sc22/wg14/www/docs/n1124.pdf.

琼斯,拉里. 2010. “WG14 N1539 委员会草案 Iso/Iec 9899: 201x.” 国际标准化组织.

Kernighan, B.W., and D.M. Ritchie. 1988. C 编程语言. 普伦蒂斯-霍尔计算机软件系列。普伦蒂斯-霍尔。books.google.com/books?id=161QAAAAMAAJ.

Knuth, D.E. 1973. 计算机编程艺术:基础算法. 阿迪森-韦斯利计算机科学和信息处理系列,第 1-2 卷。阿迪森-韦斯利。books.google.com/books?id=dC05RwAACAAJ.

Kocher, Paul, Daniel Genkin, Daniel Gruss, Werner Haas, Mike Hamburg, Moritz Lipp, Stefan Mangard, Thomas Prescher, Michael Schwarz, and Yuval Yarom. 2018. “Spectre 攻击:利用推测执行。” arXiv 预印本 arXiv:1801.01203.

Leroy, Xavier. 2017. “我在英特尔 Skylake 处理器中找到的一个错误。” 我在英特尔 Skylake 处理器中找到的一个错误gallium.inria.fr/blog/intel-skylake-bug/.

Levinthal, David. 2009. “英特尔酷睿 i7 处理器和英特尔至强 5500 处理器性能分析指南。” 英特尔性能分析指南 30: 18.

Love, Robert. 2010. Linux 内核开发. 第 3 版。阿迪森-韦斯利专业出版社。

“mmap。” 2018. mmap. 开放集团。pubs.opengroup.org/onlinepubs/9699919799/functions/mmap.html.

“malloc 概述。” 2018. malloc 内部机制 - Glibc Wiki. 自由软件基金会。sourceware.org/glibc/wiki/MallocInternals.

Peterson, Gary L. 1981. “关于互斥问题的神话。” 信息处理信件 12: 115–16.

Rangan, C.P., V. Raman, and R. Ramanujam. 1999. 软件技术基础与理论计算机科学:第 19 届会议,印度钦奈,1999 年 12 月 13-15 日论文集。计算机软件技术基础与理论计算机科学。斯普林格。books.google.com/books?id=0uHME7EfjQEC.

Reynolds, J., and J. Postel. 1994. “分配号码。” RFC 1700. RFC 编辑;互联网请求评论;RFC 编辑。

Rice, H. G. 1953. “可递归枚举集的类别及其决策问题。” 美国数学学会汇刊 74 (2): 358–66. www.jstor.org/stable/1990888.

Ritchie, Dennis M. 1993. “C 语言的发展。” SIGPLAN 通知 28 (3): 201–8. doi.org/10.1145/155360.155580.

Schweizer, Hermann, Maciej Besta, and Torsten Hoefler. 2015. “在现代架构上评估原子操作的成本。” 在 2015 国际并行架构与编译会议 (Pact) 中,445–56. IEEE.

Silberschatz, A., P.B. Galvin, and G. Gagne. 2005. 操作系统概念. 威利。books.google.com/books?id=FH8fAQAAIAAJ.

———. 2006. 操作系统原理,第 7 版. Wiley 学生版. Wiley India Pvt. Limited. books.google.com/books?id=WjvX0HmVTlMC.

“源到 Sys/Wait.h.” 不早于. Sys/Wait.h 源. superglobalmegacorp. unix.superglobalmegacorp.com/Net2/newsrc/sys/wait.h.html.

“ssh(1).” 不早于. OpenBSD 手册页. OpenBSD. man.openbsd.org/ssh.1.

Stallings, William. 2011. 操作系统:内部结构和设计原理第 7 版. 由 Stallings (国际经济版). PE. www.amazon.com/Operating-Systems-Internals-Principles-International/dp/9332518807?SubscriptionId=0JYN1NVW651KCA56C102&tag=techkie-20&linkCode=xm2&camp=2025&creative=165953&creativeASIN=9332518807.

“IPv6 部署状态 2018.” 2018. 互联网协会. 互联网协会. www.internetsociety.org/resources/2018/state-of-ipv6-deployment-2018/.

Šorn, Jure. 2015. “Gto76/Comp-Cpp.” GitHub. github.com/gto76/comp-cpp/blob/1bf9a77eaf8f57f7358a316e5bbada97f2dc8987/src/output.c.

“ThreadSanitizerCppManual.” 2018. ThreadSanitizerCppManual. Google. github.com/google/sanitizers/wiki/ThreadSanitizerCppManual.

“用户数据报协议.” 1980. 请求评论. RFC 768;RFC 编辑器. doi.org/10.17487/RFC0768.

Van der Linden, Peter. 1994. 专家 C 编程:深层次 C 秘密. Prentice Hall 专业版.

Virtamo, Jorma. 不早于. “38.3143 排队论 / Them/G/1/Queue.” 38.3143 排队论 / TheM/G/1/Queue. 阿尔托大学. www.netlab.tkk.fi/opetus/s383143/kalvot/E_mg1jono.pdf.

“虚拟内存分配和分页.” 2001. GNU C 库 - 虚拟内存分配和分页. 自由软件基金会. ftp.gnu.org/old-gnu/Manuals/glibc-2.2.3/html_chapter/libc_3.html.

Wikibooks. 2018. “x86 汇编 — Wikibooks,免费教科书项目.” en.wikibooks.org/w/index.php?title=X86_Assembly&oldid=3477563.

Wilson, Paul R., Mark S. Johnstone, Michael Neely, and David Boles. 1995. “动态存储分配:综述与批判性评论.” 在 内存管理,由 Henry G. Baler 编著,1–116. 柏林,海德堡:Springer Berlin Heidelberg.

n.d. IEEE. pubs.opengroup.org/onlinepubs/009695399/functions/pthread_sigmask.html.

posted @ 2026-02-20 16:44  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报