首页
关于
壁纸
直播
留言
友链
统计
Search
1
《三国志英杰传》攻略
6,137 阅读
2
Emby客户端IOS破解
6,005 阅读
3
白嫖Emby
5,985 阅读
4
《吞食天地1》金手指代码
5,399 阅读
5
破解emby-server
4,262 阅读
moonjerx
game
age-of-empires
zx3
san-guo-zhi
尼尔:机械纪元
net
emby
learn-video
docker
torrent
photoshop
route
minio
git
ffmpeg
im
vue
gitlab
typecho
svn
alipay
nasm
srs
mail-server
tailscale
kkfileview
aria2
webdav
synology
redis
oray
chemical
mxsite
math
π
x-ui
digital-currency
server
nginx
baota
k8s
http
cloud
linux
shell
database
vpn
esxi
rancher
domain
k3s
ewomail
os
android
windows
ios
app-store
macos
develop
java
javascript
uniapp
nodejs
hbuildx
maven
android-studio
jetbrain
jenkins
css
mybatis
php
python
hardware
hard-disk
pc
RAM
software
pt
calibre
notion
office
language
literature
philosophy
travel
登录
Search
标签搜索
ubuntu
mysql
openwrt
zerotier
springboot
centos
openvpn
jdk
吞食天地2
synology
spring
idea
windows11
吞食天地1
transmission
google-play
Japanese
xcode
群晖
kiftd
MoonjerX
累计撰写
377
篇文章
累计收到
465
条评论
首页
栏目
moonjerx
game
age-of-empires
zx3
san-guo-zhi
尼尔:机械纪元
net
emby
learn-video
docker
torrent
photoshop
route
minio
git
ffmpeg
im
vue
gitlab
typecho
svn
alipay
nasm
srs
mail-server
tailscale
kkfileview
aria2
webdav
synology
redis
oray
chemical
mxsite
math
π
x-ui
digital-currency
server
nginx
baota
k8s
http
cloud
linux
shell
database
vpn
esxi
rancher
domain
k3s
ewomail
os
android
windows
ios
app-store
macos
develop
java
javascript
uniapp
nodejs
hbuildx
maven
android-studio
jetbrain
jenkins
css
mybatis
php
python
hardware
hard-disk
pc
RAM
software
pt
calibre
notion
office
language
literature
philosophy
travel
页面
关于
壁纸
直播
留言
友链
统计
搜索到
377
篇与
moonjerx
的结果
2022-12-12
Java实现WebSocket服务
一、使用Tomcat提供的WebSocket库 Java可以使用Tomcat提供的WebSocket库接口实现WebSocket服务,代码编写也非常的简单。现在的H5联网游戏基本上都是使用WebSocket协议,基于长连接,服务器可以主动推送消息,而不是传统的网页采用客户端轮询的方式获取服务器的消息。下面给出简单使用Tomcat的WebSocket服务的基本代码结构。@ServerEndpoint("/webSocket") public class WebSocket { @OnOpen public void onOpen(Session session) throws IOException{ logger.debug("新连接"); } @OnClose public void onClose(){ logger.debug("连接关闭"); } @OnMessage public void onMessage(String message, Session session) throws IOException { logger.debug("收到消息"); } @OnError public void onError(Session session, Throwable error){ error.printStackTrace(); } }二、WebSocket协议的整个流程1. 基于TCP协议WebSocket本质是基于TCP协议的,采用Java编写WebSocket服务时可以使用NIO或者AIO实现高并发的服务。2. 握手过程客户端采用TCP协议连接服务器指定端口后,首先需要发送一条HTTP的握手协议GET /web HTTP/1.1 Upgrade: websocket Connection: Upgrade Host: 127.0.0.1:8001 Origin: http://127.0.0.1:8001 Sec-WebSocket-Key: hj0eNqbhE/A0GkBXDRrYYw== Sec-WebSocket-Version: 13请求的头里面必须包含以下内容:Connection 其值为Upgrade,表示升级协议Upgrade 其值为websocket,表示升级为WebSocket协议Sec-WebSocket-Key 客户端发送给服务器的密钥,用于标识每个客户端,其值是16位的随机base64编码。Sec-WebSocket-Version WebSocket的协议版本服务器收到这条协议验证成功后进行协议升级,并且不会关闭Socket连接,并发送给客户端响应升级握手成功的HTTP协议包。HTTP/1.1 101 Switching Protocols Content-Length: 0 Upgrade: websocket Sec-Websocket-Accept: ZEs+c+VBk8Aj01+wJGN7Y15796g= Connection: Upgrade Date: Wed, 21 Jun 2017 03:29:14 GMT 响应的协议包里面,首先是101的状态码,更换协议;其中最重要的就是Sec-WebSocket-Accept字段。其值是通过客户端的Key加上固定的"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"密钥,通过采用16位的base64编码后发送给客户端验证,如果客户端也验证成功就表示握手完成。String acc = secKey + WEBSOCK_MAGIC_TAG; MessageDigest sh1 = MessageDigest.getInstance("SHA1"); String key = Base64.getEncoder().encodeToString(sh1.digest(acc.getBytes()));3. 数据的读写握手成功后就可以进行数据发送和读取,WebSocket的数据可以是二进制或者纯文本。每次读取和发送数据需要打包成帧数据,需要按照其标准的格式进行发送或读取才能够正常的进行数据通信。上图就是帧数据的结构图,解析帧数据的代码如下,由于是摘录的部分代码,所以只能作为理解和参考,不可直接使用。protected WebSocketFrameData ParseFrame(NetPacketBuffer bytes){ bytes.mark(); WebSocketFrameData frame = new WebSocketFrameData(); int opData = bytes.readByte(); frame.UnPackOpCodeHeader(opData); // 第一步 int length = frame.UnPackMaskHeader(bytes.readByte()); // 第二步 // 读取长度 if (length == 126) { length = bytes.readShort(); } else if (length == 127){ length = (int) bytes.readInt64(); } // 数据不足,进来的是半包 if(length + 4 > bytes.remaining()){ bytes.reset(); // return null; } // 读取mask if frame.mMasked byte[] masks = new byte[4]; // 第三步 for (int i = 0; i < 4; i++) { masks[i] = (byte) bytes.readByte(); } frame.mLength = length; frame.mData = bytes.readMulitBytes(length); frame.MaskData(masks); // 第四步 return frame; }上面代码中第一步是解析出当前帧是否是最后帧mFin标记、操作码mOpCode,采用位处理,具体的实现如下。public void UnPackOpCodeHeader(int opData){ mRsv1 = (opData & 64) == 64; mRsv2 = (opData & 32) == 32; mRsv3 = (opData & 16) == 16; mFin = (opData & 128) == 128; mOpCode = (opData & 15); }第二步在读取长度前,先解析当前帧是否有采用Mask掩码加密处理,并且里面有可能包含整个帧的长度信息,具体看上面的判断代码。public int UnPackMaskHeader(int mkData){ mMasked = (mkData & 128) == 128; return (mkData & 127); // 这里返回的是长度信息 }接下来就是读取Mask内容,注意只有客户端发送给服务端时需要采用Mask对数据做处理,服务端发送给客户端时不需要做处理。最后通过Mask掩码解析出真实数据。public void MaskData(byte[] masks){ if (!mMasked or masks.length == 0) return ; for (int i = 0; i < mLength; i++) { mData[i] = (byte) (mData[i] ^ masks[i % 4]); } }以上就解析出单帧的数据,帧数据可以分为消息数据(细分为文本数据和二进制数据)、PING包、PONG包、CLOSE包、CONTINUATION包(数据未发送完成包)。而且帧数据又有mFin标记数据是否完整,否则需要将多个帧数据合成一个完整的消息数据。// 读取帧数据,可能存在多帧数据,因此需要手动拆分 WebSocketFrameData frame = ParseFrame(mCachePacket); if(frame == null){ break; // 说明数据不完整,暂不处理。 } // 不完整的帧的时候,只有第一帧会标记帧的类型 opCode = opCode == -1? frame.mOpCode: opCode; mCacheFrame.append(frame.mData, 0, frame.mLength); if(!frame.mFin) // 非完整的数据不处理。 { continue; } // 处理完整的数据 switch(opCode) { case WebSocketFrameData.OP_TEXT: case WebSocketFrameData.OP_BINARY: mCacheFrame.flip(); this.OnMessage(mCacheFrame, opCode); break; case WebSocketFrameData.OP_PING: this.OnPing(mCacheFrame); break; case WebSocketFrameData.OP_PONG: this.OnPong(mCacheFrame); break; case WebSocketFrameData.OP_CLOSE: this.OnClosed(); break; case WebSocketFrameData.OP_CONTINUATION: this.Close(); break; } opCode = -1; mCacheFrame.clear();读取整个客户端的协议数据流程就已经完成了,服务端发送回去的数据就只需要注意两点:大的数据包需要分帧数据发送。不需要采用Mask掩码加密,因此Mask位置设置为0,并且不写入掩码数据。原文摘自
2022年12月12日
117 阅读
0 评论
0 点赞
2022-12-12
别人的字节面经
如果你知道 MySQL 一行记录的存储结构,那么这个问题对你没什么难度。如果你不知道也没关系,这次我跟大家聊聊 MySQL 一行记录是怎么存储的?知道了这个之后,除了能应解锁前面这道面试题,你还会解锁这些面试题:MySQL 的 NULL 值会占用空间吗?MySQL 怎么知道 varchar(n) 实际占用数据的大小?varchar(n) 中 n 最大取值为多少?行溢出后,MySQL 是怎么处理的?这些问题看似毫不相干,其实都是在围绕「 MySQL 一行记录的存储结构」这一个知识点,所以攻破了这个知识点后,这些问题就引刃而解了。MySQL 的数据存放在哪个文件?大家都知道 MySQL 的数据都是保存在磁盘的,那具体是保存在哪个文件呢?MySQL 存储的行为是由存储引擎实现的,MySQL 支持多种存储引擎,不同的存储引擎保存的文件自然也不同。InnoDB 是我们常用的存储引擎,也是 MySQL 默认的存储引擎。所以,本文主要以 InnoDB 存储引擎展开讨论。先来看看 MySQL 数据库的文件存放在哪个目录?mysql> SHOW VARIABLES LIKE 'datadir'; +---------------+-----------------+ | Variable_name | Value | +---------------+-----------------+ | datadir | /var/lib/mysql/ | +---------------+-----------------+ 1 row in set (0.00 sec)我们每创建一个 database(数据库) 都会在 /var/lib/mysql/ 目录里面创建一个以 database 为名的目录,然后保存表结构和表数据的文件都会存放在这个目录里。比如,我这里有一个名为 my_test 的 database,该 database 里有一张名为 t_order 数据库表。然后,我们进入 /var/lib/mysql/my_test 目录,看看里面有什么文件?[root@xiaolin ~]#ls /var/lib/mysql/my_test db.opt t_order.frm t_order.ibd可以看到,共有三个文件,这三个文件分别代表着:db.opt,用来存储当前数据库的默认字符集和字符校验规则。t_order.frm ,t_order 的表结构会保存在这个文件。在 MySQL 中建立一张表都会生成一个.frm 文件,该文件是用来保存每个表的元数据信息的,主要包含表结构定义。t_order.ibd,t_order 的表数据会保存在这个文件。表数据既可以存在共享表空间文件(文件名:ibdata1)里,也可以存放在独占表空间文件(文件名:表名字.idb)。这个行为是由参数 innodb_file_per_table 控制的,若设置了参数 innodb_file_per_table 为 1,则会将存储的数据、索引等信息单独存储在一个独占表空间,从 MySQL 5.6.6 版本开始,它的默认值就是 1 了,因此从这个版本之后, MySQL 中每一张表的数据都存放在一个独立的 .idb 文件。好了,现在我们知道了一张数据库表的数据是保存在「 表名字.idb 」的文件里的,这个文件也称为独占表空间文件。那这个表空间文件的结构是怎么样的?表空间由段(segment)、区(extent)、页(page)、行(row)组成 ,InnoDB存储引擎的逻辑存储结构大致如下图:下面我们从下往上一个个看看。1、行(row)数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。后面我们详细介绍 InnoDB 存储引擎的行格式,也是本文重点介绍的内容。2、页(page)记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。因此,InnoDB 的数据是按「页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。默认每个页的大小为 16KB,也就是最多能保证 16KB 的连续存储空间。页是 InnoDB 存储引擎磁盘管理的最小单元,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。页的类型有很多,常见的有数据页、undo 日志页、溢出页等等。数据表中的行记录是用「数据页」来管理的,数据页的结构这里我就不讲细说了,之前文章有说过,感兴趣的可以去看这篇文章:换一个角度看 B+ 树总之知道表中的记录存储在「数据页」里面就行。3、区(extent)我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机I/O,随机 I/O 是非常慢的。解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。那具体怎么解决呢?在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了。4、段(segment)表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。索引段:存放 B + 树的非叶子节点的区的集合;数据段:存放 B + 树的叶子节点的区的集合;回滚段:存放的是回滚数据的区的集合,之前讲事务隔离的时候就介绍到了 MVCC 利用了回滚段实现了多版本查询数据。好了,终于说完表空间的结构了。接下来,就具体讲一下 InnoDB 的行格式了。之所以要绕一大圈才讲行记录的格式,主要是想让大家知道行记录是存储在哪个文件,以及行记录在这个表空间文件中的哪个区域,有一个从上往下切入的视角,这样理解起来不会觉得很抽象。InnoDB 行格式有哪些?行格式(row_format),就是一条记录的存储结构。InnoDB 提供了 4 种行格式,分别是 Redundant、Compact、Dynamic和 Compressed 行格式。Redundant 是很古老的行格式了, MySQL 5.0 版本之前用的行格式,现在基本没人用了。由于 Redundant 不是一种紧凑的行格式,所以 MySQL 5.0 之后引入了 Compact 行记录存储方式,Compact 是一种紧凑的行格式,设计的初衷就是为了让一个数据页中可以存放更多的行记录,从 MySQL 5.1 版本之后,行格式默认设置成 Compact。Dynamic 和 Compressed 两个都是紧凑的行格式,它们的行格式都和 Compact 差不多,因为都是基于 Compact 改进一点东西。从 MySQL5.7 版本之后,默认使用 Dynamic 行格式。Redundant 行格式我这里就不讲了,因为现在基本没人用了,这次重点介绍 Compact 行格式,因为 Dynamic 和 Compressed 这两个行格式跟 Compact 非常像。所以,弄懂了 Compact 行格式,之后你们在去了解其他行格式,很快也能看懂。COMPACT 行格式长什么样?先跟 Compact 行格式混个脸熟,它长这样:可以看到,一条完整的记录分为「记录的额外信息」和「记录的真实数据」两个部分。接下里,分别详细说下。记录的额外信息记录的额外信息包含 3 个部分:变长字段长度列表、NULL 值列表、记录头信息。1. 变长字段长度列表varchar(n) 和 char(n) 的区别是什么,相信大家都非常清楚,char 是定长的,varchar 是变长的,变长字段实际存储的数据的长度(大小)不固定的。所以,在存储数据的时候要把这些数据占用的字节数也存起来,存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。其他 TEXT、BLOB 等变长字段也是这么实现的。为了展示「变长字段长度列表」具体是怎么保存变长字段占用的字节数,我们先创建这样一张表,字符集是 ascii(所以每一个字符占用的 1 字节),行格式是 Compact,t_user 表中 name 和 phone 字段都是变长字段:CREATE TABLE `t_user` ( `id` int(11) NOT NULL, `name` VARCHAR(20) NOT NULL, `phone` VARCHAR(20) DEFAULT NULL, `age` int(11) DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT;现在 t_user 表里有这三条记录:接下来,我们看看看看这三条记录的行格式中的 「变长字段长度列表」是怎样存储的。先来看第一条记录:name 列的值为 a,长度是 1 字节,十六进制 0x01phone 列的值为 123,长度是 3 字节,十六进制 0x03age 列和 id 列不是变长字段,所以这里不用管。这些变长字段的长度值会按照列的顺序逆序存放(等下会说为什么要这么设计),所以「变长字段长度列表」里的内容是「 03 01」,而不是 「01 03」。同样的道理,我们也可以得出第二条记录的行格式中,「变长字段长度列表」里的内容是「 04 02」,如下图:第三条记录中 phone 列的值是 NULL,NULL 是不会存放在行格式中记录的真实数据部分里的,所以「变长字段长度列表」里不需要保存值为 NULL 的变长字段的长度。为什么「变长字段长度列表」的信息要按照逆序存放?这个设计是有想法的,主要是因为「记录头信息」中指向下一个记录的指针,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。「变长字段长度列表」中的信息之所以要逆序存放,是因为这样可以使得位置靠前的记录的真实数据和数据对应的字段长度信息可以同时在一个 CPU Cache Line 中,这样就可以提高 CPU Cache 的命中率。同样的道理, NULL 值列表的信息也需要逆序存放。如果你不知道什么是 CPU Cache,可以看这篇文章:面试官:如何写出让 CPU 跑得更快的代码?,这属于计算机组成的知识。每个数据库表的行格式都有「变长字段字节数列表」吗?其实变长字段字节数列表不是必须的。当数据表没有变长字段的时候,比如全部都是 int 类型的字段,这时候表里的行格式就不会有「变长字段长度列表」了,因为没必要,不如去掉以节省空间。所以「变长字段长度列表」只出现在数据表有变长字段的时候。2. NULL 值列表表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL值列表中。如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序逆序排列。二进制位的值为1时,代表该列的值为NULL。二进制位的值为0时,代表该列的值不为NULL。另外,NULL 值列表必须用整数个字节的位表示(1字节8位),如果使用的二进制位个数不足整数个字节,则在字节的高位补 0。还是以 t_user 表的这三条记录作为例子:接下来,我们看看看看这三条记录的行格式中的 NULL 值列表是怎样存储的。先来看第一条记录,第一条记录所有列都有值,不存在 NULL 值,所以用二进制来表示是酱紫的:但是 InnoDB 是用整数字节的二进制位来表示NULL值列表的,现在不足 8 位,所以要在高位补 0,最终用二进制来表示是酱紫的:所以,对于第一条数据,NULL 值列表用十六进制表示是 0x00。接下来看第二条记录,第二条记录 age 列是 NULL 值,所以,对于第二条数据,NULL值列表用十六进制表示是 0x04。最后第三条记录,第三条记录 phone 列 和 age 列是 NULL 值,所以,对于第三条数据,NULL 值列表用十六进制表示是 0x06。我们把三条记录的 NULL 值列表都填充完毕后,它们的行格式是这样的:每个数据库表的行格式都有「NULL 值列表」吗?NULL 值列表也不是必须的。当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了。所以在设计数据库表的时候,通常都是建议将字段设置为 NOT NULL,这样可以节省 1 字节的空间(NULL 值列表占用 1 字节空间)。3. 记录头信息记录头信息中包含的内容很多,我就不一一列举了,这里说几个比较重要的:delete_mask :标识此条数据是否被删除。从这里可以知道,我们执行 detele 删除记录的时候,并不会真正的删除记录,只是将这个记录的 delete_mask 标记为 1。next_record:下一条记录的位置。从这里可以知道,记录与记录之间是通过链表组织的。在前面我也提到了,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。record_type:表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 记录的真实数据 记录真实数据部分除了我们定义的字段,还有三个隐藏字段,分别为:row_id、trx_id、roll_pointer,我们来看下这三个字段是什么。row_id如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了。如果既没有指定主键,又没有唯一约束,那么 InnoDB 就会为记录添加 row_id 隐藏字段。row_id不是必需的,占用 6 个字节。trx_id事务id,表示这个数据是由哪个事务生成的。trx_id是必需的,占用 6 个字节。roll_pointer这条记录上一个版本的指针。roll_pointer 是必需的,占用 7 个字节。如果你熟悉 MVCC 机制,你应该就清楚 trx_id 和 roll_pointer 的作用了。varchar(n) 中 n 最大取值为多少?varchar(n) 字段类型的 n 代表的是最多存储的字符数量,那 n 最大能设置多少?这个问题要考虑两个因素:行格式中「变长字段长度列表」最大能表示多少字节?知道了这个才能知道,一行数据最大能存储多少字节的数据。数据库表的字符集,确定了这个,才能知道 1 个字符占用多少字节。行格式中「变长字段长度列表」有时候是占用 1 字节,有时候是占用 2 字节:如果变长字段允许存储的最大字节数小于等于 255 字节,「变长字段长度列表」就占用 1 个字节;如果变长字段允许存储的最大字节数大于 255 字节,「变长字段长度列表」就占用 2 个字节;可以看到,「 变长字段长度列表」占用的字节数最大不会不超过 2 字节。 2 个字节的最大值是 65535(十进制),从这里可以推测一行记录最大能存储 65535 字节的数据,实际上真的是这样吗?我这里以 ascii 字符集作为例子,这意味着 1 个字符占用 1 字节。那么 varchar(65535) 就意味着最多可存储 65535 个 ascii 字符,刚好满足一行记录最大能存储 65535 字节的数据。我们定义一个 varchar(65535) 类型的字段,字符集为 ascii 的数据库表。CREATE TABLE test ( `name` VARCHAR(65535) NULL ) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT;看能不能成功创建一张表:可以看到,创建失败了。从报错信息就可以知道一行数据的最大字节数是 65535(不包含 TEXT、BLOBs 这种大对象类型),其中包含了 storage overhead。问题来了,这个 storage overhead 是什么呢?其实就是变长字段长度列表和 NULL 值列表,也就是说一行数据的最大字节数 65535,其实是包含「变长字段长度列表」和 「NULL 值列表」所占用的字节数的。我们存储字段类型为 varchar(n) 的数据时,其实分成了三个部分来存储:真实数据真实数据占用的字节数NULL 标识,如果不允许为NULL,这部分不需要前面我创建表的时候,字段是允许为 NULL 的,所以会占用 1 字节来存储 NULL 标识,字段是变长字段且变长字段允许存储的最大字节数大于 255 字节 ,所以会占用 2 字节存储真实数据的占用的字节数,所以最多可以存储 65535- 2 - 1 = 65532 个字节。我们先来测试看看 varchar(65533) 是否可行?可以看到,还是不行,接下来看看 varchar(65532) 是否可行?可以看到,创建成功了。当然,我上面这个例子是针对字符集为 ascii 情况,如果采用的是 UTF-8,varchar(n) 最多能存储的数据计算方式就不一样了:在 UTF-8 字符集下,一个字符串最多需要三个字节,varchar(n) 的 n 最大取值就是 65532/3 = 21844。上面所说的只是针对于一个字段的计算方式。如果有多个字段的话,要保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL值列表所占用的字节数 <= 65535。行溢出后,MySQL 是怎么处理的?MySQL 中磁盘和内存交互的基本单位是页,一个页的大小一般是 16KB,也就是 16384字节,而一个 varchar(n) 类型的列最多可以存储 65532字节,一些大对象如 TEXT、BLOB 可能存储更多的数据,这时一个页可能就存不了一条记录。这个时候就会发生行溢出,多的数据就会存到另外的「溢出页」中。如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。在一般情况下,InnoDB 的数据都是存放在 「数据页」中。但是当发生行溢出时,溢出的数据会存放到「溢出页」中。当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。大致如下图所示。上面这个是 Compact 行格式在发生行溢出后的处理。Compressed 和 Dynamic 这两个行格式和 Compact 非常类似,主要的区别在于处理行溢出数据时有些区别。这两种格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中,看起来就像下面这样:总结MySQL 的 NULL 值是怎么存放的?MySQL 的 Compact 行格式中会用「NULL值列表」来标记值为 NULL 的列,NULL 值并不会存储在行格式中的真实数据部分。NULL值列表会占用 1 字节空间,当表中所有字段都定义成 NOT NULL,行格式中就不会有 NULL值列表,这样可节省 1 字节的空间。MySQL 怎么知道 varchar(n) 实际占用数据的大小?MySQL 的 Compact 行格式中会用「变长字段长度列表」存储变长字段实际占用的数据大小。varchar(n) 中 n 最大取值为多少?一行记录最大能存储 65535 字节的数据,但是这个是包含「变长字段字节数列表所占用的字节数」和「NULL值列表所占用的字节数」。如果一张表只有一个 varchar(n) 字段,且允许为 NULL,字符集为 ascii。varchar(n) 中 n 最大取值为 65532。计算公式:65535 - 变长字段字节数列表所占用的字节数 - NULL值列表所占用的字节数 = 65535 - 2 - 1 = 65532行溢出后,MySQL 是怎么处理的?如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。Compact 行格式针对行溢出的处理是这样的:当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。Compressed 和 Dynamic 这两种格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中。
2022年12月12日
124 阅读
0 评论
0 点赞
2022-12-12
Linux进程是如何创建出来的?
在 Linux 中,进程是我们非常熟悉的东东了,哪怕是只写过一天代码的人也都用过它。但是你确定它不是你最熟悉的陌生人?我们今天通过深度剖析进程的创建过程,帮助你提高对进程的理解深度。在这篇文章中,我会用 Nginx 创建 worker 进程的例子作为引入,然后带大家了解一些进程的数据结构 task_struct,最后再带大家看一下 fork 执行的过程。学习完本文,你将深度理解进程中的那些关键要素,诸如进程地址空间、当前目录、父子进程关系、进程打开的文件 fd 表、进程命名空间等。也能学习到内核在保存已经使用的 pid 号时是如何优化内存占用的。我们展开今天的拆解!一、Nginx 之 fork 创建 worker在 Linux 进程的创建中,最核心的就是 fork 系统调用。不过我们先不着急介绍它,先拿多进程服务中的一个经典例子 - Nginx,来看看他是如何使用 fork 来创建 worker 的。Nginx 服务采用的是多进程方式来工作的,它启动的时候会创建若干个 worker 进程出来,来响应和处理用户请求。创建 worker 子进程的源码位于 nginx 源码的 src/os/unix/ngx_process_cycle.c 文件中。通过循环调用 ngx_spawn_process 来创建 n 个 worker 出来。//file:src/os/unix/ngx_process_cycle.c static void ngx_start_worker_processes(...) { ... for (i = 0; i < n; i++) { ngx_spawn_process(cycle, ngx_worker_process_cycle, (void *) (intptr_t) i, "worker process", type); ... } }我们在来看下负责具体进程创建的 ngx_spawn_process 函数。//file: src/os/unix/ngx_process.c ngx_pid_t ngx_spawn_process(ngx_cycle_t *cycle, ngx_spawn_proc_pt proc,...) { pid = fork(); switch (pid) { case -1: //出错了 ... case 0: //子进程创建成功 ... proc(cycle, data); break; } ... }在 ngx_spawn_process 中调用 fork 来创建进程,创建成功后 Worker 进程就将进入自己的入口函数中开始工作了。二、Linux 中对进程的表示在深入理解进程创建之前,我们先来看一下进程的数据结构。在 Linux 中,是用一个 task_struct 来实现 Linux 进程的(其实 Linux 线程也同样是用 task_struct 来表示的,这个我们以后文章单独再说)。我们来看看 task_struct 具体的定义,它位于 include/linux/sched.h//file:include/linux/sched.h struct task_struct { //2.1 进程状态 volatile long state; //2.2 进程线程的pid pid_t pid; pid_t tgid; //2.3 进程树关系:父进程、子进程、兄弟进程 struct task_struct __rcu *parent; struct list_head children; struct list_head sibling; struct task_struct *group_leader; //2.4 进程调度优先级 int prio, static_prio, normal_prio; unsigned int rt_priority; //2.5 进程地址空间 struct mm_struct *mm, *active_mm; //2.6 进程文件系统信息(当前目录等) struct fs_struct *fs; //2.7 进程打开的文件信息 struct files_struct *files; //2.8 namespaces struct nsproxy *nsproxy; }2.1 进程线程状态进程线程都是有状态的,它的状态就保存在 state 字段中。常见的状态中 TASK_RUNNING 表示进程线程处于就绪状态或者是正在执行。TASK_INTERRUPTIBLE 表示进程线程进入了阻塞状态。一个任务(进程或线程)刚创建出来的时候是 TASK_RUNNING 就绪状态,等待调度器的调度。调度器执行 schedule 后,任务获得 CPU 后进入 执行进行运行。当需要等待某个事件的时候,例如阻塞式 read 某个 socket 上的数据,但是数据还没有到达的时候,任务进入 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 状态,任务被阻塞掉。当等待的事件到达以后,例如 socket 上的数据到达了。内核在收到数据后会查看 socket 上阻塞的等待任务队列,然后将之唤醒,使得任务重新进入 TASK_RUNNING 就绪状态。任务如此往复地在各个状态之间循环,直到退出。一个任务(进程或线程)的大概状态流转图如下。全部的状态值在 include/linux/sched.h 中进行了定义。//file:include/linux/sched.h #define TASK_RUNNING 0 #define TASK_INTERRUPTIBLE 1 #define TASK_UNINTERRUPTIBLE 2 #define __TASK_STOPPED 4 #define __TASK_TRACED 8 ... #define TASK_DEAD 64 #define TASK_WAKEKILL 128 #define TASK_WAKING 256 #define TASK_PARKED 512 #define TASK_STATE_MAX 1024 ......2.2 进程 ID我们知道,每一个进程都有一个进程 id 的概念。在 task_struct 中有两个相关的字段,分别是 pid 和 tgid。//file:include/linux/sched.h struct task_struct { ...... pid_t pid; pid_t tgid; }其中 pid 是 Linux 为了标识每一个进程而分配给它们的唯一号码,称做进程 ID 号,简称 PID。对于没有创建线程的进程(只包含一个主线程)来说,这个 pid 就是进程的 PID,tgid 和 pid 是相同的。2.3 进程树关系在 Linux 下所有的进程都是通过一棵树来管理的。在操作系统启动的时候,会创建 init 进程,接下来所有的进程都是由这个进程直接或者间接创建的的。通过 pstree 命令可以查看你当前服务器上的进程树信息。init-+-atd |-cron |-db2fmcd |-db2syscr-+-db2fmp---4*[{db2fmp}] | |-db2fmp---3*[{db2fmp}] | |-db2sysc---13*[{db2sysc}] | |-3*[db2syscr] | |-db2vend | `-{db2syscr} |-dbus-daemon那么,这棵进程树就是由 task_struct 下的 parent、children、sibling 等字段来表示的。这几个字段将系统中的所有 task 串成了一棵树。2.4 进程调度优先级在 task_struct 中有几个字段是表示进程优先级的,在进程调度的时候会根据这几个字段来决定优先让哪个任务(进程或线程)开始执行。static_prio: 用来保存静态优先级,可以调用 nice 系统直接来修改取值范围为 100~139rt_priority: 用来保存实时优先级,取值范围为 0~99prio: 用来保存动态优先级normal_prio: 它的值取决于静态优先级和调度策略2.5 进程地址空间对于用户进程来讲,内存描述符 mm_struct( mm 代表的是 memory descriptor)是非常核心的数据结构。整个进程的虚拟地址空间部分都是由它来表示的。进程在运行的时候,在用户态其所需要的代码,全局变量数据,以及 mmap 内存映射等全部都是通过 mm_struct 来进行内存查找和寻址的。这个数据结构的定义位于 include/linux/mm_types.h 文件下。//file:include/linux/mm_types.h struct mm_struct { struct vm_area_struct * mmap; /* list of VMAs */ struct rb_root mm_rb; unsigned long mmap_base; /* base of mmap area */ unsigned long task_size; /* size of task vm space */ unsigned long start_code, end_code, start_data, end_data; unsigned long start_brk, brk, start_stack; unsigned long arg_start, arg_end, env_start, env_end; }其中 start_code、end_code 分别指向代码段的开始与结尾、start_data 和 end_data 共同决定数据段的区域、start_brk 和 brk 中间是堆内存的位置、start_stack 是用户态堆栈的起始地址。整个 mm_struct 和地址空间、页表、物理内存的关系如下图。在内核内存区域,可以通过直接计算得出物理内存地址,并不需要复杂的页表计算。而且最重要的是所有内核进程、以及用户进程的内核态,这部分内存都是共享的。另外要注意的是,mm(mm_struct)表示的是虚拟地址空间。而对于内核线程来说,是没有用户态的虚拟地址空间的。所以内核线程的 mm 的值是 null。2.6 进程文件系统信息(当前目录等)进程的文件位置等信息是由 fs_struct 来描述的,它的定义位于 include/linux/fs_struct.h 文件中。//file:include/linux/fs_struct.h struct fs_struct { ... struct path root, pwd; }; //file:include/linux/path.h struct path { struct vfsmount *mnt; struct dentry *dentry; };通过以上代码可以看出,在 fs_struct 中包含了两个 path 对象,而每个 path 中都指向了一个 struct dentry。在 Linux 内核中,denty 结构是对一个目录项的描述。拿 pwd 来举例,该指针指向的是进程当前目录所处的 denty 目录项。假如我们在 shell 进程中执行 pwd,或者用户进程查找当前目录下的配置文件的时候,都是通过访问 pwd 这个对象,进而找到当前目录的 denty 的。2.7 进程打开的文件信息每个进程用一个 files_struct 结构来记录文件描述符的使用情况, 这个 files_struct 结构称为用户打开文件表。它的定义位于 include/linux/fdtable.h。{callout color="#fa0000"}注意:这里用的内核源码一直是 3.10.0, 不同版本的源码这里稍微可能有些出入。{/callout}//file:include/linux/fdtable.h struct files_struct { ...... //下一个要分配的文件句柄号 int next_fd; //fdtable struct fdtable __rcu *fdt; } struct fdtable { //当前的文件数组 struct file __rcu **fd; ...... };在 files_struct 中,最重要的是在 fdtable 中包含的 file **fd 这个数组。这个数组的下标就是文件描述符,其中 0、1、2 三个描述符总是默认分配给标准输入、标准输出和标准错误。这就是你在 shell 命令中经常看到的 2>&1 的由来。这几个字符的含义就是把标准错误也一并打到标准输出中来。在数组元素中记录了当前进程打开的每一个文件的指针。这个文件是 Linux 中抽象的文件,可能是真的磁盘上的文件,也可能是一个 socket。2.8 namespaces在 Linux 中,namespace 是用来隔离内核资源的方式。通过 namespace 可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源,这两拨进程根本就感觉不到对方的存在。具体的实现方式是把一个或多个进程的相关资源指定在同一个 namespace 中,而进程究竟是属于哪个 namespace,都是在 task_struct 中由 *nsproxy 指针表明了这个归属关系。//file:include/linux/nsproxy.h struct nsproxy { atomic_t count; struct uts_namespace *uts_ns; struct ipc_namespace *ipc_ns; struct mnt_namespace *mnt_ns; struct pid_namespace *pid_ns; struct net *net_ns; };命名空间包括PID命名空间、挂载点命名空间、网络命名空间等多个。在这篇文章《动手实验+源码分析,彻底弄懂Linux网络命名空间》这一文中详细介绍过网络命名空间,感兴趣的同学可以详细阅读。三、解密 fork 系统调用前面我们看了 Nginx 使用 fork 来创建 worker 进程,也了解了进程的数据结构 task_struct ,我们再来看看 fork 系统调用的内部逻辑。这个 fork 在内核中是以一个系统调用来实现的,它的内核入口是在 kernel/fork.c 下。//file:kernel/fork.c SYSCALL_DEFINE0(fork) { return do_fork(SIGCHLD, 0, 0, NULL, NULL); }这里注意下调用 do_fork 时传入的第一个参数,这个参数是一个 flag 选项。它可以传入的值包括 CLONE_VM、CLONE_FS 和 CLONE_FILES 等等很多,但是这里只传了一个 SIGCHLD(子进程在终止后发送 SIGCHLD 信号通知父进程),并没有传 CLONE_FS 等其它 flag。//file:include/uapi/linux/sched.h //cloning flags: ... #define CLONE_VM 0x00000100 #define CLONE_FS 0x00000200 #define CLONE_FILES 0x00000400 ...在 do_fork 的实现中,核心是一个 copy_process 函数,它以拷贝父进程的方式来生成一个新的 task_struct 出来。//file:kernel/fork.c long do_fork(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr) { //复制一个 task_struct 出来 struct task_struct *p; p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace); //子任务加入到就绪队列中去,等待调度器调度 wake_up_new_task(p); ... }在创建完毕后,调用 wake_up_new_task 将新创建的任务添加到就绪队列中,等待调度器调度执行。copy_process 的代码很长,我对其进行了一定程度的精简,参加下面的代码。//file:kernel/fork.c static struct task_struct *copy_process(...) { //3.1 复制进程 task_struct 结构体 struct task_struct *p; p = dup_task_struct(current); ... //3.2 拷贝 files_struct retval = copy_files(clone_flags, p); //3.3 拷贝 fs_struct retval = copy_fs(clone_flags, p); //3.4 拷贝 mm_struct retval = copy_mm(clone_flags, p); //3.5 拷贝进程的命名空间 nsproxy retval = copy_namespaces(clone_flags, p); //3.6 申请 pid && 设置进程号 pid = alloc_pid(p->nsproxy->pid_ns); p->pid = pid_nr(pid); p->tgid = p->pid; if (clone_flags & CLONE_THREAD) p->tgid = current->tgid; ...... }可见,copy_process 先是复制了一个新的 task_struct 出来,然后调用 copy_xxx 系列的函数对 task_struct 中的各种核心对象进行拷贝处理,还申请了 pid。接下来我们分小节来查看该函数的每一个细节。3.1 复制进程 task_struct 结构体注意一下,上面调用 dup_task_struct 时传入的参数是 current,它表示的是当前进程。在 dup_task_struct 里,会申请一个新的 task_struct 内核对象,然后将当前进程复制给它。需要注意的是,这次拷贝只会拷贝 task_struct 结构体本身,它内部包含的 mm_struct 等成员只是复制了指针,仍然指向和 current 相同的对象。我们来简单看下具体的代码。 //file:kernel/fork.c static struct task_struct *dup_task_struct(struct task_struct *orig) { //申请 task_struct 内核对象 tsk = alloc_task_struct_node(node); //复制 task_struct err = arch_dup_task_struct(tsk, orig); ... }其中 alloc_task_struct_node 用于在 slab 内核内存管理区中申请一块内存出来。关于 slab 机制请参考- 内核内存管理//file:kernel/fork.c static struct kmem_cache *task_struct_cachep; static inline struct task_struct *alloc_task_struct_node(int node) { return kmem_cache_alloc_node(task_struct_cachep, GFP_KERNEL, node); }申请完内存后,调用 arch_dup_task_struct 进行内存拷贝。//file:kernel/fork.c int arch_dup_task_struct(struct task_struct *dst, struct task_struct *src) { *dst = *src; return 0; }3.2 拷贝 files_struct由于进程之间都是独立的,所以创建出来的新进程需要拷贝一份独立的 files 成员出来。我们看 copy_files 是如何申请和拷贝 files 成员的。//file:kernel/fork.c static int copy_files(unsigned long clone_flags, struct task_struct *tsk) { struct files_struct *oldf, *newf; oldf = current->files; if (clone_flags & CLONE_FILES) { atomic_inc(&oldf->count); goto out; } newf = dup_fd(oldf, &error); tsk->files = newf; ... }看上面代码中判断了是否有 CLONE_FILES 标记,如果有的话就不执行 dup_fd 函数了,增加个引用计数就返回了。前面我们说了,do_fork 被调用时并没有传这个标记。所以还是会执行到 dup_fd 函数://file:fs/file.c struct files_struct *dup_fd(struct files_struct *oldf, ...) { //为新 files_struct 申请内存 struct files_struct *newf; newf = kmem_cache_alloc(files_cachep, GFP_KERNEL); //初始化 & 拷贝 new_fdt->max_fds = NR_OPEN_DEFAULT; ... }这个函数就是到内核中申请一块内存出来,保存 files_struct 使用。然后对新的 files_struct 进行各种初始化和拷贝。至此,新进程有了自己独立的 files 成员了。3.3 拷贝 fs_struct同样,新进程也需要一份独立的文件系统信息 - fs_struct 成员的。我们来看 copy_fs 是如何申请和初始化 fs_struct 的。//file:kernel/fork.c static int copy_fs(unsigned long clone_flags, struct task_struct *tsk) { struct fs_struct *fs = current->fs; if (clone_flags & CLONE_FS) { fs->users++; return 0; } tsk->fs = copy_fs_struct(fs); return 0; }在创建进程的时候,没有传递 CLONE_FS 这个标志,所会进入到 copy_fs_struct 函数中申请新的 fs_struct 并进行赋值。//file:fs/fs_struct.c struct fs_struct *copy_fs_struct(struct fs_struct *old) { //申请内存 struct fs_struct *fs = kmem_cache_alloc(fs_cachep, GFP_KERNEL); //赋值 fs->users = 1; fs->root = old->root; fs->pwd = old->pwd; ... return fs; }3.4 拷贝 mm_struct前面我们说过,对于进程来讲,地址空间是一个非常重要的数据结构。而且进程之间地址空间也必须是要隔离的,所以还会新建一个地址空间。创建地址空间的操作是在 copy_mm 中执行的。 //file:kernel/fork.c static int copy_mm(unsigned long clone_flags, struct task_struct *tsk) { struct mm_struct *mm, *oldmm; oldmm = current->mm; if (clone_flags & CLONE_VM) { atomic_inc(&oldmm->mm_users); mm = oldmm; goto good_mm; } mm = dup_mm(tsk); good_mm: return 0; }do_fork 被调用时也没有传 CLONE_VM,所以会调用 dup_mm 申请一个新的地址空间出来。//file:kernel/fork.c struct mm_struct *dup_mm(struct task_struct *tsk) { struct mm_struct *mm, *oldmm = current->mm; mm = allocate_mm(); memcpy(mm, oldmm, sizeof(*mm)); ... }在 dup_mm 中,通过 allocate_mm 申请了新的 mm_struct,而且还将当前进程地址空间 current->mm 拷贝到新的 mm_struct 对象里了。地址空间是进程线程最核心的东西,每个进程都有独立的地址空间3.5 拷贝进程的命名空间 nsproxy在创建进程或线程的时候,还可以让内核帮我们创建独立的命名空间。在默认情况下,创建进程没有指定命名空间相关的标记,因此也不会创建。新旧进程仍然复用同一套命名空间对象。3.6 申请pid接下来 copy_process 还会进入 alloc_pid 来为当前任务申请 PID。//file:kernel/fork.c static struct task_struct *copy_process(...) { ... //申请pid pid = alloc_pid(p->nsproxy->pid_ns); //赋值 p->pid = pid_nr(pid); p->tgid = p->pid; ... }注意下,在调用 alloc_pid 的时候,其参数传递的是新进程的 pid namespace。我们来深看一下 alloc_pid 的执行逻辑。//file:kernel/pid.c struct pid *alloc_pid(struct pid_namespace *ns) { //申请 pid 内核对象 pid = kmem_cache_alloc(ns->pid_cachep, GFP_KERNEL); if (!pid) goto out; //调用到alloc_pidmap来分配一个空闲的pid编号 //注意,在每一个命令空间中都需要分配进程号 tmp = ns; pid->level = ns->level; for (i = ns->level; i >= 0; i--) { nr = alloc_pidmap(tmp); pid->numbers[i].nr = nr; ... } ... return pid }这里的 PID 并不是一个整数,而是一个结构体,所以先试用 kmem_cache_alloc 把它申请出来。接下来调用 alloc_pidmap 到 pid 命名空间中申请一个 pid 号出来,申请完后赋值记录。回顾我们开篇提到的一个问题:操作系统是如何记录使用过的进程号的?在 Linux 内部,为了节约内存,进程号是通过 bitmap 来管理的。在每一个 pid 命名空间内部,会有一个或者多个页面来作为 bitmap。其中每一个 bit 位(注意是 bit 位,不是字节)的 0 或者 1 的状态来表示当前序号的 pid 是否被占用。//file:include/linux/pid_namespace.h #define BITS_PER_PAGE (PAGE_SIZE * 8) #define PIDMAP_ENTRIES ((PID_MAX_LIMIT+BITS_PER_PAGE-1)/BITS_PER_PAGE) struct pid_namespace { struct pidmap pidmap[PIDMAP_ENTRIES]; ... }在 alloc_pidmap 中就是以 bit 的方式来遍历整个 bitmap,找到合适的未使用的 bit,将其设置为已使用,然后返回。//file:kernel/pid.c static int alloc_pidmap(struct pid_namespace *pid_ns) { ... map = &pid_ns->pidmap[pid/BITS_PER_PAGE]; }在各种语言中,一般一个 int 都是 4 个字节,换算成 bit 就是 32 bit。而使用这种 bitmap 的思想的话,只需要一个 bit 就可以表示一个整数,相当的节约内存。所以,在很多超大规模数据处理中都会用到这种思想来进行优化内存占用的。3.7 进入就绪队列当 copy_process 执行完毕的时候,表示新进程的一个新的 task_struct 对象就创建出来了。接下来内核会调用 wake_up_new_task 将这个新创建出来的子进程添加到就绪队列中等待调度。//file:kernel/fork.c long do_fork(...) { //复制一个 task_struct 出来 struct task_struct *p; p = copy_process(clone_flags, stack_start, ...); //子任务加入到就绪队列中去,等待调度器调度 wake_up_new_task(p); ... }等操作系统真正调度开始的时候,子进程中的代码就可以真正开始执行了。四、总结在这篇文章中,我用 Nginx 创建 worker 进程的例子作为引入,然后带大家了解一些进程的数据结构 task_struct,最后又带大家看一下 fork 执行的过程。在 fork 创建进程的时候,地址空间 mm_struct、挂载点 fs_struct、打开文件列表 files_struct 都要是独立拥有的,所以都去申请内存并初始化了它们。但由于今天我们的例子父子进程是同一个命名空间,所以 nsproxy 还仍然是共用的。其中 mm_struct 是一个非常核心的数据结构,用户进程的虚拟地址空间就是用它来表示的。对于内核线程来讲,不需要虚拟地址空间,所以 mm 成员的值为 null。另外还学到了内核是用 bitmap 来管理使用和为使用的 pid 号的,这样做的好处是极大地节约了内存开销。而且由于数据存储的足够紧凑,遍历起来也是非常的快。一方面原因是数据小,加载起来快。另外一方面是会加大提高 CPU 缓存的命中率,访问非常快。今天的进程创建过程就学习完了。不过细心的同学可能发现了,我们这里只介绍了子进程的调用。但是对于 Nginx 主进程如何加载起来执行的还没有讲到。我们将来还会展开叙述,敬请期待!
2022年12月12日
50 阅读
0 评论
0 点赞
2022-12-12
play() failed because the user didn't interact with the document first
问题:在浏览器加载完毕后,自动播放视频:出现错误 play() failed because the user didn't interact with the document first.解决方法:给video标签加入 静音即可。Chrome 66为了避免标签产生随机噪音。声音无法自动播放这个在IOS/Android上面一直是个惯例,桌面版的Safari在2017年的11版本也宣布禁掉带有声音的多媒体自动播放功能,紧接着在2018年4月份发布的Chrome 66也正式关掉了声音自动播放,也就是说 在桌面版浏览器也将失效。最开始移动端浏览器是完全禁止音视频自动播放的,考虑到了手机的带宽以及对电池的消耗。但是后来又改了,因为浏览器厂商发现网页开发人员可能会使用GIF动态图代替视频实现自动播放,正如IOS文档所说,使用GIF的带宽流量是Video(h264)格式的12倍,而播放性能消耗是2倍,所以这样对用户反而是不利的。又或者是使用Canvas进行hack,如Android Chrome文档提到。因此浏览器厂商放开了对多媒体自动播放的限制,只要具备以下条件就能自动播放:(1)没音频轨道,或者设置了muted属性(2)在视图里面是可见的,要插入到DOM里面并且不是display: none或者visibility: hidden的,没有滑出可视区域。换句话说,只要你不开声音扰民,且对用户可见,就让你自动播放,不需要你去使用GIF的方法进行hack.桌面版的浏览器在近期也使用了这个策略,如升级后的Safari 11的说明:这个策略无疑对视频网站的冲击最大,如在Safari打开tudou的提示:添加了一个设置向导。Chrome的禁止更加人性化,它有一个MEI的策略,这个策略大概是说只要用户在当前网页主动播放过超过7s的音视频(视频窗口不能小于200 x 140),就允许自动播放。对于网页开发人员来说,应当如何有效地规避这个风险呢?Chrome的文档给了一个最佳实践:先把音视频加一个muted的属性就可以自动播放,然后再显示一个声音被关掉的按钮,提示用户点一下打开声音。对于视频来说,确实可以这样处理,而对于音频来说,很多人是监听页面点击事件,只要点一次了就开始播放声音,一般就是播放个背景音乐。但是如果对于有多个声音资源的页面来说如何自动播放多个声音呢?首先,如果用户还没进行交互就调用播放声音的API,Chrome会这么提示: DOMException: play() failed because the user didn't interact with the document first. Safari会这么提示: NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission. Chrome报错提示最为友善,意思是说,用户还没有交互,不能调play。用户的交互包括哪些呢?包括用户触发的touchend, click, doubleclick或者是 keydown事件,在这些事件里面就能调play 所以上面提到很多人是监听整个页面的点击事件进行播放,不管点的哪里,只要点了就行,包括触摸下滑。这种方法只适用于一个声音资源,不适用多个声音,多个声音应该怎么破呢?这里并不是说要和浏览器对着干,“逆天而行”,我们的目的还是为了提升用户体验,因为有些场景如果能自动播放确实比较好,如一些答题的场景,需要听声音进行答题,如果用户在答题的过程中能依次自动播放相应题目的声音,确实比较方便。同时也是讨论声音播放的技术实现。 原生播放视频应该就只能使用video标签,而原生播放音频除了使用audio标签之外,还有另外一个API叫AudioContext,它是能够用来控制声音播放并带了很多丰富的操控接口。调audio.play必须在点击事件里面响应,而使用AudioContext的区别在于只要用户点过页面任何一个地方之后就都能播放了。所以可以用AudioContext取代audio标签播放声音。 我们先用audio.play检测页面是否支持自动播放,以便决定我们播放的时机。1.页面自动播放检测方法很简单,就是创建一个audio元素,给它赋一个src,append到dom里面,然后调用它的play,看是否会抛异常,如果捕获到异常则说明不支持,如下代码所示:function testAutoPlay () { // 返回一个promise以告诉调用者检测结果 return new Promise(resolve => { let audio = document.createElement('audio'); // require一个本地文件,会变成base64格式 audio.src = require('@/assets/empty-audio.mp3'); document.body.appendChild(audio); let autoplay = true; // play返回的是一个promise audio.play().then(() => { // 支持自动播放 autoplay = true; }).catch(err => { // 不支持自动播放 autoplay = false; }).finally(() => { audio.remove(); // 告诉调用者结果 resolve(autoplay); }); }); }这里使用一个空的音频文件,它是一个时间长度为0s的mp3文件,大小只有4kb,并且通过webpack打包成本地的base64格式,所以不用在canplay事件之后才调用play,直接写成同步代码,如果src是一个远程的url,那么就得监听canplay事件,然后在里面play.在告诉调用者结果时,使用Promise resolve的方式,因为play的结果是异步的,并且库函数不推荐使用await.2. 监听页面交互点击如果当前页面能够自动播放,那么可以毫无顾忌地让声音自动播放了,否则就得等到用户开始和这个页面交互了即有点击操作了之后才能自动播放,如下代码所示:let audioInfo = { autoplay: false, testAutoPlay () { // 代码同,略... }, // 监听页面的点击事件,一旦点过了就能autoplay了 setAutoPlayWhenClick () { function setAutoPlay () { // 设置自动播放为true audioInfo.autoplay = true; document.removeEventListener('click', setAutoPlay); document.removeEventListener('touchend', setAutoPlay); } document.addEventListener('click', setCallback); document.addEventListener('touchend', setCallback); }, init () { // 检测是否能自动播放 audioInfo.testAutoPlay().then(autoplay => { if (!audioInfo.autoplay) { audioInfo.autoplay = autoplay; } }); // 用户点击交互之后,设置成能自动播放 audioInfo.setAutoPlayWhenClick(); } }; audioInfo.init(); export default audioInfo; 上面代码主要监听document的click事件,在click事件里面把autoplay值置为true。换句话说,只要用户点过了,我们就能随时调AudioContext的播放API了,即使不是在点击事件响应函数里面,虽然无法在异步回调里面调用audio.play,但是AudioContext可以做到。 代码最后通过调用audioInfo.init,把能够自动播放的信息存储在了audioInfo.autoplay这个变量里面。当需要播放声音的时候,例如切到了下一题,需要自动播放当前题的几个音频资源,就取这个变量判断是否能自动播放,如果能就播,不能就等用户点声音图标自己去播,并且如果他点过了一次之后就都能自动播放了。 那么怎么用AudioContext播放声音呢?3. AudioContext播放声音先请求音频文件,放到ArrayBuffer里面,然后用AudioContext的API进行decode解码,解码完了再让它去play,就行了。我们先写一个请求音频文件的ajax:function request (url) { return new Promise (resolve => { let xhr = new XMLHttpRequest(); xhr.open('GET', url); // 这里需要设置xhr response的格式为arraybuffer // 否则默认是二进制的文本格式 xhr.responseType = 'arraybuffer'; xhr.onreadystatechange = function () { // 请求完成,并且成功 if (xhr.readyState === 4 && xhr.status === 200) { resolve(xhr.response); } }; xhr.send(); }); }这里需要注意的是要把xhr响应类型改成arraybuffer,因为decode需要使用这种存储格式,这样设置之后,xhr.response就是一个ArrayBuffer格式了。接着实例化一个AudioContext,让它去解码然后play,如下代码所示:// Safari是使用webkit前缀 let context = new (window.AudioContext || window.webkitAudioContext)(); // 请求音频数据 let audioMedia = await request(url); // 进行decode和play context.decodeAudioData(audioMedia, decode => play(context, decode));play的函数实现如下:function play (context, decodeBuffer) { let source = context.createBufferSource(); source.buffer = decodeBuffer; source.connect(context.destination); // 从0s开始播放 source.start(0); }这样就实现了AudioContext播放音频的基本功能。如果当前页面是不能autoplay,那么在 new AudioContext的时候,Chrome控制台会报一个警告:这个的意思是说,用户还没有和页面交互你就初始化了一个AudioContext,我是不会让你play的,你需要在用户点击了之后resume恢复这个context才能够进行play.假设我们不管这个警告,直接调用play没有报错,但是没有声音。所以这个时候就要用到上一步audioInfo.autoplay的信息,如果这个为true,那么可以play,否则不能play,需要让用户自己点声音图标进行播放。所以,把代码重新组织一下:function play (context, decodeBuffer) { // 调用resume恢复播放 context.resume(); let source = context.createBufferSource(); source.buffer = decodeBuffer; source.connect(context.destination); source.start(0); } function playAudio (context, url) { let audioMedia = await request(url); context.decodeAudioData(audioMedia, decode => play(context, decode)); } let context = new (window.AudioContext || window.webkitAudioContext)(); // 如果能够自动播放 if (audioInfo.autoplay) { playAudio(url); } // 支持用户点击声音图标自行播放 $('.audio-icon').on('click', function () { playAudio($(this).data('url')); });调了resume之后,如果之前有被禁止播放的音频就会开始播放,如果没有则直接恢复context的自动播放功能。这样就达到基本目的,如果支持自动播放就在代码里面直接play,不支持就等点击。只要点了一次,不管点的哪里接下来的都能够自动播放了。就能实现类似于每隔3s自动播下一题的音频的目的:// 每隔3秒自动播放一个声音 playAudio('question-1.mp3'); setTimeout(() => playAudio(context, 'question-2.mp3'), 3000); setTimeout(() => playAudio(context, 'question-3.mp3'), 3000);这里还有一个问题,怎么知道每个声音播完了,然后再隔个3s播放下一个声音呢?可以通过两个参数,一个是解码后的decodeBuffer有当前音频的时长duration属性,而通过context.currentTime可以知道当前播放时间精度,然后就可以弄一个计时器,每隔100ms比较一下context.currentTime是否大于docode.duration,如果是的话说明播完了。soundjs这个库就是这么实现的,我们可以利用这个库以方便对声音的操作。这样就实现了利用AudioContext自动播放多个音频的目的,限制是用户首次打开页面是不能自动播放的,但是一旦用户点过页面的任何一个地方就可以了。AudioContext还有其它的一些操作。4. AudioContext控制声音属性例如这个CSS Tricks列了几个例子,其中一个是利用AudioContext的振荡器oscillator写了一个电子木琴:这个例子没有用到任何一个音频资源,都是直接合成的,感受如这个Demo:Play the Xylophone (Web Audio API).还有这种混响均衡器的例子:见这个codepen:Web Audio API: parametric equalizer.最后,一直以来都是只有移动端的浏览器禁掉了音视频的自动播放,现在桌面版的浏览器也开始下手了。浏览器这样做的目的在于,不想让用户打开一个页面就各种广告或者其它乱七八糟的声音在播,营造一个纯静的环境。但是浏览器也不是一刀切,至少允许音视频静音的播放。所以对于视频来说,可以静音自动播放,然后加个声音被关掉的图标让用户点击打开,再加添加设置向导之类的方法引导用户设置允许当前网站自动播放。而对于声音可以用AudioContext的API,只要页面被点过一次AudioContext就被激活了,就能直接在代码里面控制播放了。以上可作为当前网页多媒体播放的最佳实践参考。参考链接:https://juejin.im/post/5af7129bf265da0b8262df4c转载于:https://www.cnblogs.com/Neilisme/p/9412315.html
2022年12月12日
105 阅读
0 评论
0 点赞
2022-12-12
uniapp获取图片base64
1.从相册中获取图片uni.chooseImage({ count: 1, // 默认9 sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有 sourceType: ['album'], // 从相册选择 success: (res) => { this.img = res.tempFilePaths } })2.图片转成base64uni.getFileSystemManager().readFile({ filePath: this.img[0], encoding: 'base64', success: r => { console.log("base64===="+r.data) }, fail: (errr) => { uni.hideLoading() } })
2022年12月12日
250 阅读
0 评论
0 点赞
1
...
26
27
28
...
76
您的IP: