0%

前言

经济学的研究对研究性能与成本之间的权衡也有一定的指导意义,并且我对经济学也挺感兴趣,因此假期花两天时间阅读了经济学原理:微观经济学分册(第七版)

这本书基本都是大白话,一开始也打算就读一读。读了一会觉得有些方面确实值得记录一下,索性对整本书的一些关键的点都做一个记录。

1.十大原理

  • 人们如何做出决策
    • 1.人们面临权衡取舍
    • 2.某种东西的成本是为了得到它所放弃的东西(机会成本)
    • 3.理性人考虑边际量(围绕所做的事情的边缘进行微小调整,而不是非黑即白地考虑问题)
    • 4.人们会对激励做出反应
  • 人们如何相互影响
    • 5.贸易可以使每个人的状况都变得更好
    • 6.市场通常是组织经济活动的一种好方法
    • 7.政府有时可以改善市场的结果
  • 整体经济如何运行
    • 8.一国的生活水平取决于它生产物品与服务的能力
    • 9.当政府发行了过多货币时,物价上升
    • 10.社会面临通货膨胀与失业之间的短期权衡取舍(经济周期,通胀->失业->通胀…)
      • 长期来看,通货膨胀导致物价水平上升。但短期来看比较复杂:
        • 经济中货币量增加刺激了社会支出水平,从而增加了对物品与服务的需求
        • 需求的增加会提高企业物价,同时鼓舞企业雇佣更多工人,意味更少的失业

2.像经济学家一样思考

  • 客观、适当假设、建立经济模型
  • 实证表述:关于世界是什么的论断;规范表述:关于世界应该是什么的论断。
  • 经济学家做出实证表述时,是以科学家的角度;做出规范表述时,是以政策顾问的角度。
  • 经济学家的建议有时会相互矛盾(科学判断的差别、价值观的差别)。
  • 由于政治过程施加的力量和约束,决策者可能不会采取建议。

3.贸易

  • 绝对优势、比较优势、机会成本(贸易的优势)

4.市场

  • 完全竞争与垄断(两个极端)
  • 需求曲线(价格越低需求越多、曲线移动因素)
  • 供给曲线(价格越高供给越多、曲线移动因素)
  • 供给与需求曲线相交(均衡价格、均衡数量)
  • 过剩(价格降低、需求上升、供给降低,沿曲线移动而非曲线本身移动),短缺相反
  • 供求定理:价格自发调整
  • 均衡变动
  • 哄抬物价为什么合理的(更平等地分配资源、限制了囤积物品)

5.弹性

  • 需求价格弹性(需求量对价格变动的反应很大:富有弹性、缺乏弹性)
  • 相近替代品(有相近替代品的物品的需求往往富有弹性)
  • 必需品的需求往往缺乏弹性、奢侈品的需求往往富有弹性
  • 需求价格弹性 = 需求量变动百分比 / 价格变动百分比,中点法计算
  • 如果需求富有弹性,那么价格上升导致总收益减少(需求减少幅度大);缺乏弹性则价格上升导致总收益增加
  • 供给价格弹性,和需求价格弹性差不多的逻辑

提高/降低价格、增加/减少供给,能否最终提高总收益,要看需求/供给是富有弹性还是缺乏弹性。

6.政策

  • 价格控制

税收:

  • 税收归宿(税收负担在市场参与者之间如何分配)
  • 政府对一个物品征税,无论是对买家还是卖家征税,一旦达到新的均衡时,都是买者与卖者共同承担税收负担,最后的情况是等同的,由供需市场决定。
  • 税收归宿仍取决于供给和需求的力量,税收负担更多地落在缺乏弹性的市场一方(买方或卖方;即需求和供给哪个更缺乏弹性)。
    • 缺乏弹性意味着征税->提高价格后,由于缺乏替代品,这一方不愿意离开市场,愿意承担更多的税收负担。
  • 对一个物品征税使得这种物品的市场规模缩小。

7.消费者、生产者与市场效率

  • 消费者剩余(愿意支付的量 减去 实际支付的量)

  • 需求曲线以下和价格以上的面积衡量一个市场上的消费者剩余

  • 物品价格降低增加消费者剩余

  • 消费者剩余衡量了买者从一种物品中得到的自己感觉到的利益

  • 消费者剩余在某种程度上反映了经济福利

  • 生产者剩余(卖者出售物品得到的量 减去 成本)

  • 价格之下和供给曲线以上的面积衡量一个市场上的生产者剩余

  • 总剩余 = 买者的评价 - 卖者的成本 = 消费者剩余 + 生产者剩余

总剩余最大化是价格处于均衡点的位置。在前面的计算中,“曲线之下”和“曲线之上”的前提是要满足供给了一定有需求,有需求一定有供给。假如不再均衡的位置,要么过剩要么缺乏,需要考虑实际的数量来计算面积。

在过剩的时候,实际上只有低成本(相应的低价格)的卖家才能卖出。

img

因此,市场结果使总剩余达到了最大化,均衡的结果是资源的有效配置(有效率)。

市场失灵(不受管制的市场不能有效配置资源):

  • 市场势力(如单一买家、单一卖家)会使价格和数量背离供求均衡,使市场无效率。
  • 外部性(比如环境污染)这种市场副作用使市场福利不仅仅取决于买者评价和卖者成本,也会使市场无效率。
  • 从整体看,市场均衡可能是无效率的。

8.赋税的代价

  • 税收使物品市场规模缩小,同时使得买者支付价格提高,卖者收入降低,导致买者和卖者的利益(剩余)损失
  • 政府因税收获得收入,但这些收入小于买者和卖者的损失
  • 因此税收使得总剩余(加上税收本身)减少,减少的部分称为无谓损失(由市场扭曲 [比如税收] 产生的总剩余的减少)
  • 无谓损失减少了买者与卖者之间贸易的好处
  • 需求和供给的价格弹性决定无谓损失的大小
  • 需求和供给的弹性越大,税收的无谓损失也就越大。
    • 弹性越大,表明数量 —— 价格图中需求和供给曲线斜率低
    • 由于税收增加了需求和供给之间的价格差距,同样的差距,斜率越低,数量降低就越多
    • 数量降低越多,无谓损失也就越大
  • 税收越大,无谓损失就越大,但税收收入本身是先变大后变小,不能太极端(无税收和高税收)
    • 税收在一个合理的规模,才能保证一定规模的交易发生,并且每次交易产生的税收较大

9.国际贸易

  • 世界价格:一种物品在世界市场上通行的价格
    • 如果先前国内价格低于世界价格,那么出口是合适的。此时由于需求上来了,价格会提高,国内的需求量下降(国内买家利益受损了),但总需求量上升。
    • 反之进口是合适的
  • 关税:对进口的(国外生产)于国内销售的物品征收的税
    • 如果没有关税,那么在进出口后,国内价格会降低,等于世界价格。
    • 关税使得售卖的价格可以再提高,提高量等于关税量
    • 使得价格更接近于没有贸易时的均衡价格
    • 让国内供给者的状况变好了,国内购买者的状况变差了
    • 产生无谓损失,消费者的损失大于供给者的好处和税收收入
  • 好处:
    • 增加物品多样性
    • 通过规模经济降低成本(规模经济是指一种物品只有大量生产时,才能以低成本生产),自由贸易使得企业进入更大的市场,充分实现规模经济
    • 增加竞争
  • 质疑的观点:
    • 工作岗位论:产生失业,但可以流动到国内优势行业
    • 国家安全论:行业对国家安全是至关重要的,是合理的,但如果是行业代表提出而非国防机构提出,要小心其夸大公司的作用
    • 不公平竞争:每个国家的政策不同(如进出口补贴),会使一些人受到不公平的对待

10.外部性

  • 负外部性(比如生成造成的污染),会产生外部成本

    • 供给者的私人成本加上外部成本就是社会成本
    • 最优量便是社会成本和需求(私人价值)曲线的交点
    • 均衡量大于最优量,因为市场均衡仅仅反应生产的私人成本
    • 添加外部成本的一种方式是征税,这种税的运用称为外部性内在化,生产者考虑成本时由于税收也要考虑外部成本
  • 正外部性(比如教育带来了社会隐含的利益),会产生外部利益

    • 社会价值就是需求(私人价值)加上外部利益
    • 最优量便是社会价值和供给(私人成本)曲线的交点
    • 最优量大于均衡量,因为市场均衡仅仅反应消费者私人价值
    • 添加外部利益的方式是,政府进行补贴(如补贴教育),同样也是外部性内在化
  • 一些针对外部性的公共政策(使资源配置更接近于社会最优状态)

    • 管制:完全禁止某些事情(比如因为污染完全禁止工业生产)是不太可能的,因此需要在某种程度上权衡、管制
    • 基于市场,使用矫正税和补贴,矫正税与其他税(引发无谓损失)不同,它使资源配置向社会最优水平移动
    • 基于市场,采用可交易的许可证(污染许可证)
    • 个人方式,如慈善、道德约束,或者私人之间达成协议(科斯定理说明这往往是有效率的)。但私人解决的方法并不总是有效,可能由于交易成本过高无法达成协议,或者人数过多难以协商。

11.公共物品和公共资源

  • 物品的属性:
    • 排他性:该物品可以阻止一个人使用该物品
    • 消费中的竞争性:一个人使用某种物品会减少其他人对该物品的使用
  • 既排他又竞争:如某些私人物品(食物、衣服),由于是私人物品,除非付费购买,否则无法使用(排他);由于吃了食物或者穿了衣服,别人就不能吃/穿。
  • 排他但无竞争:如俱乐部用品(电视),除非付费购买,否则无法使用(排他);但一旦购买了,大家都可以享用。
  • 无排他但竞争:如公共资源(鱼塘里的鱼),不会阻止人们去获取,但一个人获取了其他人就少了(竞争)。
  • 无排他无竞争:如公共物品(报警器),每个人都可以获取,且一个人听到报警并不会使其他人减少听到报警的收益。
    • 排他总是需要购买的,竞争总是资源有限的
    • 公共资源是有限的,公共物品在某种程度上是可以复用的

  • 由于公共物品没有排他性,搭便车者(得到利益但避免付费的人)的存在就使得私人市场无法提供公共物品。因为这会使得个人生产者无法获利
  • 但如果公共物品的总收益大于成本,政府就可以用税收收入进行支付,提供公共物品,使所有人状况变好。
    • 比如:国防、基础研究(指一般性的知识)、脱贫
  • 有效地评估公共物品(如修建高速公路)的收益是困难的,因为这是免费的,没有可供观察的价格信号。

  • 公共资源没有排他性,但有竞争性,因此需要关注它被使用了多少。
  • 公共资源是有限的,过度使用会导致资源无法再生(鱼全捕完了,或者土地过度使用了),产生负外部性。
    • 人们往往考虑自己的价值,而不考虑负外部性,就会导致灾难。
    • 因此,需要政府或管理者介入
  • 为什么大象濒临灭绝而奶牛不用担心呢(它们都具有商业价值)。因为大象是公共资源(不排他)、奶牛是私人物品(排他)。这使得购买了奶牛的农场主会维护自己的奶牛。

在所有的情况,市场没有有效地配置资源,是因为没有很好地建立产权,即某些有价值的东西并没有其所有者。

比如清新的空气,没有一个人有权给它一个价格,并从它的使用中获得收益。

12.税制的设计

  • 政府有时能改善市场结果,比如政府解决一种外部性、提供一种公共物品、管制公共资源,能增进经济福利。但政府需要通过税收来筹集收入。
  • 预算赤字:政府支出大于收入;预算盈余:政府收入大于支出。

  • 税制的效率:它给纳税人带来的成本。除了资源从纳税人向政府的转移,还有两种成本:
    • 无谓损失
    • 纳税人遵从管理(填税表等)带来的负担

  • 平均税率:支付的总税收除以总收入;边际税率:增加1元收入所支付的税收(就是当前级别下的税率)
  • 理性人考虑边际量:边际税率衡量税制在多大程度上鼓励人们不工作;比如多工作几小时,边际税率决定了在多增加的收入中拿走多少税收。
  • 决定所得税无谓损失的是边际税率。

  • 如何平等:
    • 受益原则:人们应该根据他们从政府服务中得到的利益来纳税。比如汽油税用来修路,因为多买汽油的人可能多使用道路。
    • 支付能力原则:应该根据一个人所能承受的负担来对这个人征税。即所有公民都应该做出“平等的牺牲”。
      • 比例税、累退税、累进税。
  • 公司所得税的最终承担者是顾客和工人。倘若征税,那么公司拥有者就不愿意生产这类东西(生产别的去了),导致供给减少,部分工人失业,物品价格也提高。
  • 税收负担的分配与税单的分配并不相同
    • 税收负担是指税收对经济主体的经济负担,是一个经济学概念。税款的分配是指政府按照一定的税收制度对纳税人征收的税款,是一个税收制度的概念。
    • 在税收制度中,政府会对纳税人征收不同的税款,这些税款可能会对不同的人造成不同的经济负担。不同纳税人所承担的税收负担可能会有所不同,这与税款的分配并不完全相同。

13.生产成本

  • 企业目标是利润最大化,利润 = 总收益 - 总成本
  • 企业的成本有显性的和隐性的,显性是指表面上花费的,隐性是指为了做这件事情而放弃的
  • 随着投入量增加,典型企业的生产函数变得平坦,表现出边际产量递减的性质。
    • 随着产量增加,企业的总成本曲线变得更陡峭
  • 总成本可以分为固定成本和可变成本
  • 总成本的衡量有两种方式,平均总成本(包含固定成本和可变成本)和边际成本(产量增加一单位时总成本的增加量)
    • 画出平均总成本和边际成本的图形往往是有帮助的
    • 一般,边际成本随产量增加而增加,平均总成本随产量先下降后上升
    • 边际成本曲线总是与平均总成本曲线相交于平均总成本的最低点
  • 许多成本短期中是固定的,但在长期中是可变的。

14.竞争市场上的企业

竞争企业是价格接受者(受市场调控)

  • 平均收益:总收益除以产量;边际收益:每增加一单位销售量所引起的总收益变动量(一般等于物品的单价)
  • 为了使利润最大化,企业选择使边际收益等于边际成本的产量。由于竞争企业的边际收益等于市场价格,所以企业选择价格等于边际成本的产量。因此边际成本曲线又是其供给曲线。
  • 短期内,当企业不能回收固定成本时,如果物品价格小于平均可变成本(亏钱),应该选择暂时停止营业。
  • 长期内,当企业能回收固定成本时,如果物品价格小于平均总成本(固定成本 + 可变成本),应该选择退出市场。
  • 沉没成本:已经发生而且无法回收的成本。在做决策时,可以不考虑沉没成本。
  • 在可以自由进入与退出的市场,长期中利润为零。在长期均衡时,价格等于最低平均总成本,企业数量会自发调整,以满足在这种价格时的需求量。
    • 利润为零,填补了显性和隐性的成本。
  • 在短期中,需求增加引起价格上升并带来利润,需求减少引起价格下降并带来亏损。
  • 在长期中,如果企业可以自由进入和退出市场,那么企业数量会自发调整,使市场回到零利润。

15.垄断

垄断企业是价格决定者

垄断产生的三个基本原因:

  • 垄断资源:生产所需要的关键资源由单个企业所拥有
  • 政府管制:政府给予单个企业排他性地生产某种物品或服务的权力
  • 生产流程:某个企业能以低于大量企业的成本生产产品
    • 自然垄断:一个企业能以低于两个或更多企业的成本向整个市场供给一种物品或服务而产生的垄断

  • 垄断者增加销售数量时面临两种情况:
    • 产量效应:由于销量多,可能增加收益
    • 价格效应:价格下降了,可能减少收益
  • 垄断者面临向右下方倾斜的产品需求曲线,因为当其增加产量时,价格就会下降,减少了所有单位的产量赚到的收益量。因此垄断者的边际收益总是低于其物品的价格。
    • 竞争企业的产品需求曲线是平的,因为无论一个企业生产多少产品,面临的价格都是市场价格
    • 注意边际收益是增加一单位产品新增的收益,假如2个产量时各买了10元,3个产量时各买了9元,那么2->3的边际收益是7,而不是9(价格),因为增加产量使得本可以按更高价格卖出的产品按较低的价格卖出。
    • 需求曲线限制了垄断者通过市场势力获得超高利润的能力。
  • 利润最大化产量是:边际收益和边际成本曲线的交点,这与竞争企业相同(产量是边际收益 = 边际成本的时候)。
  • 垄断企业与竞争企业关键差别是:竞争市场上,价格等于边际成本(因为等于边际收益);垄断市场上,价格大于边际成本。

  • 垄断者没有供给曲线,因为其是价格的制定者,不是价格的接受者,垄断者在选择供给量的同时决定价格
  • 因此垄断者需要分析需求曲线,而不像竞争企业一样可以分析供给曲线来进行决策。

  • 边际成本曲线与需求曲线相交的位置是有效率的(最优产量)
  • 由于垄断者最优情况下,销售价格大于边际成本,一些对物品评价大于生产成本的消费者也不去购买了,因此垄断者生产的产量小于最优产量。(相当于边际成本曲线上移与需求曲线相交)
  • 这种情况下就产生了无谓损失,引起经济福利减少。

  • 垄断者可以通过买者的支付意愿对同一种物品收取不同的价格来增加利润。这种价格歧视的做法可以通过使一些本来不想购买的消费者购买物品从而增加经济福利。
  • 在完全价格歧视下,无谓损失完全消除了,所有的消费者都按支付意愿的价格购买,市场上所有的剩余都归垄断者所有。
  • 更一般的情况下,价格歧视不完全,可能会增加或减少福利。
  • 竞争市场上无法实行价格歧视,因为普通价格已经能销售了(或者说有需求),没人愿意降低价格;同时如果提高价格,就会丢失需求(去别家买)。

四种方式应对垄断:

  • 增强竞争(反对合并、垄断的形成)
  • 管制
  • 公有化
  • 不作为

16.垄断竞争

很多行业介于完全竞争和垄断的极端情况之间的某个位置,也称为不完全竞争。不完全竞争有两种类型:

  • 寡头:只有少数几个提供相似或相同产品的卖者的市场结构
  • 垄断竞争:存在许多出售相似但不相同产品的企业的市场结构

垄断竞争描述了具有以下特征的市场:

  • 许多卖者:争夺相同的顾客群体
  • 产品存在差别:每个企业生产的一种产品至少与其他企业生产的这种产品略有不同
    • 因此企业不是价格的接受者,面临一条向右下方倾斜的需求曲线
  • 自由进入和退出:企业可以无限制地进入和退出一个市场。因此市场上企业的数量要一直调整到经济利润为零时为止。
  • 与完全竞争的区别在于:每个卖者提供略有差别的产品

  • 短期内,垄断竞争企业和垄断企业相似:选择生产边际收益等于边际成本的产量,然后用需求曲线找出它可以销售的价格(高于边际成本)
  • 长期中,和竞争市场相似:由于企业的进入(盈利)和退出(亏损),企业最终变成零经济利润(价格等于平均总成本)

使平均总成本最小的产量称为企业的有效规模。在长期中,完全竞争企业在有效规模上生产,而垄断竞争企业的产量低于这一水平(因为价格加成,高于边际成本,使得需求会少,产量适应需求)。因此,垄断竞争企业生产能力过剩(可以增加生产,但放弃,因为增加就要降价)


  • 由于垄断竞争中固有的产品差别,使企业使用广告与品牌。
    • 广告操纵人们的爱好,许多广告是心理性的而非信息性的。夸大了产品的差别,减小竞争。
    • 同时,广告也向顾客提供某些信息、信任,使竞争更激烈。

17.寡头

寡头市场只有几个卖者,因此关键特征是:合作与利己之间的冲突

  • 寡头合作(卡特尔:联合起来行事的企业集团)则像一个垄断者,,获取高利润
  • 然而每个寡头只关心自己的利润,因此也有强大的激励作用使寡头之间难以维持合作

  • 纳什均衡:相互作用的经济主体在假定所有其他主体所选策略为既定的情况下选择他们自己最优策略的状态。
  • 在纳什均衡的状态下,寡头独立做出决策,结果是产量大于垄断(合作)的产量,价格低于垄断价格。
  • 市场上的寡头数量越多,价格和产量越接近于完全竞争状态下的水平。

囚徒困境表明,利己使人们即使在合作符合他们共同利益时也无法合作。

18.生产要素市场

  • 生产要素:用于生产物品与服务的投入,三种最重要的生产要素:劳动、土地、资本
  • 生产要素如销售物品一样,由供求力量支配(趋于平衡)
  • 竞争的、以利润最大化为目标的企业在某要素的边际产量值等于其价格这一点上使用该要素。(相当于供需交点)
  • 劳动的供给产生于个人在工作和闲暇间的权衡取舍。向右上方倾斜的劳动供给曲线意味着人们对工资上升的反应是做更多工作和少享受闲暇。

19.收入与歧视

  • 补偿性工资差别:由于非货币特性所引起的工资差别。比如有些工作轻松有趣,那么需求就多,工资就少。
  • 人力资本:对人投资的积累,比如教育,与一个特定的人相关联。
  • 教育、经验、工作特性影响收入,但能力(天赋)、努力、机遇同样以难以衡量的方式影响收入。
  • 有时候,教育本身不能提高工作能力,而是作为一种“信号”,来告知雇主自己有更高的学习能力(等)
  • 工资有时候会高于供求平衡的水平,产生的三个原因是:最低工资法、工会(号召罢工威胁雇主)、效率工资(企业为了提高工人的生产效率而支付更高的工资)

  • 收入的一些差别的产生因素是歧视,歧视的衡量很困难。
  • 竞争市场倾向于限制歧视对工资的影响。
    • 如果一个群体的工资由于与边际生产率无关的原因而低于另一个群体,那么非歧视企业将比歧视企业盈利更多
    • 因为非歧视企业可以用相对更低的工资雇佣被歧视的群体
    • 但如果顾客或政府乐意为歧视企业支付更多,那么歧视就会持续下去。

20.收入不平等与贫困

  • 世界上不平等的问题是十分严重的。
  • 衡量不平等出现的问题。
    • 实物转移支付:以物品和服务而不是现金形式给予穷人的转移支付。不平等衡量时没有考虑这些实物转移支付。
    • 经济生命周期:人的一生中收入总是在变动的,因此人们总以借款与储蓄来缓解这些变动。因此收入不平等不代表生活水平真正不平等。
    • 暂时收入与持久收入:暂时收入受变动更容易,购买能力主要取决于持久收入。
  • 经济流动性:“富人”与“穷人”的群体总是在不同的阶层上变动的。
  • 关于收入分配的观点:
    • 功利主义:使效用之和最大化
    • 自由主义:在“无知”下进行收入分配,使最小效用最大化(关注最不幸的人)。
    • 自由至上主义:不关注不平等
  • 政策
    • 最低工资法
    • 福利
    • 负所得税
    • 实物转移支付
  • 穷人往往面临很高的有效边际税率,高有效边际税率不鼓励贫困家庭依靠自己的力量脱贫
    • 因为脱贫后会瞬间丢失福利(或部分福利)

21.消费者选择理论

  • 预算约束:对消费者可以支付得起的消费组合的限制。预算约束曲线往往是向有下倾斜的直线
  • 无差异曲线:代表偏好,一条表示给消费者带来相同满足程度的消费组合的曲线
    • 向下倾斜、不相交、凸向原点、消费者往往倾向于更大的曲线(多消费)
    • 曲线上一点的斜率称为边际替代率,表示消费者愿意以一种物品交换另一种物品(而获得相同满足)的比率
    • 一个消费者有无数条不相交的无差异曲线,因为满足程度是无限的
  • 完全替代品:无差异曲线为直线的两种物品,意思是减少某一种东西,完全可以线性地由另一种东西替换。
  • 完全互补品:无差异曲线为直角的两种物品,意思是买了一种东西就必须要有另一种东西才行(左鞋子和右鞋子)
  • 最优化:预算约束线和无差异曲线相切的那一点。

  • 收入变动使得预算约束线向右上方平行地移动(斜率不变)
  • 正常物品:收入增加引起需求增加的物品;低档物品:收入增加引起需求减少的物品
  • 价格变动使得预算约束线斜率改变

一种物品价格变动对消费影响可以分解为:

  • 收入效应:价格变动使消费者移动到更高或耕地无差异曲线
  • 替代效应:价格变动使消费者沿着一条既定的无差异曲线变动到新边际替代率的一点
  • 在实际上,消费者可以会转移到新的无差异曲线上,但替代效应中的移动对阐述是有用的

通过收入、替代效应,可以解释为什么工资提高极可能增加也可能减少劳动供给量、为什么高利率既可能增加也可能减少储蓄。

单位时间工资提高,不同人由于偏好会做出不同的反应。工资反应了消费,现在有两种物品:消费和闲暇时间,本质上是消费和闲暇的权衡。

  • 由于收入效应,工资增加导致移动到更高的无差异曲线上,现在倾向于用这种福利享受更高的消费和更高的闲暇(闲暇变多了,劳动时间即供给变少了)
  • 由于替代效应,工资增加使得闲暇相对消费来说更昂贵了,倾向于在无差异曲线上移动到更多消费的点。换句话说这鼓励因为更高的工资而勤奋工作。

更高的利率(表示年老消费增加),现在两种物品是年老时消费和年轻时消费:

  • 由于收入效应,更高的利率导致移动到更高的无差异曲线上,倾向于同时享受更多的年轻时的消费(减少储蓄)和年老时的消费
  • 由于替代效应,年轻时消费相对年老时消费来说更昂贵了,就会倾向于年老时消费(增加储蓄)

22.微观经济学前沿

最后一章提出三个主题,分别是不对称信息经济学、政治经济学、行为经济学。

  • 不对称信息经济学:信息不对称会影响人们做出的决策
    • 道德风险:一个没有受到完全监督的人从事不诚实或不合意行为的倾向。(简单来说,就是上班摸鱼,因为老板不知道你在干什么)
      • 雇主可以:更好地监督、高工资(获取高工资的工人不太可能怠工,因为不想被抓住失去工作)、延期支付(工人怠工被抓后果严重)
    • 逆向选择:从无信息一方的角度看,无法观察到的特征组合变为不合意的倾向。(简单来说,我不知道你的物品的某些信息,我更倾向于判定你的物品是有问题的)
    • 发信号:有信息的一方向无信息的一方披露自己私人信息所采取的行动。(有信息的一方为了获取信任)
      • 信号成本是昂贵的,不能是免费的,否则任何人都可以使用
      • 比如学历就是一种信号
    • 筛选:无信息的一方采取的引起有信息的一方披露信息的行动。(比如去看车去看房)

  • 政治经济学:用经济学的分析方法研究政府。
    • 康多塞悖论:多数原则没有产生可传递的社会偏好。(这些原则没有表明社会真正想要什么结果)
    • 阿罗不可能性定理:它表明在某些假设条件之下,没有一种方案能把个人偏好加总为一组正当的社会偏好。
      • 无论社会在把其成员的偏好加总时采用哪一种投票方案,在某些方面都是有缺陷的。

  • 行为经济学:经济学家运用心理学来研究人类行为
    • 人们不总是理性的
      • 人们过分自信
      • 人们过分重视从现实生活中观察到的细枝末节
      • 人们不愿意改变自己的观念
    • 人们关注公正(特别是,对自己是公正的)
    • 人们是前后不一致的(特别是,人有自己的及时欲望)

简介

不是全面的面经,主要是针对我自己的一个查缺补漏,涉及的知识算比较深入的。

内容包括:C++、操作系统、计算机网络、Linux内核、少量数据库MySQL;以及业务上的一些问题,比如安全、高并发、缓存等。

学习补充

面阿里云时有电话录音记得比较清楚

阿里云云网络一面学习补充

免费ARP

ARP协议详解之Gratuitous ARP(免费ARP) - 大学霸 - 博客园 (cnblogs.com)

Gratuitous ARP也称为免费ARP,无故ARP。Gratuitous ARP不同于一般的ARP请求,它并非期待得到IP对应的MAC地址,而是当主机启动的时候,将发送一个Gratuitous arp请求,即请求自己的IP地址的MAC地址。免费ARP数据包是主机发送ARP查找自己的IP地址。

1.验证IP是否冲突

一个主机可以通过它来确定另一个主机是否设置了相同的IP地址。发送主机并不需要一定收到此请求的回答。如果收到一个回答,表示网络中存在与自身IP相同的主机。如果没有收到应答,则表示本机所使用的IP与网络中其它主机并不冲突。

2.更换物理网卡

如果发送ARP的主机正好改变了物理地址(如更换物理网卡),可以使用此方法通知网络中其它主机及时更新ARP缓存。其他主机接收到这个ARP请求的时候,发现自己的ARP高速缓存表中存在对应的IP地址,但是MAC地址不匹配,那么就需要利用接收的ARP请求来更新本地的ARP高速缓存表表项。

3.网关定期刷新

  这个主要是用在网关设备(如路由器)上。网关定期发布免费ARP,告诉网内所有主机自己的IP–MAC对应关系,让网内主机定期收到这个免费ARP请求,进而重置ARP老化时间等

网卡收发过程

网卡适配器收发数据帧流程 - 云物互联 - 博客园 (cnblogs.com)

收包

  1. 首先,内核在 RAM 中为收发数据建立一个环形的缓冲队列,通常叫 DMA 环形缓冲区,又叫 BD(Buffer descriptor)表。
  2. 内核将这个缓冲区通过 DMA 映射,把这个队列交给网卡;
  3. 网卡收到数据,就直接放进这个环形缓冲区,也就是直接放进 RAM 了;
  4. 然后,网卡驱动向系统产生一个中断,内核收到这个中断,就取消 DMA 映射,这样,内核就直接从主内存中读取数据;

在这里插入图片描述

如何将网卡收到的数据写入到内核内存?

在通信程序中,经常使用环形缓冲器作为数据结构来存放通信中发送和接收的数据。环形缓冲区是一个先进先出的循环缓冲区,可以向通信程序提供对缓冲区的互斥访问。

圆形缓冲区的一个有用特性是:当一个数据元素被用掉后,其余数据元素不需要移动其存储位置。相反,一个非圆形缓冲区(例如一个普通的队列)在用掉一个数据元素后,其余数据元素需要向前搬移。换句话说,圆形缓冲区适合实现先进先出缓冲区,而非圆形缓冲区适合后进先出缓冲区。

NIC在接收到数据包之后,首先需要将数据同步到内核中,这中间的桥梁是rx ring buffer((40条消息) 环形缓冲区(Ring Buffer)使用说明_爬坡的小蜗牛的博客-CSDN博客_环形buffer)。它是由NIC和驱动程序共享的一片区域,事实上,rx ring buffer存储的并不是实际的packet数据,而是一个描述符,这个描述符指向了它真正的存储地址,具体流程如下:

1、驱动在内存中分配一片缓冲区用来接收数据包,叫做sk_buffer;

2、将上述缓冲区的地址和大小(即接收描述符),加入到rx ring buffer。描述符中的缓冲区地址是DMA使用的物理地址;

3、驱动通知网卡有一个新的描述符;

4、网卡从rx ring buffer中取出描述符,从而获知缓冲区的地址和大小;

5、网卡收到新的数据包;

6、网卡将新数据包通过DMA直接写到sk_buffer中。

发包

  1. 用户态应用程序(应用层)可以通过系统调用接口访问 Socket 层,传递给 Socket 的数据首先会保存在 sk_buff (sk_buff结构体中的都是sk_buff的控制信息,是网络数据包的一些配置,真正储存数据的是sk_buff结构体中几个指针指向的数据区中)对应的缓冲区中
  2. 当数据被储存到了 sk_buff 缓存区中,网卡驱动的发送函数 hard_start_xmit 也随之被调用
  3. 通过DMA发送到网卡

详细:

  1. 网卡驱动创建 Tx descriptor ring,将 Tx descriptor ring 的总线地址写入网卡寄存器 TDBA。(rx和tx是两个环形缓冲,负责读写)
  2. 协议栈通过 dev_queue_xmit() 将 sk_buffer 下送到网卡驱动。
  3. 网卡驱动将 sk_buff 放入 Tx descriptor ring,更新网卡寄存器 TDT。
  4. 感知到 TDT 的改变后,网卡找到 Tx descriptor ring 中下一个将要使用的 descriptor。
  5. DMA 通过 PCI 总线将 descriptor 的数据缓存区复制到 Tx FIFO。
  6. 复制完后,通过 MAC 芯片将数据包发送出去。
  7. 发送完后,网卡更新网卡寄存器 TDH,启动硬中断通知 CPU 释放数据缓存区中的数据包。

在这里插入图片描述

网络发包收包全景图

vxlan里ARP是怎么转发的

简单来说就是包在vxlan标头里转发,A先广播,隧道端点知道要走隧道就转发出去,另一边解封后发现地址是广播地址就广播ARP,响应是单播的

(40条消息) 带你了解VXLAN网络中报文的转发机制_华为云开发者联盟的博客-CSDN博客_vxlan集中部署模式下,哪一类流量不经过核心转发

如图1-12所示,VM_A、VM_B和VM_C同属于10.1.1.0/24网段,且同属于VNI 5000。此时,VM_A想与VM_C进行通信。

由于是首次进行通信,VM_A上没有VM_C的MAC地址,所以会发送ARP广播报文请求VM_C的MAC地址。

图1-12 同子网VM互通组网图

img

下面就让我们根据ARP请求报文及ARP应答报文的转发流程,来看下MAC地址是如何进行学习的。

ARP请求报文转发流程
结合图1-13,我们来一起了解一下ARP请求报文的转发流程。

图1-13 ARP请求报文转发流程示意

img

VM_A发送源MAC为MAC_A、目的MAC为全F、源IP为IP_A、目的IP为IP_C的ARP广播报文,请求VM_C的MAC地址。

VTEP_1收到ARP请求后,根据二层子接口上的配置判断报文需要进入VXLAN隧道。确定了报文所属BD后,也就确定了报文所属的VNI。同时,VTEP_1学习MAC_A、VNI和报文入接口(Port_1,即二层子接口对应的物理接口)的对应关系,并记录在本地MAC表中。之后,VTEP_1会根据头端复制列表对报文进行复制,并分别进行封装。

可以看到,这里封装的外层源IP地址为本地VTEP(VTEP_1)的IP地址,外层目的IP地址为对端VTEP(VTEP_2和VTEP_3)的IP地址;外层源MAC地址为本地VTEP的MAC地址,而外层目的MAC地址为去往目的IP的网络中下一跳设备的MAC地址。封装后的报文,根据外层MAC和IP信息,在IP网络中进行传输,直至到达对端VTEP。

报文到达VTEP_2和VTEP_3后,VTEP对报文进行解封装,得到VM_A发送的原始报文。同时,VTEP_2和VTEP_3学习VM_A的MAC地址、VNI和远端VTEP的IP地址(IP_1)的对应关系,并记录在本地MAC表中。之后,VTEP_2和VTEP_3根据二层子接口上的配置对报文进行相应的处理并在对应的二层域内广播。
VM_B和VM_C接收到ARP请求后,比较报文中的目的IP地址是否为本机的IP地址。VM_B发现目的IP不是本机IP,故将报文丢弃;VM_C发现目的IP是本机IP,则对ARP请求做出应答。下面,让我们看下ARP应答报文是如何进行转发的。

ARP应答报文转发流程
结合图1-14,我们来一起了解一下ARP应答报文的转发流程。

图1-14 ARP应答报文转发流程示意

img

由于此时VM_C上已经学习到了VM_A的MAC地址,所以ARP应答报文为单播报文。报文源MAC为MAC_C,目的MAC为MAC_A,源IP为IP_C、目的IP为IP_A。
VTEP_3接收到VM_C发送的ARP应答报文后,识别报文所属的VNI(识别过程与步骤②类似)。同时,VTEP_3学习MAC_C、VNI和报文入接口(Port_3)的对应关系,并记录在本地MAC表中。之后,VTEP_3对报文进行封装。

可以看到,这里封装的外层源IP地址为本地VTEP(VTEP_3)的IP地址,外层目的IP地址为对端VTEP(VTEP_1)的IP地址;外层源MAC地址为本地VTEP的MAC地址,而外层目的MAC地址为去往目的IP的网络中下一跳设备的MAC地址。

封装后的报文,根据外层MAC和IP信息,在IP网络中进行传输,直至到达对端VTEP。

报文到达VTEP_1后,VTEP_1对报文进行解封装,得到VM_C发送的原始报文。同时,VTEP_1学习VM_C的MAC地址、VNI和远端VTEP的IP地址(IP_3)的对应关系,并记录在本地MAC表中。之后,VTEP_1将解封装后的报文发送给VM_A。
至此,VM_A和VM_C均已学习到了对方的MAC地址。之后,VM_A和VM_C将采用单播方式进行通信。

ARP广播抑制与代答

[VXLAN中ARP广播抑制 - CloudEngine 16800 V200R020C10 配置指南-VXLAN - 华为 (huawei.com)](https://support.huawei.com/enterprise/zh/doc/EDOC1100198462/c7801b0d#:~:text=VXLAN中ARP广播抑制 1 Server1发送ARP请求报文,请求目的主机Server2的MAC地址。 2 作为VXLAN二层网关的Device1收到ARP请求报文后,查询主机信息。 如果主机信息中有目的主机信息,Device1将ARP请求报文中的广播目的MAC地址和Target MAC地址替换为目的主机的MAC地址,并进行VXLAN封装后转发。 …,3 作为VXLAN二层网关的Device2收到封装后的ARP请求报文进行VXLAN解封装获取内层二层报文,判断报文的目的MAC是否为广播地址。 是,在对应的二层广播域内非VXLAN网络侧进行广播处理。 … 4 目的主机Server2收到单播ARP请求报文后,进行ARP应答。 5 Server1收到ARP应答报文建立ARP缓存表,并可以与Server2通信。)

为了抑制ARP广播请求报文给网络带来的广播风暴,可在VXLAN二层网关设备上使能广播抑制功能。

如图所示,VXLAN三层网关通过动态学习终端租户的ARP表项,再根据ARP表项生成主机信息(包括主机IP地址、MAC地址、VTEP地址和VNI ID),并将主机信息通过BGP EVPN对外发布,使其他的BGP邻居可以学习到主机信息。VXLAN二层网关学习到的主机信息用于广播抑制。

Server1初次访问Server2时,Server1会向Server2发送ARP广播请求报文,请求目的主机Server2的MAC地址,具体实现过程如下:

  1. Server1发送ARP请求报文,请求目的主机Server2的MAC地址。
  2. 作为VXLAN二层网关的Device1收到ARP请求报文后,查询主机信息
    • 如果主机信息中有目的主机信息,Device1将ARP请求报文中的广播目的MAC地址和Target MAC地址替换为目的主机的MAC地址,并进行VXLAN封装后转发。(广播抑制)
    • 如果主机信息中没有目的主机信息,ARP请求报文中的广播目的MAC地址不变,Device1进行VXLAN封装后转发。
  3. 作为VXLAN二层网关的Device2收到封装后的ARP请求报文进行VXLAN解封装获取内层二层报文,判断报文的目的MAC是否为广播地址。
    • 是,在对应的二层广播域内非VXLAN网络侧进行广播处理。
    • 不是,发送给对应的目的主机。
  4. 目的主机Server2收到单播ARP请求报文后,进行ARP应答。
  5. Server1收到ARP应答报文建立ARP缓存表,并可以与Server2通信。

img

代答

注意,广播抑制是用三层网关学习到的主机信息,代答是用隧道端点自己的ARP表。

当二层网关设备再收到ARP请求报文时,设备首先根据报文中的目的IP查找本地的ARP Snooping表项(包括本地侦听的和从其他网关同步的):

  • 如果查找成功,则用查找到的信息对ARP请求报文直接进行代答。
  • 如果查找失败,则按原有的流程处理该ARP请求报文。

在这里插入图片描述

阿里云云网络二面学习补充

  • 静态绑定和动态绑定,就是编译期确定(重载、模板)和运行期确定(虚函数)

  • vlan

    • 机数目较多时会导致冲突严重、广播泛滥、性能显著下降甚至造成网络不可用等问题。通过二层设备实现LAN互连虽然可以解决冲突严重的问题,但仍然不能隔离广播报文和提升网络质量。在这种情况下出现了VLAN技术。这种技术可以把一个LAN划分成多个逻辑的VLAN,每个VLAN是一个广播域,VLAN内的主机间通信就和在一个LAN内一样,而VLAN间则不能直接互通,广播报文就被限制在一个VLAN内。
    • 标签位置:在以太网帧的type字段。在以太网数据帧中加入4个字节的VLAN标签(又称VLAN Tag,简称Tag),用以标识VLAN信息。
  • 有了vlan为什么还需要vxlan:(40条消息) 为什么需要VXLAN ?和VLAN有什么区别 ?_阿苏呐的博客-CSDN博客

  • TOS

    • TOS是IPV4协议头部中的一个8bits的字段,ToS即为服务类型,只有当网络设备能够支持(能够识别IP首部中的ToS字段)识别ToS字段时,这给字段设置才有意义
    • 3bits优先级(貌似已废弃),即8个优先级,比如【关键】、【疾速】、【普通】等等,优先级对应的应用级别就像音视频、普通数据等
    • 4bits是服务类型,1000 – minimize delay 最小延迟、0100 – maximize throughput 最大吞吐量、0010 – maximize reliability 最高可靠性、0001 – minimize monetary cost 最小费用、0000 – normal service 一般服务
    • 1bit末尾,没有被使用,必须强制设置为0
  • IPV6没有ARP协议,用NDP协议(邻居发现协议)

  • 输出hello world内核具体是怎么执行的

    • 为了调用系统调用,产生中断,CPU保存上下文信息,从用户态切换到内核态,将堆栈切换到内核栈

    • 查找中断符号表,获取中断处理程序地址

    • 执行write()函数

    • 恢复被中断进程的CPU现场信息,返回被中断进程,继续运行

  • 中断

    • 中断是指由于接收到来自外围硬件(相对于中央处理器和内存)的异步信号或来自软件的同步信号,而进行相应的硬件/软件处理。发出这样的信号称为进行中断请求(interrupt request,IRQ)。

    • 硬件中断导致处理器通过一个上下文切换(context switch)来保存执行状态(以程序计数器和程序状态字等寄存器信息为主);软件中断则通常作为CPU指令集中的一个指令,以可编程的方式直接指示这种上下文切换,并将处理导向一段中断处理代码。

    • 硬件中断和软件中断是两种不同的中断方式。硬件中断是由外部设备向处理器发送信号来请求中断服务,而软件中断是由处理器内部执行指令来产生的。

      • 硬件中断的优点是可以实现异步通信,即外部设备不需要等待处理器的空闲时间,而是在需要时就发出请求。硬件中断的缺点是需要使用专门的硬件设备,如中断控制器,来识别和分配不同的中断源,并且可能会影响处理器的正常执行流程。
      • 软件中断的优点是可以实现同步通信,即处理器可以在合适的时机主动调用中断服务程序,而不受外部设备的干扰。软件中断的缺点是需要使用特定的指令来触发,并且不能屏蔽或忽略。
  • 对称加密与非对称加密通俗易懂的对称加密与非对称加密原理浅析 - 掘金 (juejin.cn)

    • 对称加密又叫做私钥加密,即信息的发送方和接收方使用同一个密钥去加密和解密数据。对称加密的特点是算法公开、加密和解密速度快,适合于对大数据量进行加密。
    • 非对称加密也叫做公钥加密。非对称加密与对称加密相比,其安全性更好。对称加密的通信双方使用相同的密钥,如果一方的密钥遭泄露,那么整个通信就会被破解。而非对称加密使用一对密钥,即公钥和私钥,且二者成对出现。私钥被自己保存,不能对外泄露。公钥指的是公共的密钥,任何人都可以获得该密钥。用公钥或私钥中的任何一个进行加密,用另一个进行解密。
    • 由于加密和解密使用了两个不同的密钥,这就是非对称加密“非对称”的原因。非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。
  • http与https

    • http是明文传输,报文给截了就泄密了
    • https在内容上是对称加密,在证书验证上是非对称加密,SSL就是证书,验证证书是验证有没有过期、机构对不对啥的
      • 从安全性来说,我们从加密过程可以很明显看出来非对称加密安全性要高很多,原因是如果采用对称加密,秘钥有可能在开始阶段由主动方发出时被黑客截获,后面任何加密信息将失去保密效果 ,而如果采用非对称加密,那么黑客即使截获了公钥,也没有任何作用,因为只有掌握在通讯双方手中的私钥才是揭开密文的唯一“钥匙”。而从性能的角度来说,由于非对称加密所涉及到的算法更加复杂,因此,非对称加密的性能会比对称加密差一些,因此,在选择加密方式的时候,我们不只是一味全盘使用非对称加密,而应该在不影响安全性的情况下,果断选择对称加密代替复杂的非对称加密,以此来提高性能。
      • 分析完两种加密方式的利弊,那么 HTTPS 究竟是怎么选择的呢? HTTPS 选择了两种混用,如图,由于 HTTPS 涉及到大量的接口、数据等非常频繁的操作行为,所以如果一概采用非对称加密的话,会严重影响 HTTPS 的性能,因此传输的数据的加密方式应该采用对称加密,而对称加密的秘钥在通讯两端同步的时候容易被截获,因此,HTTPS 采用非对称加密的方式来对该对称秘钥进行传输。通过这两种加密方式的结合,HTTPS 有效地避免性能损耗的同时,也让数据传输安全性得到了保障
批注 2022-06-19 213311.png 批注 2022-06-19 225410.png 批注 2022-06-19 225437.png

img

​ 简单说一下就是,服务器有证书,就是一对公钥和私钥。服务器把公钥给客户端,客户端先验证,验证通过后生成随机密钥密钥通过公钥加密,服务器用私钥密钥解密,这样双方就同步了密钥,接下来就是对称加密。

C++

C++ exception的底层原理

异常处理可以在调用跳级。这是一个代码编写时的问题:假设在有多个函数的调用栈中出现了某个错误,使用整型返回码要求你在每一级函数中都要进行处理。而使用异常处理的栈展开机制,只需要在一处进行处理就可以了,不需要每级函数都处理。

  • 栈展开:如果在一个函数内部抛出异常(throw),而此异常并未在该函数内部被捕捉(catch),就将导致该函数的运行在抛出异常处结束,所有已经分配在上的局部变量都要被释放。然后会接着向下线性的搜索函数调用栈,来寻找异常处理者,并且带有异常处理的函数(也就是有catch捕捉到)之前的所有实体(每级函数),都会从函数调用栈中删除。

函数中的局部变量、返回地址、寄存器中不够存储的参数等等都是存储在栈中的,每个函数中的这些信息组合起来称为一个栈帧(stack frame)。函数调用完后,栈指针将会指向上一个栈帧,而调用完的函数所在的栈帧将会消亡,其中所有的局部变量都失效,如果有指针指向其中的局部变量,对该指针的使用将会造成未定义的行为。

对于C++中的类,其中的数据成员类似结构体,成员函数类似普通的函数,唯一的不同就是需要传入this指针。哪怕是含有virtual函数的类,其结构中多了vptr虚表指针,这样的类也能一如常规的方式存放在栈帧里。

而存在异常处理的函数的栈帧与传统栈帧不同。它在栈帧中存在一个保存异常处理相关信息的结构体EXP,这个结构体是编译器生成的。假如存在如下的函数调用栈funA->funB->funC(a->b表示a调用b),由于EXP是链式存储的,而且异常捕获的原则是调用栈更近的catch块优先,因此funC.EXP.pre->funB.EXP(子函数的EXP指向调用它的函数的EXP)。EXP中有一个指针指向了一个处理异常相关的结构体EHDL,EHDL中保存了两个表:

  • tblUnwind,其中存放了栈展开过程中需要销毁的对象指针及其析构函数指针
  • tblTryBlocks,其中存放了try块的开始、结束位置,以及该try块对应的catch块表

捕获:当程序抛出异常后,首先在栈捕获表tblTryBlocks中,对其中每一个保存的try块信息,查看抛出异常的位置是否在try块的覆盖范围内。如果在,查看try块对应的catch块表,是否有匹配的catch块;如果不在,查看下一个try块;如果该栈帧的try块或catch块遍历完了还没有找到匹配的catch块,则说明该函数未能捕获异常,异常将交给调用它的函数来解决。因此当前函数后面的内容将不会得到执行,而且局部类变量将被析构,这时需要用到栈展开来析构类变量。

栈展开:栈展开表tblUnwind中,对其中保存的每个局部类对象(内置类型无需析构)通过保存的析构函数指针调用析构函数。这也是不能让异常逃离析构函数的原因,发生异常会进行栈展开,栈展开时会调用析构函数,如果这时候再遇到异常,异常处理的结构便会被破坏,程序将会终止。所有局部变量都被成功析构后,异常处理结构体利用指针指向上一个节点,处理上一个函数。

智能指针管理数组

有两种方式:shared_ptr和unique_ptr。

首先我们介绍一下两中方式的不同:

  • shared_ptr不支持下标访问,成员访问只能通过get获取指针后再去访问成员
  • shared_ptr定义的数组需要指定deleter,因为shared_ptr的默认deleter是删除管理的对象,但是,使用new[]进行分配内存时,需要用delete [] 而不是delete。
  • unique_ptr可以直接使用下标访问
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//share_ptr
void ArrayDeleter(TestClass *array) {
delete [] array;
}
int main()
{
const size_t size = 100;

//使用函数指定
std::shared_ptr<TestClass []> spFunc(new TestClass[size], ArrayDeleter);

//使用lambda表达式
std::shared_ptr<TestClass []> spLambda(new TestClass[size], [] (TestClass * tc) {delete [] tc;});
{
//使用默认删除
std::shared_ptr<TestClass []> spDefaultDeleter(new TestClass[size], std::default_delete<TestClass[]>());
(spDefaultDeleter.get())->b = 10;
}

//访问成员
//spFunc[0] = 10; //error
(spFunc.get())->b = 10;
(spFunc.get() + 1)->b = 20;
std::cout << "the first element: " << (spFunc.get())->b << "\n";
std::cout << "the second element: " << (spFunc.get() + 1)->b << "\n";

return 0;
}

//unique_ptr
int main()
{
const size_t size = 100;

std::unique_ptr<TestClass []> upFunc(new TestClass[size]);

//访问成员
//(upFunc.get())->b = 10; //error
//(upFunc.get() + 1)->b = 20; //error
upFunc[0].b = 10;
upFunc[1].b = 20;
std::cout << "the first element: " << upFunc[0].b << "\n";
std::cout << "the second element: " << upFunc[1].b << "\n";

return 0;
}

智能指针多线程安全

(40条消息) C++ 智能指针线程安全的问题_年年年年年的博客-CSDN博客_智能指针线程安全

多态

  • 运行期多态通过虚函数
  • 编译期多态通过函数重载和模板具现化

虚函数

虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

  • 类的虚表会被这个类的所有对象所共享。类的对象可以有很多,但是他们的虚表指针都指向同一个虚表(虚表里没有普通函数指针)

  • 如果是多重继承(一次继承多个基类),则子类有多个虚表指针,指向多个虚表,每个虚表都对应一个基类。

  • 如果父类不是虚函数而子类是虚函数(父实子虚),那么依然不体现出多态,子类的虚函数隐藏了父类实函数。

  • 模板函数不能是虚函数;因为每个包含虚函数的类具有一个virtual table,包含该类的所有虚函数的地址,因此vtable的大小是确定的。模板只有被使用时才会被实例化,将其声明为虚函数会使vtable的大小不确定(函数模板可能用到,可能用不到)。所以,成员函数模板不能为虚函数。

    • 编译器在编译一个类的时候,需要确定这个类的虚函数表的大小。一般来说,如果一个类有N个虚函数,它的虚函数表的大小就是N,如果按字节算的话那么就是4*N。 如果允许一个成员模板函数为虚函数的话,因为我们可以为该成员模板函数实例化出很多不同的版本,也就是可以实例化出很多不同版本的虚函数
    • 那么编译器为了确定类的虚函数表的大小,就必须要知道我们一共为该成员模板函数实例化了多少个不同版本的虚函数。显然编译器需要查找所有的代码文件,才能够知道到底有几个虚函数,这对于多文件的项目来说,代价是非常高的,所以才规定成员模板函数不能够为虚函数。
  • 静态成员函数不能是virtual的,因为静态成员函数属于类而非单个具体对象,所有的对象共享一份代码,没有实现多态的必要。编译会出错

    • 静态函数是没有this指针的!没有this指针就会影响到虚函数的VTABLE机制:vptr指针是在类的构造函数的创建中产生的,并且只能通过this指针来访问的!通过this指针vptr会指向保存虚函数地址的VTABLE。而static函数它没有this指针,所以virtual也无法工作
    • 静态成员是编译期绑定的
  • inline成员函数可以声明为virtual,但是在编译时不会实际将代码直接在调用处展开。运行期绑定

  • 友元函数也不能声明为virtual,因为友元关系是不能被继承的,编译会出错。

虚函数表剖析

C++ 虚函数表剖析 - 知乎 (zhihu.com)

img

只要抓住“对象的虚表指针用来指向自己所属类的虚表虚表中的指针指向其继承的最近的一个类的虚函数”这个特点,便可以快速将这几个类的对象模型在自己的脑海中描绘出来。

重载、覆盖、隐藏(overload、override、overwrite)

重载很简单,就是同一个作用域下,对不同参数列表同名函数的访问。覆盖和隐藏涉及到虚函数和继承。

覆盖通过虚函数表现出多态性质,要求函数签名一致,特征是:

  • 函数名字与参数都相同
  • 父类的函数是虚函数(virtual)
  • 函数的const属性必须一致。
  • 函数的返回类型必须相同或协变
  • 虚函数不能是模板函数。
    • 协变(covariant),如果它保持了子类型序关系≦。该序关系是:子类型≦基类型。
    • 比如基类:virtual Base* op(),子类virtual Derive* op()。注意只能通过指针完成协变。
    • 逆变就是相反,不变就是二者都不满足。

隐藏指的是子类隐藏了父类的函数的作用域(内层作用域的名称会遮掩外层),两种情况:

  • 子类函数与父类函数的名称相同,是虚函数参数不同、返回类型不同,父类函数被隐藏
  • 子类函数与父类函数的名称相同,参数也相同,但是父类函数没有virtual,父类函数被隐藏

隐藏不表现出多态:

  • 当没有virtual时,显然没有多态,这时以指针的类型静态绑定函数,子类指针只在子类里查找同名函数,找不到父类的;父类同理,指向子类的父类指针只能查找父类函数。
  • 当有virtual时,因为参数不同,就和非虚的同名函数一样,仅仅是隐藏关系,没有虚特性,以指针的类型静态绑定函数。

要调用被隐藏的函数,必须显式给出父类名的作用域,比如using P::func()。子类覆盖了父类的一个函数,会把父类中重载的其他函数都隐藏。因为编译器找一个名称,会先从当前作用域(子类)找,找不到再找外层作用域(父类)。如果当前作用域能找到,就完全不看外层作用域了,因此外层重载的函数会被隐藏,编译器完全看不到只能报错。

static & const

不可以同时用const和static修饰成员函数

C++编译器在实现const的成员函数的时候为了确保该函数不能修改类的实例的状态,会在函数中添加一个隐式的参数const this*。但当一个成员为static的时候,该函数是没有this指针的。也就是说此时const的用法和static是冲突的。

#include<file.h> #include “file.h” 的区别

前者是从标准库路径寻找,后者是从当前工作路径

上行转换和下行转换

上行转换大致意思是把子类实例指针向上转换为父类型, 下行转换是把父类实例指针转换为子类实例

通常子类因为继承关系会包含父类的所有属性, 但是有些子类的属性父类没有,所以上行转换的时候,子类实例转换给父类是安全的, 转换后的指针或者对象可以放心使用父类的所有方法或者属性。但是下行转换的时候可能是不安全的, 因为假如子类有父类没有的属性或者方法的话, 父类指针或者实例转换为子类型后,转换后的实例中并没有子类多出来的方法或属性, 当调用到这些方法或属性时程序就会崩溃了

  • static_cast :编译时期的静态类型检查static_cast静态转换相当于C语言中的强制转换,但不能实现普通指针数据(空指针除外)的强制转换,一般用于父类和子类指针、引用间的相互转换。没有运行时类型检查来保证转换的安全性
  • 将一个基类对象指针(或引用)cast到继承类指针,dynamic_cast根据基类指针是否真正指向继承类指针来做相应处理, 即会作出一定的判断。

比如一个父类指针指向子类实例对象,那么下行转换(把父类指针转换成子类指针)是安全的;但如果父类指针指向父类对象,那么下行转换就不安全了,因为子类指针调用的子类成员可能是父类对象没有的。

在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。

const 和 define 的区别

  1. const 生效于编译阶段,而 define 生效于预处理阶段
  2. define只是简单的字符串替换,没有类型检查,而 const 有对应的数据类型,编译器要进行判断的,可以避免一些低级的错误;
  3. 用 define 定义的常量是不可以用指针变量去指向的,用 const 定义的常量是可以用指针去指向该常量的地址的;
  4. define 不分配内存,给出的是立即数,有多少次使用就进行多少次替换,在内存中会有多个拷贝,消耗内存大,const 在静态存储区中分配空间,在程序运行过程中内存中只有一个拷贝;
  5. 可以对 const 常量进行调试,但是不能对宏常量进行调试。

const和define的内存分配问题

const定义的只读变量在程序运行过程中只有一份拷贝(因为它是全局的只读变量,存放在静态区),而#define定义的宏常量在内存中有若干个拷贝。

const节省了空间,避免了不必要的内存分配,同时提高了效率。编译器通常不为普通的const只读变量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的值,没有了存储与读内存的操作,使得它的效率也很高。C++中是不太推荐用宏的,尽量少用。因为C++是强类型的语言,希望通过类型检查来降低程序中的很多错误,而宏只是在编译期前做简单替换,绕过了类型检查,失去了强类型系统的优势支撑。

1
2
3
4
5
6
7
8
9
10
11
12
13
#define M 3   //宏常量

const int N=5; // 此时并未将N放入内存中

..............

int i=N; //此时为N分配内存,以后不再分配!

int I=M; //预编译期间进行宏替换,分配内存

int j=N; //没有内存分配

int J=M; //再进行宏替换,又一次分配内存!

原因:const定义的只读变量从汇编的角度来看,只是给出了对应的内存地址,而不是像#define一样给出的是立即数(占用代码段的内存),所以const定义的只读变量在程序运行过程中只有一份拷贝。

extern 的作用,extern变量在哪个数据段

extern 变量表示声明一个变量,表示该变量是一个外部变量,也就是全局变量,所以 extern 修饰的变量保存在静态存储区(全局区),全局变量如果没有显式初始化,会默认初始化为 0,或者显式初始化为 0 ,则保存在程序的 BSS 段,如果初始化不为 0 则保存在程序的 DATA 段。

share_ptr循环引用(内存泄漏)

share_ptr循环引用产生原因及其解决方案_路人甲同学的博客-CSDN博客_shared_ptr循环引用

在这里插入图片描述

根据代码执行顺序,share_ptr指针指向new创建的一个Person对象,也就是图中栈空间的person指针指向了堆空间的Person对象,引用计数为1,同理,car指针也指向了堆空间的Car对象,引用计数亦为1。

接下来,Person对象里的成员m_car指向Car对象,Car对象的引用计数加1后为2,Car对象的m_person也指向Person对象,Person对象引用计数也加1为2。

若此时代码执行结束,栈空间上的car指针先进行释放,Car对象的引用计数减1后为1,后释放person指针,Person对象的引用计数也减为1。由于Person对象和Car对象都是建立再堆空间上,两者相互依赖,都在等待对方释放。

可以看到,这个例子中,堆空间里的 Person对象 与 Car对象互相使用着,导致双方的 shared_ptr 强引用数量不会为0,所以不会自动释放内存,产生了内存泄漏。


循环引用的解决方案是使用 weak_ptr。

在这里插入图片描述

根据之前的分析可知,前三句代码执行完后,Person对象的引用计数为1,Car对象的引用计数为2。而第四条语句car->m_person = person执行的便是途中虚线弱引用的语句,不增加Person对象的引用计数。因此,Person对象的引用计数为1,Car对象的引用计数为2。

若此时代码执行结束,栈空间上的car指针先进行释放,Car对象的引用计数减1为1,后释放person指针,Person对象的引用计数减1后为0,Person对象释放内存空间,因此m_car成员函数也得到释放,Car对象引用计数减1后为0,Car对象也得到释放。因此不会产生内存泄漏。

库文件(静态库与动态库)

  • 什么是库?库文件是一种目标文件,静态库是可重定位目标文件,动态库是共享目标文件

    • 头文件是在预处理时使用;库文件是链接时使用。
    • 头文件内容还是高级语言内容;库文件是二进制文件
  • 库文件和二进制文件的区别:库文件也是二进制文件,二进制文件包括可执行文件目标文件。可执行文件是可以直接运行的程序,目标文件是编译器生成的中间代码,需要链接器将它们合并成可执行文件,这里的目标文件也就是库文件。

  • 静态库与动态库的区别

    • 1、静态库的扩展名一般为“.a”或“.lib”;动态库的扩展名一般为“.so”或“.dll”。(前面是linux,后面是windows)
    • 2、静态库在编译时会直接整合到目标程序中,编译成功的可执行文件可独立运行;动态库在编译时不会放到连接的目标程序中,即可执行文件无法单独运行。
    • 静态库和动态库最本质的区别就是:该库是否被编译进目标(程序)内部
  • 静态库

    • 优点:
      • ①静态库被打包到应用程序中加载速度快
      • ②发布程序无需提供静态库,移植方便(因为编译成可运行程序了)
    • 缺点:
      • ①相同的库文件数据可能在内存中被加载多份,消耗系统资源,浪费内存
      • ②库文件更新需要重新编译项目文件,生成新的可执行程序,浪费时间。
  • 动态库

    • 优点:
      • ①可实现不同进程间的资源共享
      • ②动态库升级简单,只需要替换库文件,无需重新编译应用程序
      • ③可以控制何时加载动态库,不调用库函数动态库不会被加载
    • 缺点:
      • ①加载速度比静态库
      • ②发布程序需要提供依赖的动态库

可变参数模板

C++ 泛型编程(一) —— 可变参数模板 - 简书 (jianshu.com)

指针和引用的区别

  1. 定义和性质不同。指针是一种数据类型,用于保存地址类型的数据,而引用可以看成是变量的别名。指针定义格式为:数据类型 *;而引用的定义格式为:数据类型 &;
  2. 引用不可以为空,当被创建的时候必须初始化,而指针变量可以是空值,在任何时候初始化;
  3. 指针可以有多级,但引用只能是一级;
  4. 引用使用时无需解引用(*),指针需要解引用;
  5. 指针变量的值可以是 NULL,而引用的值不可以为 NULL;
  6. 指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了;
  7. sizeof 引用得到的是所指向的变量(对象)的大小,而 sizeof 指针得到的是指针变量本身的大小;
  8. 指针作为函数参数传递时传递的是指针变量的值,而引用作为函数参数传递时传递的是实参本身,而不是拷贝副本;
  9. 指针和引用进行++运算意义不一样。

C++ 和 C 中 struct 的区别

  1. C 的结构体不允许有函数存在,C++ 的结构体允许有内部成员函数,并且允许该函数是虚函数
  2. C 的结构体内部成员不能加权限,默认是 public,而 C++ 的结构体内部成员权限可以是 public、protected、private,默认 public
  3. C 的结构体是不可以继承,C++ 的结构体可以从其它的结构体或者类继承
  4. C 中的结构体不能直接初始化数据成员(因为不能有函数所以也没有构造函数),C++ 中可以
  5. C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名后直接使用,而 C++ 中使用结构体可以省略 struct 关键字直接使用 struct

struct和class 的区别

  1. struct 一般用于描述一个数据结构集合,而 class 是对一个对象数据的封装
  2. struct 中默认访问控制权限是 public,而 class 中默认的访问控制权限是 private struct A { int iNum; // 默认访问控制权限是 public } class B { int iNum; // 默认访问控制权限是 private }
  3. 在继承关系中,struct 默认是公有继承,而 class 是私有继承

sizeof 原理

sizeof 是在编译的时候,查找符号表判断类型,然后根据基础类型来取值。如果 sizeof 运算符的参数是一个不定长数组,则该需要在运行时计算数组长度

对指针变量进行 sizeof 运算,获得的是指针变量的大小,而无论是什么类型的指针,在同一平台下结果都是一样的。在 32 位平台下是 4 个字节,在 64 位平台下是 8 个字节。

volatile可以和 const 同时使用吗

volatile 限定符是用来告诉计算机,所修饰的变量的值随时都会进行修改的。用于防止编译器对该代码进行优化。通俗的讲就是编译器在用到这个变量时必须每次都小心地从内存中重新读取这个变量的值,而不是使用保存在寄存器里的备份。 const 和 volatile 可以一起使用,volatile 的含义是防止编译器对该代码进行优化,这个值可能变掉的。而 const 的含义是在代码中不能对该变量进行修改。因此,它们本来就不是矛盾的。

const只在编译期间保证常量被使用时的不变性,无法保证运行期间的行为。程序员直接修改常量会得到一个编译错误,但是使用间接指针修改内存,只要符合语法则不会得到任何错误和警告。因为编译器无法得知你是有意还是无意的修改,但是既然定义成const,那么程序员就不应当修改它,不然直接使用变量定义好了。

  • const全局变量:此时该常量是存放在.rodata段的—Read Only Data也就是常量区,是无法通过取地址方式去修改的,修改内容会报段错误
  • const局部变量:
    • c++中 对于基础类型(整数,浮点数,字符) 系统不会给const变量开辟空间 ,会将其放到符号表中;
    • c++中当 对const变量取地址的时候 系统就会给它开辟空间(栈);
    • 当用变量给const变量赋值时,系统直接为其开辟空间 而不会把它放入符号表中,这里的赋值是通过指针取地址赋值
    • const 自定义数据类型(结构体、对象) 和数组系统会分配空间;

typdef和define区别

#define是预处理命令,在预处理是执行简单的替换,不做正确性的检查

typedef是在编译时处理的,它是在自己的作用域内给已经存在的类型一个别名 typedef (int*) pINT; #define pINT2 int* 效果相同?实则不同!实践中见差别:pINT a,b;的效果同int *a; int *b;表示定义了两个整型指针变量。而pINT2 a,b;的效果同int *a, b;表示定义了一个整型指针变量a和整型变量b。

引用作为函数参数以及返回值

对比值传递,引用传参的好处:

  1. 在函数内部可以对此参数进行修改
  2. 提高函数调用和运行的效率(所以没有了传值和生成副本的时间和空间消耗)
  3. 用引用作为返回值最大的好处就是在内存中不产生被返回值的副本

有以下的限制:

  1. 不能返回局部变量的引用。因为函数返回以后局部变量就会被销毁
  2. 不能返回函数内部new分配的内存的引用。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一 个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memory leak
  3. 可以返回类成员的引用,但是最好是const。因为如果其他对象可以获得该属性的非常量的引用,那么对该属性的单纯赋值就会破坏业务规则的完整性。

栈溢出的原因以及解决方法

栈溢出是指函数中的局部变量造成的溢出(注:函数中形参和函数中的局部变量存放在栈上) 栈的大小通常是1M-2M,所以栈溢出包含两种情况,一是分配的的大小超过栈的最大值,二是分配的大小没有超过最大值,但是接收的buf比原buf小。

  1. 函数调用层次过深,每调用一次,函数的参数、局部变量等信息就压一次栈
  2. 局部变量体积太大。

解决办法大致说来也有两种:

  1. 增加栈内存的数目;如果是不超过栈大小但是分配值小的,就增大分配的大小
  2. 使用堆内存;具体实现由很多种方法可以直接把数组定义改成指针,然后动态申请内存;也可以把局部变量变成全局变量,一个偷懒的办法是直接在定义前边加个static,呵呵,直接变成静态变量(实质就是全局变量)

ifndef和program once

相同点: 它们的作用是防止头文件被重复包含。

不同点:

  1. ifndef 由语言本身提供支持,但是 program once 一般由编译器提供支持,也就是说,有可能出现编译器不支持的情况(主要是比较老的编译器)。
  2. 通常运行速度上 ifndef 一般慢于 program once,特别是在大型项目上, 区别会比较明显,所以越来越多的编译器开始支持 program once。
  3. ifndef 作用于某一段被包含(define 和 endif 之间)的代码, 而 program once 则是针对包含该语句的文件, 这也是为什么 program once 速度更快的原因。

指针数组和数组指针

数组指针,是指向数组的指针,而指针数组则是指该数组的元素均为指针。

数组指针,是指向数组的指针,其本质为指针,形式如下。如 int (*p)[n],p即为指向数组的指针,()优先级高,首先说明p是一个指针,指向一个整型的一维数组,这个一维数组的长度是n,也可以说是p的步长。也就是说执行p+1时,p要跨过n个整型数据的长度。数组指针是指向数组首元素的地址的指针,其本质为指针,可以看成是二级指针,一般用作二维数组。

1
类型名 (*数组标识符)[数组长度]

指针数组,在C语言和C++中,数组元素全为指针的数组称为指针数组,其中一维指针数组的定义形式如下。指针数组中每一个元素均为指针,其本质为数组。如 int *p[n], []优先级高,先与p结合成为一个数组,再由int*说明这是一个整型指针数组,它有n个指针类型的数组元素。这里执行p+1时,则p指向下一个数组元素,这样赋值是错误的:p=a;因为p是个不可知的表示,只存在p[0]、p[1]、p[2]…p[n-1],而且它们分别是指针变量可以用来存放变量地址。但可以这样 *p=a; 这里*p表示指针数组第一个元素的值,a的首地址的值。

1
类型名 *数组标识符[数组长度]

C++是不是类型安全

不是。两个不同类型的指针之间可以强制转换

全局变量和局部变量

生命周期不同: 全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;

使用方式不同: 通过声明后全局变量程序的各个部分都可以用到;局部变量只能在局部使用;分配在栈区。

内存分配位置不同: 全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。

操作系统和编译器如何识别

操作系统和编译器通过内存分配的位置来知道的,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。

c++内存分配

内存区域:栈、堆、全局区、常量区、代码区

  • 栈:系统自动分配的空间,只要不特殊声明,就定义在栈区,函数的区域也在栈上。栈是向下增长的。(const 局部变量在栈里)
  • 文件映射区(mmap()系统调用,让内核创建一个新的虚拟存储区域【匿名文件】。另一个作用是把文件内容【普通文件】映射到进程的虚拟内存空间, 通过对这段内存的读取和修改,来实现对文件的读取和修改。这里面没有像read和write那样拷贝,虚拟内存空间映射了内核空间)
  • 堆:使用动态内存分配的方式可以申请堆空间,用完要手动释放。
  • 全局区:全局变量、静态变量(static)
  • 常量区:代码中的数字,字符等常量,例如’a’,—1.2等
  • 代码区:存放可执行代码,避免频繁的读硬盘。

其中全局区和常量区和代码区又分为

  • Bss: 未初始化的全局变量,不占用可执行文件的大小。大多数操作系统,在加载程序时,会把所有的bss全局变量全部清零,无需要你手工去清零。
  • Data:数据段,要放在可执行文件中的数据,包括堆、栈、以初始化的全局变量
  • Rodata:只读数据段,存放常量,字符常量,const常量。常量不一定就放在rodata里,有的立即数直接编码在指令里,存放在代码段(.text)中。
  • Text: 只读区域,包括常量区和代码区

new和malloc

malloc 底层是brk()和mmap(),当小于128k时用brk(),会维护一个内存池,复用小块内存。大于128k用mmap(),在文件映射区开辟空间。

new操作针对数据类型的处理,分为两种情况:

  1. 简单数据类型(包括基本数据类型和不需要构造函数的类型) 简单类型直接调用 operator new 分配内存; 可以通过new_handler 来处理 new 失败的情况; new 分配失败的时候不像 malloc 那样返回 NULL,它直接抛出异常(bad_alloc)。要判断是否分配成功应该用异常捕获的机制;
  2. 复杂数据类型(需要由构造函数初始化对象) new 复杂数据类型的时候先调用operator new,然后在分配的内存上调用构造函数。

delete也分为两种情况:

  1. 简单数据类型(包括基本数据类型和不需要析构函数的类型) delete简单数据类型默认只是调用free函数。
  2. 复杂数据类型(需要由析构函数销毁对象) delete复杂数据类型先调用析构函数再调用operator delete。

与 malloc 和 free 的区别:

  1. 属性上:new / delete 是c++关键字,需要编译器支持。 malloc/free是库函数,需要c的头文件支持。
  2. 参数:使用new操作符申请内存分配时无须制定内存块的大小,编译器会根据类型信息自行计算。而mallco则需要显式地指出所需内存的尺寸。
  3. 返回类型:new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,故new是符合类型安全性的操作符。而malloc内存成功分配返回的是void *,需要通过类型转换将其转换为我们需要的类型。
  4. 分配失败时:new内存分配失败时抛出bad_alloc异常;malloc分配内存失败时返回 NULL。
  5. 自定义类型:new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。 malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
  6. 重载:C++允许重载 new/delete 操作符。而malloc为库函数不允许重载。
  7. 内存区域:new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。其中自由存储区为:C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。自由存储区不等于堆,如上所述,布局new就可以不位于堆中。

既然有了malloc/free,C++中为什么还需要new/delete呢? 运算符是语言自身的特性,有固定的语义,编译器知道意味着什么,由编译器解释语义,生成相应的代码。库函数是依赖于库的,一定程度上独立于语言的。编译器不关心库函数的作用,只保证编译,调用函数参数和返回值符合语法,生成call函数的代码。 对于非内部数据类型而言,光用malloc/free无法满足动态对象都要求。new/delete是运算符,编译器保证调用构造和析构函数对对象进行初始化/析构。但是库函数malloc/free是库函数,不会执行构造/析构。

构造函数和析构函数的执行顺序

构造函数:

  1. 首先调用父类的构造函数;
  2. 调用成员变量的构造函数;
  3. 调用类自身的构造函数。

析构函数 对于栈对象或者全局对象,调用顺序与构造函数的调用顺序刚好相反,也即后构造的先析构。对于堆对象,析构顺序与delete的顺序相关。

static关键字的作用

  • 修饰局部变量:static修饰局部变量时,使得被修饰的变量成为静态变量,存储在静态区。存储在静态区的数据生命周期与程序相同,在main函数之前初始化,在程序退出时销毁。(无论是局部静态还是全局静态)

  • 修饰全局变量:全局变量本来就存储在静态区,因此static并不能改变其存储位置。但是,static限制了其链接属性。被static修饰的全局变量只能被该包含该定义的文件访问(即改变了作用域)。

  • 修饰函数:static修饰函数使得函数只能在包含该函数定义的文件中被调用。对于静态函数,声明和定义需要放在同一个文件中,因为编译器看到声明就会强制在该文件中找定义。

  • 修饰成员变量:用static修饰类的数据成员使其成为类的全局变量,会被类的所有对象共享,包括派生类的对象,所有的对象都只维持同一个实例。 因此,static成员必须在类外进行初始化(初始化格式:int base::var=10;),而不能在构造函数内进行初始化,不过也可以用const修饰static数据成员在类内初始化。

  • 修饰成员函数:用static修饰成员函数,使这个类只存在这一份函数,所有对象共享该函数,不含this指针,因而只能访问类的static成员变量。静态成员是可以独立访问的,也就是说,无须创建任何对象实例就可以访问。例如可以封装某些算法,比如数学函数,如ln,sin,tan等等,这些函数本就没必要属于任何一个对象,所以从类上调用感觉更好,比如定义一个数学函数类Math,调用Math::sin(3.14);还可以实现某些特殊的设计模式:如Singleton;

程序编译的过程

编译的全部过程可以分为四个阶段,分别是预处理、编译、汇编和链接。

  • 预处理阶段会处理源代码中的宏定义、文件包含、条件编译等指令,并删除注释和空白字符,生成.i文件。
  • 编译阶段会对.i文件进行语法分析和优化,生成汇编代码,即.s文件。
  • 汇编阶段会将.s文件转换为机器语言或指令,生成.o文件。
  • 链接阶段会将.o文件与库文件等链接起来,生成可执行文件。

new/delete线程安全吗

C++语义中的new/delete两个关键字封装了malloc/free的。这些函数都是经过编译器向操作系统进行申请内存的,除非是编译器的设计问题,否则应该是安全的。

new/delete、malloc/free都是线程安全的

访问private变量

类只有int值,为private,无public方法,给一个实例化的对象,有没有什么办法访问or修改

  • 如果不能用公有方法,那么可以尝试使用友元函数或友元类,它们可以访问类的所有成员,包括私有的。
    • 友元函数不是类的成员函数,它只是被类声明为可以访问其私有成员的函数。
    • 友元函数可以放在类中的任何位置,包括public、private或protected区域,但这并不影响它们的访问权限。
    • 友元函数可以被任何其他函数调用,而不需要通过类的对象或指针。
    • 友元函数不算作public方法,它是一种特殊的非成员函数。
  • 如果给了一个实例化对象:可以使用指针或引用强制转换类型,绕过编译器的检查。但这种方法很危险,可能会破坏类的数据结构和逻辑。

使用友元函数的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;

class A {
int private_var;
public:
A(){private_var = 0;} //初始化为零
friend void access_private(A* a); //声明友元函数
};

void access_private(A* a) {
cout << "Private var is: " << a->private_var << endl; //访问私有成员
a->private_var = 10; //修改私有成员
}

int main() {
A a;
access_private(&a); //调用友元函数
}

使用指针的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

class mydata {
private:
int x;
public:
mydata (int no) { x=no; }
};

int main() {
mydata obj(5);

int *ptr = (int*)&obj; //创建一个指向对象的指针
cout << "Value of x is: " << *ptr << endl; //访问私有成员
*ptr = 10; //修改私有成员
}

C++11里面的list的size获取时间复杂度

C++11里面的list的size获取时间复杂度是常数。这是因为C++11规定了所有标准容器的size成员函数都必须是常数时间复杂度。

只有std::forward_list,也就是单向链表,没有提供size成员函数。它的size只能用线性时间来计算

  • std::forward_list没有size成员函数的原因是为了效率。std::forward_list是一种单向链表,它的设计目标是尽可能地节省时间和空间。
  • 为了保持单向链表的效率和简洁性,设计者没有给它提供size变量。

C++11之前的版本,list的size获取时间复杂度是线性。这是因为C++11之前的版本没有规定size成员函数的时间复杂度,所以不同的编译器可能有不同的实现。

  • GCC 4.x系列的编译器就是一个例子,它使用了一个计数器来记录list的大小,但这样会影响splice操作的常数时间复杂度。
    • list::splice实现list拼接的功能。将源list的内容部分或全部元素删除,拼插入到目的list。
    • splice操作是一种在list容器中转移元素的操作。它不会复制或移动元素,只会改变内部指针的指向。如果list容器使用了一个计数器来记录大小,那么在执行splice操作时,就需要更新两个容器的计数器值。这样就会增加splice操作的时间复杂度,从原来的常数时间变成线性时间
    • 这是一个权衡的问题,在C++11之前没有统一的标准,在C++11之后才规定了size和splice都必须是常数时间复杂度。
  • GCC 5.0系列才开始支持C++11规定的常数时间复杂度。

RTTI

【C++】RTTI有什么用?怎么用? - 知乎 (zhihu.com)

RTTI是运行阶段类型识别(Runtime Type Identification)的简称。

这是新添加到C++中的特性之一,很多老式实现不支持。另一些实现可能包含开关RTTI的编译器设置。

假设有一个类层次结构,其中的类都是从一个基类派生而来的,则可以让基类指针指向其中任何一个类的对象。

有时候我们会想要知道指针具体指向的是哪个类的对象。因为:

  • 可能希望调用类方法的正确版本,而有时候派生对象可能包含不是继承而来的方法,此时,只有某些类的对象可以使用这种方法。
  • 也可能是出于调试目的,想跟踪生成的对象的类型。

在C++ 环境中﹐头文件(header file) 含有类之定义(class definition)亦即包含有关类的结构资料(representational information)。但是,这些资料只供编译器(compiler)使用,编译完毕后并未留下来,所以在执行时期(at run-time),无法得知对象的类资料。包括类名称、数据成员名称与类型、函数名称与类型等等。

例如,两个类Figure和Circle,其之间为继承关系。 若有如下指令﹕

1
2
3
Figure *p; 
p = new Circle();
Figure &q = *p;

在执行时﹐p指向一个对象﹐但欲得知此对象之类资料﹐就有困难了。同样欲得知q 所参考(reference) 对象的类资料﹐也无法得到。

RTTI(Run-Time Type Identification)就是要解决这困难﹐也就是在执行时﹐您想知道指针所指到或参考到的对象类型时﹐该对象有能力来告诉您。随着应用场合之不同﹐所需支持的RTTI范围也不同。最单纯的RTTI包括﹕

  • 类识别(class identification)──包括类名称或ID。
  • 继承关系(inheritance relationship)──支持执行时期的「往下变换类型」(downward casting)﹐亦即动态变换类型(dynamic casting) 。

在对象数据库存取上﹐还需要下述RTTI﹕

  • 对象结构(object layout) ──包括属性的类型、名称及其位置(position或offset)。
  • 成员函数表(table of functions)──包括函数的类型、名称、及其参数类型等。

其目的是协助对象的I/O 和持久化(persistence) ,也提供调试讯息等。 若依照Bjarne Stroustrup 之建议,C++ 还应包括更完整的RTTI﹕

  • 能得知类所实例化的各对象 。
  • 能参考到函数的源代码。
  • 能取得类的有关在线说明(on-line documentation) 。

其实这些都是C++ 编译完成时所丢弃的资料﹐如今只是希望寻找个途径来将之保留到执行期间。然而﹐要提供完整的RTTI﹐将会大幅提高C++ 的复杂度

dynamic_cast运算符

这是最常用的RTTI组件,它不能回答“指针指向的是哪类对象”这样的问题,但能够回答“是否可以安全地将对象的地址赋给特定类型的指针”这样的问题。

说白了,就是看看这个对象指针能不能转换为目标指针。

通常,如果指向的对象(*pt)的类型为Type或者是从Type直接或简介派生而来的类型,则下面的表达式将指针pt转换为Type类型的指针:

1
dynamic_cast<Type *>(pt)

否则,结果为0,即空指针。

typeid运算符和type_info类

typeid运算符能够用于确定两个对象是否为同种类型。它与sizeof有些相像,可以接受两种参数:

  • 类名
  • 结果为对象的表达式。

返回一个对type_info对象的引用,其中,type_info是在头文件typeinfo中定义的一个类,这个类重载了==!=运算符,以便可以用于对类型进行比较。

1
2
// 判断pg指向的是否是ClassName类的对象
typeid(ClassName) == typeid(*pg)

如果pg是一个空指针,程序将引发bad_typeid异常,该异常是从exception类派生而来的,它是在头文件typeinfo中声明的。

type_info类的实现随厂商而异,但包含一个name()成员,该函数返回一个随实现而异的字符串,通常(但并非一定)是类的名称。可以这样显示:

1
std::cout << "Now processing type is " << typeid(*pg).name() << ".\\n";

其实,typeid运算符就是指出或判断具体的类型,而dynamic_cast运算符主要用于判断是否能够转换,并进行类型转换(指针或引用)。

误用RTTI的例子

有些人对RTTI口诛笔伐,认为它是多余的,会导致程序效率低下和糟糕的编程方式。这里有一个需要尽量避免的例子。

在判断是否能调用某个方法时,尽量不要使用if-elsetypeid的形式,因为这会使得代码冗长。

如果在扩展的if else语句系列中使用了typeid,则应该考虑是否应该使用虚函数和dynamic_cast

reinterpret_cast

reinterpret(重新诠释)

允许将任何指针转换为任何其他指针类型。 也允许将任何整数类型转换为任何指针类型以及反向转换。

C++类型转换之reinterpret_cast - 知乎 (zhihu.com)

变量在内存中是以“…0101…”二进制格式存储的,一个int型变量一般占用32个位(bit),参考下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
int num = 0x00636261;//用16进制表示32位int,0x61是字符'a'的ASCII码
int * pnum = &num;
char * pstr = reinterpret_cast<char *>(pnum);
cout<<"pnum指针的值: "<<pnum<<endl;
cout<<"pstr指针的值: "<<static_cast<void *>(pstr)<<endl;//直接输出pstr会输出其指向的字符串,这里的类型转换是为了保证输出pstr的值
cout<<"pnum指向的内容: "<<hex<<*pnum<<endl;
cout<<"pstr指向的内容: "<<pstr<<endl;
return 0;
}

在Ubuntu 14.04 LTS系统下,采用g++ 4.8.4版本编译器编译该源文件并执行,得到的输出结果如下:

img

第6行定义了一个整型变量num,并初始化为0x00636261(十六进制表示),然后取num的地址用来初始化整型指针变量pnum。接着到了关键的地方,使用reinterpret_cast运算符把pnum从int*转变成char*类型并用于初始化pstr。

将pnum和pstr两个指针的值输出,对比发现,两个指针的值是完全相同的,这是因为“reinterpret_cast 运算符并不会改变括号中运算对象的值,而是对该对象从位模式上进行重新解释”。如何理解位模式上的重新解释呢?通过推敲代码11行和12行的输出内容,就可见一斑。

很显然,按照十六进制输出pnum指向的内容,得到636261;但是输出pstr指向的内容,为什么会得到”abc”呢?

在回答这个问题之前,先套用《深度探索C++对象模型》中的一段话,“一个指向字符串的指针是如何地与一个指向整数的指针或一个指向其他自定义类型对象的指针有所不同呢?从内存需求的观点来说,没有什么不同!它们三个都需要足够的内存(并且是相同大小的内存)来放置一个机器地址。指向不同类型之各指针间的差异,既不在其指针表示法不同,也不在其内容(代表一个地址)不同,而是在其所寻址出来的对象类型不同。也就是说,指针类型会教导编译器如何解释某个特定地址中的内存内容及其大小。”参考这段话和下面的内存示意图,答案已经呼之欲出了。

img

使用reinterpret_cast运算符把pnum从int*转变成char*类型并用于初始化pstr后,pstr也指向num的内存区域,但是由于pstr是char*类型的,通过pstr读写num内存区域将不再按照整型变量的规则,而是按照char型变量规则。一个char型变量占用一个Byte,对pstr解引用得到的将是一个字符,也就是’a’。而在使用输出流输出pstr时,将输出pstr指向的内存区域的字符,那pstr指向的是一个的字符,那为什么输出三个字符呢?这是由于在输出char*指针时,输出流会把它当做输出一个字符串来处理,直至遇到’\0’才表示字符串结束。对代码稍做改动,就会得到不一样的输出结果,例如将num的值改为0x63006261,输出的字符串就变为”ab”。

上面的例子融合了一些巧妙的设计,我们在pstr指向的内存区域中故意地设置了结束符’\0’。假如将num的值改为0x64636261,运行结果会是怎样的呢?

img

上面是我测试的截图,大家可以思考一下为什么在输出”abcd”之后又输出了6个字符才结束呢(提示:参考上面的内存示意图)。

有些情况下,就不会这么幸运了,迎接我们的很可能是运行崩溃。例如我们直接将num(而不是pnum)转型为char*,再运行程序的截图如下

img

可以分析出,程序在输出pstr时崩溃了,这是为什么呢?pstr指向的内存区域的地址是0x64636261,而这片内存区域很有可能并不在操作系统为当前进程分配的虚拟内存空间中,从而导致段错误。

shared_ptr和weak_ptr互相转换

当一个shared_ptr指针被转换为weak_ptr指针时,可以使用以下代码:

1
2
std::shared_ptr<int> sp(new int(10));
std::weak_ptr<int> wp(sp); //不会增加sp的计数

当一个weak_ptr指针被转换为shared_ptr指针时,可以使用以下代码:

1
2
std::weak_ptr<int> wp;
std::shared_ptr<int> sp = wp.lock();

C++11新特性说一下

新特性:智能指针、右值引用、强制转型、std::thread、lambda

操作系统

线程的上下文切换

线程所使用的资源来自其所属进程的资源

只切换线程的私有数据:栈、寄存器、程序计数器;线程没有独立的地址空间,虚拟内存和全局变量和堆是共享的,不需要切换,而进程需要切换。

如果是不同进程的线程,那么实际上就是进程上下文切换。

进程上下文切换

如果只是用户态和内核态的切换,只用换一下CPU寄存器和程序计数器。

如果是不同进程的切换就比较麻烦。

在Linux内核中,进程上下文切换的具体步骤如下:

  1. 保存当前进程的CPU寄存器状态:进程上下文切换需要保存当前进程的CPU寄存器状态,包括通用寄存器、特殊寄存器和程序计数器等。这些寄存器状态将被保存在当前进程的内核栈中。
  2. 保存当前进程的内核栈指针:当前进程的内核栈指针也需要被保存,以便在切换回该进程时能够正确地恢复内核栈中的寄存器状态。这个指针也将被保存在当前进程的内核栈中。
  3. 保存当前进程的虚拟内存状态:当前进程的页表(指针)、虚拟内存映射关系、虚拟内存使用情况(空间大小)等虚拟内存状态需要被保存到当前进程的进程控制块(PCB)中(PCB保存在内存)。
  4. 切换到新进程的虚拟内存状态:将新进程的虚拟内存状态从进程控制块中恢复到系统的内核数据结构中。
  5. 恢复新进程的CPU寄存器状态:将新进程的CPU寄存器状态从新进程的内核栈中恢复,包括通用寄存器、特殊寄存器和程序计数器等。
  6. 恢复新进程的内核栈指针:将新进程的内核栈指针从新进程的内核栈中恢复。
  7. 跳转到新进程的代码:将CPU的控制权转移到新进程的代码中,从新进程的上下文开始执行。

在进程上下文切换的过程中,通常情况下是不需要将进程的内存数据保存到磁盘的。因为这些数据已经保存在虚拟内存中,并且虚拟内存的页表信息也已经保存在进程控制块中。当进程恢复执行时,它的虚拟内存状态会被重新加载回内存中,从而使进程的内存数据得以恢复。

但是,如果系统遇到内存不足的情况,就需要通过将部分进程的内存数据写入磁盘中来腾出一些内存空间。这个过程称为”页面置换”(Page swapping)或者”页面换出”(Page out),可以使用类似于”页面置换算法”(Page replacement algorithm)的机制来选择需要置换的进程和页面。这个过程是由操作系统的”页调度器”(Page scheduler)来负责的,与进程上下文切换的过程有所不同。

线程和协程

线程是抢占式,而协程是非抢占式的,所以需要用户代码释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。

协程并不是取代线程,而且抽象于线程之上。线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行。

线程切换:线程就是进程,就是构造函数(clone)的标志位不太一样而已,只是叫法不一样,用的数据结构都是进程描述符,其实就是一个东西。进程内线程切换,本质上还是进程切换。只要进程切换,就必然已经进入了内核态。道理很简单,只有内核才有权力进行进程调度,而且进程调度涉及到数据结构,例如可调度进程的红黑树、亦或是阻塞进程的队列集,也只有内核才有资格访问。

线程之间是如何进行协作的呢?

  • 涉及到同步锁;
  • 涉及到线程阻塞状态和可运行状态之间的切换;
  • 涉及线程的上下文切换;

协程,又称微线程,是一种用户态的轻量级线程,协程的调度完全由用户控制(也就是在用户态执行)。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到进程的堆区,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁地访问全局变量,所以上下文的切换非常快。

协程最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和线程切换相比,线程数量越多,协程的性能优势就越明显。不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

一个线程内的多个协程是串行执行的,不能利用多核,所以,显然,协程不适合计算密集型的场景。协程适合I/O 阻塞型。

I/O本身就是阻塞型的(相较于CPU的时间世界而言)。就目前而言,无论I/O的速度多快,也比不上CPU的速度,所以一个I/O相关的程序,当其在进行I/O操作时候,CPU实际上是空闲的。

协程能比较好地处理这个问题,当一个协程(特殊子进程)阻塞时,它可以切换到其他没有阻塞的协程上去继续执行,这样就能得到比较高的效率

I/O阻塞时,利用协程来处理确实有优点(切换效率比较高),但是我们也需要看到其不能利用多核的这个缺点,必要的时候,还需要使用综合方案:多进程+协程。

什么是信号?原理是什么

信号(Signal)主要用来通知进程某个特定事件的发生,或者是让进程执行某个特定的处理函数,原理是软中断

signal信号,又称为软中断信号,用来通知进程发生了异步事件,是在软件层次上是对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。

  • 进程之间可以互相通过系统调用kill发送软中断信号。
  • 内核也可以因为内部事件而给进程发送信号,通知进程发生了某个事件。
  • 信号是进程间通信机制中唯一的异步通信机制,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。
  • 如果该进程当前并未处于执行态,则该信号就由内核保存起来,直到该进程恢复执行再传递给它;
  • 如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被 取消时才被传递给进程。

信号来源

  • 硬件方式
    • 当用户按某些终端键时,引发终端产生的信号,例如Ctrl +C 通常会产生终端信号SIGINT;
    • 硬件异常产生信号。除数为0、无效的内存引用等等,这些通常由硬件检测到,并将通知内核。然后内核会为正在运行的进程产生适当的信号。例如对执行一个无效内存引用产生SIGSEGV信号;
  • 软件方式
    • kill 将信号sig 发送给pid 进程
    • killpg 发送信号sig 到pgrp 的所有进程中
    • raise 给当前进程发送信号sig
    • abort 给自己发送异常终止信号
    • alarm 定时将产生SIGALRM信号给调用进程

用户进程对信号的响应方式:

  • 1,捕捉 (收到某个信号,做指定的动作,而不是做默认的)
  • 2,忽略 (收到某个信号,不做什么动作)
  • 3,阻塞 (收到某个信号,先做完当前事情,然后在响应信号)
  • 4,按照默认动作,SIGKILL,SIGSTOP不能被捕捉

过程:

  • 接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中(进程控制块PCB主要维护了一个进程描述符,里面有着pid,进程状态,所以信号也存在里面),同时向进程发送一个中断,使其陷入内核态。此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。
  • 内核进行中断处理,在返回用户态前处理到达的信号
  • 信号处理函数在用户空间,回到用户态执行处理函数
  • 返回内核态,检查是否要处理下一个信号(如果有)
  • 返回用户态继续执行程序

img

用户处理信号的时机为第一次内核态切换到用户态之时,为什么要选此时?

  • 信号不一定会被立即处理,操作系统不会为了处理一个信号而挂起当前正在运行的进程,这样产生的消耗太大(紧急信号【实时信号】可能会被立即处理)。
    操作系统选择在内核态切换到用户态的时候去处理信号,不要单独进行进程切换而浪费时间。
  • 有时候一个正在睡眠的进程突然收到信号,操作系统肯定不愿意切换当前正在运行的进程,预示就将该信号存在此进程的PCB的信号字段中,在合适的时候处理信号

信号与中断的相似点
1)采用了相同的异步通信方式;
2)当检测出有信号或中断请求时,都暂停正在执行的程序而转去执行相应的处理程序;
3)都在处理完毕后返回到原来的断点;
4)对信号或中断都可进行屏蔽。

信号与中断的区别
1)中断有优先级,而信号没有优先级,所有的信号都是平等的;
2)信号处理程序是在用户态下运行的,而中断处理程序是在核心态下运行;
3)中断响应是及时的,而信号响应通常都有较大的时间延迟

大端、小端,如何判断大端和小端

大端和小端指的是字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序。字节序分为大端字节序(Big-Endian) 和小端字节序(Little-Endian)。

  1. 大端字节序:是指最高位字节存储在内存的低地址处,低位字节存储在内存的高地址处 。注意不是完全倒过来,字节内部是不倒序的

  2. 小端字节序:是指最高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处

  3. 如何判断大端还是小端:可以定义一个联合体union,联合体中有一个 short 类型的数据,有一个 char 类型的数组,数组大小为 short 类型的大小。给 short 类型成员赋值一个十六进制数 0x0102,然后输出根据数组第一个元素和第二个元素的结果来判断是大端还是小端。

栈和堆的区别

  1. 管理方式 对于栈来讲,是由编译器自动管理,无需手动控制;对于堆来说,分配和释放都是由程序员控制的。
  2. 空间大小 总体来说,栈的空间是要小于堆的。堆内存几乎是没有什么限制的;但是对于栈来讲,一般是有一定的空间大小的。
  3. 碎片问题 对于堆来讲,由于分配和释放是由程序员控制的(利用new/delete 或 malloc/free),频繁的操作势必会造成内存空间的不连续,从而造成大量的内存碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的数据结构,在某一数据弹出之前,它之前的所有数据都已经弹出。
  4. 生长方向 对于堆来讲,生长方向是向上的,也就是沿着内存地址增加的方向,对于栈来讲,它的生长方式是向下的,也就是沿着内存地址减小的方向增长。
  5. 分配方式 堆都是动态分配的,没有静态分配的堆。栈有两种分配方式:静态分配和动态分配,静态分配是编译器完成的,比如局部变量的分配;动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,它的动态分配是由编译器实现的,无需我们手工实现。
  6. 分配效率 栈是机器系统提供的数据结构,计算机会在底层对栈提供支持,分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率很高。堆则是 C/C++ 函数提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。

为什么栈快但是空间小呢?

为什么栈相对于堆很小? - 知乎 (zhihu.com)

栈只是的名词,我们的关注点是它的功能,栈的功能主要是函数调用、局部变量申请、函数参数传递所使用的空间,是为函数调用的实现提供一些保存、恢复操作。栈帧中主要存储的数据有局部变量、函数返回地址、函数参数。在一个程序中这些信息总共也没多少,所以一般情况下栈空间都特别小。

Stack 的最顶端一般会留存在 CPU registers 和 cache 中。遇到频繁但是层次不多的函数调用,可以利用高速 cache。大块的内存会破坏这种优化。

哈希冲突

哈希冲突产生原因:通过哈希函数产生的哈希值是有限的,当数据比较多时,经过哈希函数处理后仍然有不同的数据对应相同的哈希值,这就产生了哈希冲突。

衡量冲突概率的概念:装填因子(元素个数/可装填总数)。

解决办法:

  1. 线性探测:使用哈希函数计算出的哈希值如果已经有元素占用了,则往后一次寻找,直到找到一个未被占用的哈希值;
  2. 开链:每个表格维护一个list,如果哈希函数计算出的格子相同就按顺序存在这个list中;
  3. 再散列:发生冲突时使用另一种哈希函数再计算,直到不冲突;
  4. 公共溢出区:一旦哈希函数计算的结果相同就放入公共溢出区。

为什么哈希表扩容是两倍(容量是2的n次方)

比如table 是一个数组,那么如何最快的将元素 e 放入数组 ? 当然是找到元素 e 在 table 中对应的位置 index ,然后 table[index] = e; 就好了;如何找到 e 在 table 中的位置了 ? 我们知道只能通过数组下标(索引)操作数组,而数组的下标类型又是 int ,如果 e 是 int 类型,那好说,就直接用 e 来做数组下标(若 e > table.length,则可以 e % table.length 来获取下标),可 key - value 中的 key 类型不一定,所以我们需要一种统一的方式将 key 转换成 int ,最好是一个 key 对应一个唯一的 int (目前还不可能, int有范围限制,对转换方法要求也极高),所以引入了 hash 方法。拿到了 key 对应的 哈希值h 之后,我们最容易想到的对 value 的 put 操作如下:

1
table[h % table.length] = value

直接取模是我们最容易想到的获取下标的方法,但是最高效的方法吗 ?我们知道计算机中的四则运算最终都会转换成二进制的位与运算,如下,只有 & 数是1时,& 运算的结果与被 & 数一致:

1
2
3
4
1&1=1
0&1=0
1&0=0
0&0=0

对于多位也是一样:

1
2
3
4
1010&1111=1010;      => 10&15=10;
1011&1111=1011; => 11&15=11;
01010&10000=00000; => 10&16=0;
01011&10000=00000; => 11&16=0;

10 & 16 与 11 & 16 得到的结果一样,也就是冲突(碰撞)了,那么 10 和 11 对应的 value 会在同一个链表中,而 table 的有些位置则永远不会有元素,这就导致 table 的空间未得到充分利用。比如说前面的16,结果要么是16要么是0,会造成一个空洞。或者说101(5),11(3)这个位置永远不可能放,因为与的那一位是0。

对于一个容量为n的哈希表,位与运算n-1模n做到的功能是一样的,而2^n-1的二进制都是连续的一,能充分利用空间,因此容量是2^n,即每次扩容两倍。

分段和分页

  1. 分段(外部碎片,因为一个段大小不等)
    将用户程序地址空间分成若干个大小不等的段,每段可以定义一组相对完整的逻辑信息。存储分配时,以段为单位,段与段在内存中可以不相邻接,实现了离散分配。分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。
  2. 分页(内部碎片,内存利用率好)
    用户程序的地址空间被划分成若干固定大小的区域,称为“页”,相应地,内存空间分成若干个物理块,页和块的大小相等。可将用户程序的任一页放在内存的任一块中,实现了离散分配。分页主要用于实现虚拟内存,从而获得更大的地址空间。
  3. 段页式
    1. 页式存储管理能有效地提高内存利用率(解决内存碎片),而分段存储管理能反映程序的逻辑结构并有利于段的共享。将这两种存储管理方法结合起来,就形成了段页式存储管理方式。段页式存储管理方式即先将用户程序分成若干个段,再把每个段分成若干个页,并为每一个段赋予一个段名。在段页式系统中,为了实现从逻辑地址到物理地址的转换,系统中需要同时配置段表和页表,利用段表和页表进行从用户地址空间到物理内存空间的映射。
    2. 系统为每一个进程建立一张段表,每个分段有一张页表。段表表项中至少包括段号、页表长度和页表始址,页表表项中至少包括页号和块号。在进行地址转换时,首先通过段表查到页表始址,然后通过页表找到页帧号,最终形成物理地址。

在这里插入图片描述

面试简答:分页是为了提高内存利用率,将内存分为一个个页框,将进程按照页框大小分为一个个页,分页对用户不可见。分段则是按照程序的自身逻辑分配到内存中,对用户可见,用户编程时需要显式给出段名。并且分段比分页更容易实现信息的共享,因为页的大小是由页框决定,一个页中可能包含多个逻辑模块,令多个逻辑模块共享同一块内存显然是不合理的(因为一共享就只能以页为单位来共享

乐观锁和悲观锁

乐观锁:乐观锁总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。

悲观锁:悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。适用于多写场景,或者冲突的代价很高的场景。

CAS(比较交换compare and swap)

  • 加锁是一种悲观的策略,它总是认为每次访问共享资源的时候,总会发生冲突,所以宁愿牺牲性能(时间)来保证数据安全。

  • 无锁是一种乐观的策略,它假设线程访问共享资源不会发生冲突,所以不需要加锁,因此线程将不断执行,不需要停止。一旦碰到冲突,就重试当前操作直到没有冲突为止。

无锁的策略使用一种叫做比较交换的技术(CAS Compare And Swap)来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。

CAS核心算法:执行函数:CAS(V,E,N)

V表示准备要被更新的变量

E表示我们提供的 期望的值

N表示新值 ,准备更新V的值

è¿éåå¾çæè¿°

如果多个线程同时使用CAS操作一个变量的时候,只有一个线程能够修改成功。其余的线程提供的期望值已经与共享变量的值不一样了,所以均会失败。

由于CAS操作属于乐观派,它总是认为自己能够操作成功,所以操作失败的线程将会再次发起操作自旋(循环)而不是被OS挂起。所以说,即使CAS操作没有使用同步锁,其它线程也能够知道对共享变量的影响。

因为其它线程没有被挂起,并且将会再次发起修改尝试(从主存中读取新值),所以无锁操作即CAS操作天生免疫死锁

另外一点需要知道的是,CAS是系统原语,CAS操作是一条CPU的原子指令,所以不会有线程安全问题

三大问题:

  • ABA问题
    • 因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了
    • ABA问题的解决思路就是使用版本号(或者称为时间戳)。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。
  • 循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
  • 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

孤儿进程和僵尸进程

  1. 孤儿进程是指一个父进程退出后,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并且由 init 进程对它们完整状态收集工作,孤儿进程一般不会产生任何危害
  2. 僵尸进程是指一个进程使用 fork() 函数创建子进程,如果子进程退出,而父进程并没有调用 wt() 或者wtpid() 系统调用取得子进程的终止状态,那么子进程的进程描述符仍然保存在系统中占用系统资源(比如说pid号),这种进程称为僵尸进程。

  1. 为了防止产生僵尸进程,在 fork() 子进程之后我们都要及时在父进程中使用 wt() 或者 wtpid() 系统调用,等子进程结束后,父进程回收子进程 PCB 的资源。 同时,当子进程退出的时候,内核都会给父进程一个 SIGCHLD 信号,所以可以建立一个捕获 SIGCHLD 信号的信号处理函数,在函数体中调用 wt() 或 wtpid(),就可以清理退出的子进程以达到防止僵尸进程的目的。
  2. 如果父进程代码没有wait(),可以寻找僵尸进程把他kil掉,如果无效则可以手动kill掉父进程来结束僵尸进程,让init进程来收尸。因为每个进程结束的时候,系统都会扫描当前系统中所运行的所有进程,看看有没有哪个进程是刚刚结束的这个进程的子进程,如果是的话,就由init进程来接管他,成为他的父进程,从而保证每个进程都会有一个父进程。而init进程会自动wait其子进程,因此被Init接管的所有进程都不会变成僵尸进程。

共享内存

共享内存将相同的物理内存地址映射到用户空间,用户可以直接操作

  1. 什么是共享内存 共享内存是进程间通信的一种方式。不同进程之间共享的内存通常为同一段物理内存,进程可以将同一段物理内存连接到他们自己的地址空间中,所有的进程都可以访问共享内存中的地址。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。
  2. 共享内存的优点:因为所有进程共享同一块内存,共享内存在各种进程间通信方式中具有最高的效率。访问共享内存区域和访问进程独有的内存区域一样快,并不需要通过系统调用或者其它需要切入内核的过程来完成。同时它也避免了对数据的各种不必要的复制。
  3. 共享内存的缺点:共享内存没有提供同步机制,这使得我们在使用共享内存进行进程之间的通信时,往往需要借助其他手段来保证进程之间的同步工作。

写时拷贝

传统的 fork() 系统调用直接把所有的资源复制给新创建的进程,这种实现过于简单并且效率低下,因为它拷贝的数据或许可以共享,或者有时候 fork() 创建新的子进程后,子进程往往要调用一种 exec 函数以执行另一个程序。

而 exec 函数会用磁盘上的一个新程序替换当前子进程的正文段、数据段、堆段和栈段,如果之前 fork() 时拷贝了内存,则这时被替换了,这是没有意义的。 Linux 的 fork() 使用写时拷贝(Copy-on-write)页实现。

写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候,大大提高了效率。

互斥锁和自旋锁

自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。 它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。

如果线程切换的消耗比锁被持有的时间还要长,就可以使用自旋锁。

内存对齐

  1. 什么是内存对齐 现代计算机中内存空间都是按照 字节(byte)划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数 k(通常它为4或8)的倍数,这就是所谓的内存对齐。

  2. 内存对齐的原因 - 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的。某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。 - 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问,而对齐的内存访问仅需要一次访问

  3. 如果一个变量的内存地址正好位于它长度的整数倍,它就被称做自然对齐

    1
    2
    3
    4
    5
    char   偏移量为sizeof(char)   即 1 的倍数 
    short 偏移量为sizeof(short) 即 2 的倍数
    int 偏移量为sizeof(int) 即 4 的倍数
    float 偏移量为sizeof(float) 即 4 的倍数
    double 偏移量为sizeof(double) 即 8 的倍数

通过网络传输,内存对齐对传输有影响吗

内存对齐存在的意义之一是为了减少访问次数,通过以空间换效率的方式提高性能。其特性在相同平台的网络通讯中是没有影响的。 但在跨平台中传输结构体(或联合)时,则这个特性有可能会影响到数据的准确性。原因之一是自定义网络通讯协议包通常都是定义成struct的形式, 而struct会自动内存对齐,这会造成结构体成员间有”空洞“,传给其它平台后,其它平台弄不清楚原平台是按什么方式对齐的,只会按自己的方式解包。 解出来的结果有可能是错误的。

红黑树比AVL的优势,为何用红黑树

AVL树是严格的平衡二叉搜索树,平衡条件必须满足所有节点的左右子树高度差不超过1。不管我们是执行插入还是删除操作,只要不满足上面的条件,就要通过旋转来保持平衡,而旋转是非常耗时的,由此我们可以知道AVL树合适用于插入与删除次数比较少,但搜索多的情况。AVL树在Windows NT内核中广泛被使用。

红黑树是一种弱平衡二叉搜索树(红黑树确保没有一条路径比其它路径长出两倍),由于是弱平衡,可以看出,在相同的节点情况下,AVL树的高度低于红黑树,相对于要求严格的AVL树来说它的旋转次数少,所以对于插入与删除较多的情况,我们就用红黑树。红黑树广泛用于C++的STL中,map和set都是用红黑树实现的。

红黑树高度

证明:红黑树高度上限(2lg(n+1)证明._luixiao1220的博客-CSDN博客_红黑树最大高度

image-20230226123624303

零拷贝

img

发生了 4 次用户态与内核态的上下文切换发生了 4 次数据拷贝

img

仍然需要 4 次上下文切换

img

2 次上下文切换,和 3 次数据拷贝

img

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上

PID

进程pid:进程pid(进程ID),每个进程在系统中都有一个唯一·的非负整数表示的进程ID,用getpid() 获取进程ID。

线程tid:线程tid(线程ID),每个线程在所属进程中都有一个唯一的线程ID,用pthread_self() 获取自身现成ID。有多个进程时,可能会出现多个线程ID相同的线程,故线程tid只在其所属的进程上下文中有意义,不能作为系统中某个线程的唯一标识符。

线程pid:线程pid,每个线程在系统中都有一个唯一的pid标识符,用系统调用sys_call(SYS_gettid()) 获取自身线程pid。主线程pid与所在进程pid相同。Linux中的pthread线程库实现的线程其实也是一个进程(LWP),只是该进程与主进程(启动线程的进程)共享一些资源而已,比如代码段,数据段等。在系统中是唯一的,不可重复的

进程能创建多少线程

5.6 一个进程最多可以创建多少个线程? | 小林coding (xiaolincoding.com)

  • 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
  • 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。
    • /proc/sys/kernel/threads-max,表示系统支持的最大线程数,默认值是 14553
    • /proc/sys/kernel/pid_max,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败,默认值是 32768;(默认2^15,64位系统最多可以修改参数设置为2^22)
    • /proc/sys/vm/max_map_count,表示限制一个进程可以拥有的VMA(虚拟内存区域)的数量,具体什么意思我也没搞清楚,反正如果它的值很小,也会导致创建线程失败,默认值是 65530

内存回收

4.3 内存满了,会发生什么? | 小林coding (xiaolincoding.com)

内核在给应用程序分配物理内存的时候,如果空闲物理内存不够,那么就会进行内存回收的工作,主要有两种方式:

  • 后台内存回收:在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
  • 直接内存回收:如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。

可被回收的内存类型有文件页和匿名页:

  • 文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。
  • 匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。

文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能。

img

  • 图中绿色部分:如果剩余内存(pages_free)大于 页高阈值(pages_high),说明剩余内存是充足的;
  • 图中蓝色部分:如果剩余内存(pages_free)在页高阈值(pages_high)和页低阈值(pages_low)之间,说明内存有一定压力,但还可以满足应用程序申请内存的请求;
  • 图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。
  • 图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。

在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM 机制,OOM killer 就会根据每个进程的内存占用情况和 oom_score_adj 的值进行打分,得分最高的进程就会被首先杀掉。

我们可以通过调整进程的 /proc/[pid]/oom_score_adj 值,来降低被 OOM killer 杀掉的概率。

申请内存超出物理内存

4.4 在 4GB 物理内存的机器上,申请 8G 内存会怎么样? | 小林coding (xiaolincoding.com)

  • 在 32 位操作系统,因为进程理论上最大能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
  • 在 64位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:
    • 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
    • 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;

预读失效和缓存污染

4.5 如何避免预读失效和缓存污染的问题? | 小林coding (xiaolincoding.com)

传统的 LRU 算法法无法避免下面这两个问题:

  • 预读失效导致缓存命中率下降;(被提前加载进来的页,并没有被访问,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率 。
  • 缓存污染导致缓存命中率下降;(批量扫描数据,这些只被访问过一次的数据踢掉了热点数据后不会被访问了,比如select扫描数据库)

为了避免「预读失效」造成的影响,Linux 和 MySQL 对传统的 LRU 链表做了改进:

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active list)和非活跃 LRU 链表(inactive list)
  • MySQL Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题

为了避免「缓存污染」造成的影响,Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断:
    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;(代表数据依然是短时间内被访问)
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;(代表数据长期内都会被访问,视作热点数据)

通过提高了进入 active list (或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。

缓冲与非缓冲 I/O

文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O

  • 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
  • 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。

这里所说的「缓冲」特指标准库内部实现的缓冲。

比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的。比如说printf

直接与非直接 I/O

我们都知道磁盘 I/O 是非常慢的,所以 Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是「页缓存」,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求。

那么,根据是「否利用操作系统的缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O

  • 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。(然而还是会读到内核态再拷贝到用户态,但是内核不留备份)
  • 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。

如果你在使用文件操作类的系统调用函数时,指定了 O_DIRECT 标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O。

如果用了非直接 I/O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘?

以下几种场景会触发内核缓存的数据写入磁盘:

  • 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
  • 用户主动调用 sync,内核缓存会刷到磁盘上;
  • 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
  • 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上

进程发生了崩溃,已写入的数据会丢失吗

不会

img

因为进程在执行 write (使用非直接 IO)系统调用的时候,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了,文件数据还是保留在内核的 page cache,我们读数据的时候,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。

内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了。

当然, 我们也可以在程序里调用 fsync 函数,在写文件的时候,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。

  • 注意printf和fputs是写入标准库缓冲(缓存IO),调用fflush可以把标准库缓冲写入内核缓存
  • 而fsync这些就是把内核缓存写入磁盘

一致性哈希

不同的负载均衡算法适用的业务场景也不同的。轮询这类的策略只能适用与每个节点的数据都是相同的场景,访问任意节点都能请求到数据。但是不适用分布式系统,因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时候,一定要寻址存储该数据的节点。

哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。为了减少迁移的数据量,就出现了一致性哈希算法。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。

为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

引入虚拟节点后,可以提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。


虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高

比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力

而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更多的虚拟机节点即可。

因此,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景

img

img

函数调用机制

局部变量占用的内存是在程序执行过程中“动态”地建立和释放的。这种“动态”是通过栈由系统自动管理进行的。当任何一个函数调用发生时,系统都要作以下工作:
(1)建立栈空间;
(2)保护现场:主调函数运行状态和返回地址入栈;
(3)为被调函数中的局部变量分配空间,完成参数传递;
(4)执行被调函数函数体;
(5)释放被调函数中局部变量占用的栈空间;
(6)回复现场:取主调函数运行状态及返回地址,释放栈空间;
(7)继续主调函数后续语句。

CPU多核

每个核都能执行一个线程

多核比多CPU贵,性能好,因为单CPU内通过总线通信快、延迟低,且共享同一块cache

CPU的根本任务就是执行指令,即“0”和“1”组成的机器码。CPU架构可以划分成3个模块,分别是控制单元、运算单元和存储单元,这三部分由CPU内部总线连接起来。

img

  • 控制单元是整个CPU的指挥控制中心,包括指令寄存器IR(Instruction Register)、指令译码器ID(Instruction Decoder)和操作控制器OC(Operation Controller)、时序发生器和程序计数器等部件,对协调整个电脑有序工作极为重要。
  • 运算单元是核心组成部分,其包括执行算术运算和逻辑运算。相对控制单元而言,运算器接受控制单元的命令而进行动作,即运算单元所进行的全部操作都是由控制单元发出的控制信号来指挥的,所以它是执行部件。
  • 存储单元包括CPU片内缓存和寄存器组,是CPU中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU访问寄存器所用的时间要比访问内存的时间短。

通常,控制单元和运算单元统称为一个核Core,换言之,单核是指CPU中包括一个控制单元和一个运算单元。那么对于多核CUP而言,就是由多个核组织(多个控制单元和多个运算单元),共用存储单元

多CPU

多个CPU常见于分布式系统,用于普通消费级市场的不多,多用于cluster,云计算平台什么的。

多CPU架构最大的瓶颈就是I/O,尤其是各个CPU之间的通讯。这主要是每个CPU都有自己的cache,又共享内存,会产生缓存一致性问题,这样速度就慢了。

多核CPU与多个CPU并不冲突,相反,两者会相互结合。目前有些大型机经常会有多个CPU,每个CPU都是多核的。如2个物理CPU,每个物理CPU都有2个核,那么最终的CPU就是4核的。

进程间通信编程

(42条消息) Linux下C++进程间通信机制_linux c++ 进程间通信 单变量设置_醉如泥的博客-CSDN博客

匿名管道

int pipe(int fd[2]) ; 创建一个无名管道fd包含2个文件描述符的数组,fd[0]用于读,fd[1]用于写若成功返回0,否则返回-1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//一般某个进程用于读,另一个进程用于写,用于读的进程需要close(fd[1]),用于写的进程需要close(fd[0]);
int main(void)
{
pid_t pid;
int fd[2],i,n;
char chr;
pipe(fd);
pid=fork();
if(pid==0) //子进程拷贝父进程信息
{
close(fd[1]);
for(i=0;i<10;i++)
{
read(fd[0],&chr,1);
printf("%c\n",chr);
}
close(fd[0]);
exit(0);
}
close(fd[0]); //父进程
for(i=0;i<10;i++)
{
chr='a'+i;
write(fd[1],&chr,1);
sleep(1);
}
close(fd[1]);
return 0;
}

命名管道

命名管道相当于一个文件。

1、FIFO 在文件系统中作为一个特殊的文件而存在,但 FIFO 中的内容却存放在内存中。

2、当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。

3、FIFO 有名字,不相关的进程可以通过打开命名管道进行通信。

int mkfifo(const char *pathname,mode_t mode);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
mkfifo("fifo",0660); //创建一个命令管道,属主和用户组具有读写权限
pid_t pid;
pid=fork();
if(pid==0)
{
char buf[256];
int fd=open("fifo",O_RDONLY); //子进程读管道中的数据
read(fd,buf,10);
buf[10]=0;
printf("%s",buf);
close(fd);
exit(0);
}
int fd=open("fifo",O_WRONLY); //父进程向管道写入数据
write(fd,"fifo test\n",10);
close(fd);
return 0;
}

信号量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

1int semget(key_t key,int nsems,int semflg) //获得或创建信号量,成功返回信号量标识,出错返回-1
//key:根据key生成信号量标识;
//nsems:创建的信号量集中的信号量的个数,该参数只在创建信号量集时有效;
//semflg:存取权限或创建条件若为0则用于获得已存在的信号量,若为IPC_CREAT|perm perm为存取权限,则用于创建信号量;

2、int semop(int semid,struct sembuf* sops,unsigned nsops) //获得或者释放信号量,成功返回0,否则返回-1
//semid:信号量标识;
//sops指向由sembuf组成的数组;
//nsops信号量的个数;

struct sembuf{
ushort sem_num; //在信号量数组中的索引
short sem_op; //要执行的操作,若sem_op大于0那么操作为将sem_op加入到信号量的值中,并唤醒等待信号增加的进程;
//若sem_op等于0,当信号量的值也是0时, 函数返回,否则阻塞直到信号量的值为0;若sem_op小于0,则判断信号量的值加上sem_op的值,
//如果结果为0,唤醒等待信号量为0的进程,如果小于0,调用该函数的进程阻塞,如果大于0,那么从信号量里减去这个值并返回。

short sem_flg; //操作标致,SEM_UNDO会阻塞,IPC_NOWAIT不会阻塞

};

3int semctl(int semid,int semnum,int cmd,union semun arg); //在信号量集上的控制操作
//semid信号量集的标识;
//semnum信号量集的第几个信号量,撤销信号量集时,次参数可缺省;
//cmd用于指定操作类别,值为GETVAL获得信号量的值,SETVAL设置信号量的值,GETPID获得最后一次操作信号量的进程,
//GETNCNT获得正在等待信号量的进程数,GETZCNT获得等待信号量值变为0的进程数,IPC_RMID 删除信号量或信号量数组

消息队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
1int msgget(key_t key,int msgflg)
//函数说明:功能:获取或者创建一个消息队列,key值同上,msgflg:存取或者创建条件值同上。成功返回消息队列标识,失败返回-1.

2、int msgsnd(int msgid,const void* msgp,size_t msgsz,int msgflg)
//函数说明:功能:向消息队列中发送消息,msgid:消息队列标识,msgp消息结构体的地址,msgsz:消息结构体的字节,
//msgflg:操作标志,成功返回0,否则返回-1。在消息队列没有足够的空间容纳发送的消息时,该函数会阻塞,
//如果msgflg为IPC_NOWAIT ,则不管发送消息是否成功,该函数都不会阻塞。其中msgp必须指向这样一个结构体

struct msgbuf{
long mtype; //必须有且大于0
char mtext[1]; //这个可以自己定以,也可以定义其他成员
}

3size_t msgrcv(int msgid,void *msgp,size_t msgsz,long msgtyp,int msgflg)
//函数说明:获取指定消息队列中,msgtyp类型的消息,该值要根发送消息的结构体中msgp->mtype值一样,msgsz,消息结构体的大小,msgflg操作标志,值同上
//成功返回收到的字节个数,失败返回-1

4、int msgctl(int msqid,int cmd,struct msqid_ds* buf)
//函数说明:cmd操作类型,IPC_RMID删除消息队列,IPC_STAT获取消息队列的状态,IPC_SET改变消息队列的状态,buf用来存放消息队列的属性信息,其结构体如下
struct msqid_ms{
struct ipc_perm msg_perm; //权限
struct msg *msg_first;   //消息队列的首
struct msg *msg_last;    //消息队列的尾
__kernel_time_t msg_stime; //最后发送时间
__kernel_time_t msg_rtime; //最后接受时间
__kernel_time_t msg_ctime; //最后修改时间
unsigned short msg_cbytes; //当前消息队列的字节数
unsigned short msg_qnum; //消息队列中的消息数
unsigned short msg_qbytes; //消息队列的最大字节数
__kernel_ipc_pid_t msg_lspid; //最后发送消息的进程ID
__kernel_ipc_pid_t msg_lrpid; //最后接受消息的进程ID
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <unistd.h>
#define MSG_KEY 111
#define BUFSIZE 4096
struct msgbuf
{
long mtype;
char mtext[BUFSIZE];
};
int main()
{
int mspid;
pid_t pid;

mspid=msgget(MSG_KEY,IPC_CREAT|0666);
if(mspid==-1)
{
perror("msgget");
exit(-1);
}

pid=fork();
if(pid<0)
{
perror("fork");
exit(-1);
}
else if(pid==0)
{
sleep(10);
int msqid=msgget(MSG_KEY,0);
if(msqid==-1)
{
perror("msgget");
exit(-1);
}
struct msgbuf buf;
if(msgrcv(msqid,(void *)&buf,sizeof(struct msgbuf),1,0)==-1)
{
perror("msgrcv");
exit(-1);
}
printf("child:rcv a msg is %s\n",buf.mtext);
exit(0);
}
else
{
struct msgbuf buf;
buf.mtype=1;
strcpy(buf.mtext,"Hello World!");
if(msgsnd(mspid,(const void *)&buf,sizeof(struct msgbuf),0)==-1)
{
perror("msgsnd");
exit(-1);
}
printf("parent:snd a msg is %s\n",buf.mtext);
sleep(10);
}

// struct msqid_ds cbuf;
msgctl(mspid,IPC_RMID,NULL);
return 0;
}

共享内存

共享内存将相同的物理内存地址映射到用户空间,用户可以直接操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1int shmget(key_t key,int size,int shmflg)
//函数说明:功能:创建或获得共享内存,key:作用同上,size:共享内存的大小,shmflg:存取权限或创建条件,
//若为IPC_CREAT|perm perm为存取权限,则表示创建共享内存,为0表示获得共享内存

2、void * shmat(int shmid,const void *shmaddr,int shmflg)
//函数说明:功能:将创建的共享内存映射到进程虚拟地址空间的某个位置,shmid:共享内存标识,
//shmaddr要映射到的进程虚拟空间地址,若为NULL,则由系统决定对应的地址,
//shmflg:指定如何使用共享内存,若指定了SHM_RDONLY位则表示以只读的方式使用此段,否则以读写的方式使用此段.成功返回映射的地址失败返回-1

3、int shmdt(const void* shmaddr);
//函数说明:解除共享内存的映射,shmaddr:共享内存的映射地址,成功返回0,否则返回-1

4int shmctl(int shmid,int cmd,struct shmid_ds *buf)
//函数说明:对以存在的共享内存进行操作,shmid:共享内存标识,cmd:操作类型:cmd 为IPC_STAT 获取共享内存的状态,
//IPC_/SET设置共享内存的权限,IPC_RMID删除共享内存,IPC_LOCK 锁定共享内存,使共享内存不被置换出去,IPC_UNLOCK解锁。

struct shmid_ds{
struct ipc_perm   shm_perm; //存取权限
int        shm_segsz; //共享内存大小
__kernel_time_t shm_atime; //最后映射时间
__kernel_time_t shm_dtime; //最后解除映射时间
__kernel_time_t shm_ctime; //最后修改时间
__kernel_ipc_pid_t shm_cpid; //创建进程ID
__kernel_ipc_pid_t shm_lpid; //最近操作进程ID
unsigned short shm_nattch; //建立映射的进程数
}

也可以两个进程同时mmap到一个文件,mmap把文件加载到内核空间,然后把这块空间映射到用户空间,用户直接操作这块空间就相当于对内核空间的内存进行读写,然后内核刷脏页,不用在内核和用户中进行切换和拷贝。

下面是二者区别:

1、mmap保存到实际硬盘,实际存储并没有反映到主存上。优点:储存量可以很(多于主存);缺点:进程间读取和写入速度要比主存的要慢。

2、shm保存到物理存储器(主存),实际的储存量直接反映到主存上。优点,进程间访问速度(读写)比磁盘要;缺点,储存量不能非常大(多于主存)

使用上看:如果分配的存储量不大,那么使用shm;如果存储量大,那么使用mmap

CPU时间片大小

Windows 系统中线程轮转时间也就是时间片大约是20ms,如果某个线程所需要的时间小于20ms,那么不到20ms就会切换到其他线程;如果一个线程所需的时间超过20ms,系统也最多只给20ms,除非意外发生(那可能导致整个系统无响应),而Linux/unix中则是5~800ms

map中红黑树和哈希表比较

  • 有序无序:map始终保证遍历的时候是按key的大小顺序的,这是一个主要的功能上的差异。
  • 时间复杂度:红黑树的插入删除查找性能都是O(logN),而哈希表的插入删除查找性能理论上都是O(1)红黑树是相对于稳定的,最差情况下都是高效的。哈希表的插入删除操作的理论上时间复杂度是常数时间的,这有个前提就是哈希表不发生数据碰撞。在发生碰撞的最坏的情况下,哈希表的插入和删除时间复杂度最坏能达到**O(n)**。
  • 空间性能:红黑树占用的内存更小(需要为其存在的节点分配内存),而Hash事先应该分配足够的内存存储散列表,即使有些槽可能弃用
  • 范围查找:map可以做范围查找(中序遍历),而unordered_map不可以。
  • 扩容导致迭代器失效。 map的iterator除非指向元素被删除,否则永远不会失效。unordered_map的iterator在对unordered_map修改时有时会失效。

红黑树适合数据量较高的情况。

  • put和remove过程中,红黑树要通过左旋,右旋、变色这些操作来保持平衡,另外构造红黑树要比构造链表复杂,在链表的节点不多的时候,从整体的性能看来, 数组+链表+红黑树的结构可能不一定比数组+链表的结构性能高。就好比杀鸡焉用牛刀的意思。
  • HashMap频繁的扩容,会造成底部哈希表不断的进行拆分和重组,这是非常耗时的。因此,也就是链表长度比较长的时候转变成红黑树才会显著提高效率。

浏览器为什么使用进程

浏览器使用进程的主要目的是为了提高浏览器的稳定性和安全性,以及增强用户的体验。

  • 将浏览器的不同组件(如渲染引擎、JavaScript 解释器、网络请求等)放在不同的进程中可以避免它们之间的相互影响和崩溃。如果一个组件崩溃了,只会导致它所在的进程崩溃,而不会影响到其他进程和组件,从而提高了浏览器的稳定性。此外,通过将插件和扩展程序放在单独的进程中,可以保护浏览器不受它们可能引发的安全漏洞的影响。
  • 现代网页使用了大量的代码和资源,包括HTML、CSS、JavaScript等等,这些资源需要浏览器来解析和执行。如果所有的代码和资源都运行在同一个进程中,会造成该进程负载过重,导致浏览器响应缓慢、卡顿、甚至崩溃。因此,将每个网页放到单独的进程中运行,可以将负载分散到多个进程中,从而提高浏览器的性能和稳定性
  • 使用多进程还可以提供更好的用户体验。例如,使用多进程可以在一个标签页崩溃时仅仅关闭该标签页,而不是整个浏览器,从而避免用户丢失已经打开的其他标签页。此外,多进程还可以提供更好的隔离性,使得网站之间的资源无法相互访问,从而增强用户的隐私和安全性

综上所述,使用进程是浏览器提高稳定性、安全性、性能和用户体验的有效手段之一。

最佳线程数

CPU 密集型

(计算比较多,比如复杂算法)

一个完整请求,I/O操作可以在很短时间内完成, CPU还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分

对于计算密集型的任务,一个有Ncpu个处理器的系统通常通过使用一个Ncpu + 1个线程的线程池来获得最优的利用率(计算密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作)。

1
最佳线程= cpu核数(逻辑) +1

I/O密集型程序

与 CPU 密集型程序相对,一个完整请求,CPU运算操作完成之后还有很多 I/O 操作要做,也就是说 I/O 操作占比很大部分

单核:

1
最佳线程数 = ((线程IO时间+线程CPU时间)/线程CPU时间 )* CPU数目

img

计算示例

多核:

1
最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时))

物理CPU(核)和逻辑CPU(核)

一个物理CPU核上如果只有一套寄存器,那就只能运行一个程序的指令,这里面可能有指令空闲的时候(比如load要等几个周期)。

超线程技术就是在一个物理核上做两套寄存器,就能装两个程序的指令,在一个程序指令空闲时插入另一个程序的指令,相当于两个核,也就是两个逻辑核。

锁、信号量底层实现

所谓的锁,在计算机里本质上就是一块内存空间。当这个空间被赋值为1的时候表示加锁了,被赋值为0的时候表示解锁了,仅此而已。多个线程抢一个锁,就是抢着要把这块内存赋值为1。在一个多核环境里,内存空间是共享的。每个核上各跑一个线程,那如何保证一次只有一个线程成功抢到锁呢?这必须要硬件的某种guarantee。

锁总线从而原子赋值

  • CPU如果提供一些用来构建锁的atomic指令,譬如x86的CMPXCHG(加上LOCK prefix),能够完成atomic的compare-and-swap(CAS),用这样的硬件指令就能实现spin lock。本质上LOCK前缀的作用是锁定系统总线(或者锁定某一块cache line)来实现atomicity,可以了解下基础的缓存一致协议譬如MSEI。简单来说就是,如果指令前加了LOCK前缀,就是告诉其他核,一旦我开始执行这个指令了,在我结束这个指令之前,谁也不许动。缓存一致协议在这里面扮演了重要角色,这里先不赘述。这样便实现了一次只能有一个核对同一个内存地址赋值

互斥锁需要维护挂起进程

  • 一个spin lock就是让没有抢到锁的线程不断在while里面循环进行compare-and-swap,燃烧CPU,直到前面的线程放手(对应的内存被赋值0)。这个过程不需要操作系统的介入,这是运行程序和硬件之间的故事。如果需要长时间的等待,这样反复CAS轮询就比较浪费资源,这个时候程序可以向操作系统申请被挂起,然后持锁的线程解锁了以后再通知它。这样CPU就可以用来做别的事情,而不是反复轮询。但是OS切换线程也需要一些开销,所以是否选择被挂起,取决于大概是否需要等很长时间,如果需要,则适合挂起切换为别的线程。
  • 线程向操作系统请求被挂起是通过一个系统调用,在linux上的实现就是futex,宏观来讲,OS需要一些全局的数据结构来记录一个被挂起线程和对应的锁的映射关系,这样一个数据结构天然是全局的,因为多个OS线程可能同时操作它。所以,实现高效的锁本身也需要锁。有没有一环套一环的感觉?futex的巧妙之处就在于,它知道访问这个全局数据结构不会太耗时,于是futex里面的锁就是spin lock。linux上pthread mutex的实现就是用的futex。

这里面的关系是,互斥锁需要挂起进程,就需要有数据结构记录,这个数据结构本身又不是线程安全的,就需要自旋锁来维护。


信号量与自旋锁的实现机制是不一样的,用处也是不一样的。

  • 首先,自旋锁和信号量都使用了计数器来表示允许同时访问共享资源的最大进程数,但自旋锁的共享计数值是1,也就是说任意时刻只有一个进程在共享代码区运行;信号量却允许使用大于1的共享计数,即共享资源允许被多个不同的进程同时访问,当然,信号量的计数器也能设为1,这时信号量也称为互斥量。
  • 其次,自旋锁用于保护短时间能够完成操作的共享资源,使用期间不允许进程睡眠和进程切换;信号量常用于暂时无法获取的共享资源,如果获取失败则进程进入不可中断的睡眠状态,只能由释放资源的进程来唤醒。
  • 最后,自旋锁可以用于中断服务程序之中;信号量不能在中断服务程序中使用,因为中断服务程序是不允许进程睡眠的

信号量的使用方式如下:

1
2
3
down(sem);
//...临界区...
up(sem);

首先看函数down(sem)的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static inline void down(struct semaphore * sem)
{
might_sleep();
__asm__ __volatile__(
"# atomic down operation\n\t"
LOCK_PREFIX "decl %0\n\t" /* --sem->count */
"jns 2f\n"
"\tlea %0,%%eax\n\t"
"call __down_failed\n"
"2:"
:"+m" (sem->count)
:
:"memory","ax");
}

这里面包含了一些汇编代码,%0代表sem->count。也就是说先将sem->count减1,LOCK_PREFIX表示执行这条指令时将总线锁住,保证减1操作是原子的。减1之后如果大于或等于0就转到标号2处执行,也就跳过了__down_failed函数直接到函数尾部并返回,成功获取信号量;否则减1之后sem->count小于0则顺序执行后面的__down_failed函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
static inline void up(struct semaphore * sem)
{
__asm__ __volatile__(
"# atomic up operation\n\t"
LOCK_PREFIX "incl %0\n\t" /* ++sem->count */
"jg 1f\n\t"
"lea %0,%%eax\n\t"
"call __up_wakeup\n"
"1:"
:"+m" (sem->count)
:
:"memory","ax");
}

首先将sem->count加1,是原子操作,如果加1后sem->count大于0则说明没有进程在等待信号量资源,无须唤醒队列中进程,直接跳转到标号1处返回;否则运行__up_wakeup唤醒等待队列中的进程。

唤醒进程中一个关键的指令是调用函数__up:

1
2
3
4
fastcall void __up(struct semaphore *sem)
{
wake_up(&sem->wait);
}

__up的的工作就是唤醒等待队列中的所有进程,但是由于sem等待队列中的进程 的TASK_EXCLUSIVE标志为 1,因此不会唤醒后续进程了。也就是说up(sem)操作实际上是将sem->count自增1,然后唤醒等待队列中的第一个进程(如果有的话)。

计算机网络

vxlan优势

VXLAN vs VLAN - 知乎 (zhihu.com)

  • 子网数量多(不是主要原因)
  • 减少交换机mac表,因为一台物理机可以有许多虚拟机(每台虚拟机都有自己的mac地址),交换机要都记录虚拟机的mac是很困难的,但如果物理机就对应一个vtep,那么交换机看到的只是一台vtep在传输数据。(主要原因)

TCP 粘包

4.6 如何理解是 TCP 面向字节流协议? | 小林coding (xiaolincoding.com)

TCP基于字节流,无法判断发送方报文段边界(TCP并不维护数据边界)

多个数据包被连续存储于连续的缓存中,在对数据包进行读取时由于无法确定发生方的发送边界,而采用某一估测值大小来进行数据读出,若发送方发送数据包的长度和接收方在缓存中读取的数据包长度不一致,就会发生粘包。

很多地方这里没讲清楚:

  • 实际上TCP报文中的报文头部是可以提供正确的数据信息的,这个过程由内核封装与解封,对接收端来说它看到的信息是绝对正确的。
  • 问题出在接收端即使收到了正确的信息,也分不清消息的边界,也就是说接收端看到的一条消息可能与发送端的不一样,这就是问题所在。
  • 例如发送两条信息”hello”和”world”,接收端可能当作一条消息处理”helloworld”或者当成不同的多片报文处理。

解决方法主要是确定边界

  1. 发送方关闭Nagle算法,使用TCP_NODELAY选项关闭Nagle功能
  2. 发送定长的数据包。每个数据包的长度一样,接收方可以很容易区分数据包的边界
  3. 数据包末尾加上\r\n标记,模仿FTP协议,但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界
  4. 数据包头部加上数据包的长度。数据包头部定长4字节,可以存储数据包的整体长度
  5. 应用层自定义规则

Nagle算法主要用来预防小分组的产生。在广域网上,大量TCP小分组极有可能造成网络的拥塞。

Nagle时针对每一个TCP连接的。它要求一个TCP连接上最多只能有一个未被确认的小分组。在该分组的确认到达之前不能发送其他小分组。TCP会搜集这些小的分组,然后在之前小分组的确认到达后将刚才搜集的小分组合并发送出去。

DNS 解析过程以及 DNS 劫持

DNS查询的过程简单描述就是:主机向本地域名服务器发起某个域名的DNS查询请求,如果本地域名服务器查询到对应IP,就返回结果,否则本地域名服务器直接向根域名服务器发起DNS查询请求,要么返回结果,要么告诉本地域名服务器下一次的请求服务器IP地址,下一次的请求服务器可能是顶级域名服务器也可能还是根域名服务器,然后继续查询。(这是迭代查询,也可以递归查询,就是本地域名服务器直接去查上级服务器,不用本地主机去访问)

在完成整个域名解析的过程之后,并没有收到本该收到的IP地址,而是接收到了一个错误的IP地址。比如输入的网址是百度,但是却进入了奇怪的网址,并且地址栏依旧是百度。在这个过程中,攻击者一般是修改了本地路由器的DNS地址,从而访问了一个伪造的DNS服务器,这个伪造的服务器解析域名的时候返回了一个攻击者精心设计的网站,这个网站可能和目标网站一模一样,当用户输入个人账户时,数据会发送给攻击者,从而造成个人财产的丢失。

TCP 延迟确认

当发送没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。 为了解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。 TCP 延迟确认的策略:

  • 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
  • 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
  • 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK

当被动关闭方在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。

img

ECN 显式拥塞通知

显式拥塞通知Explicit Congestion Notification(ECN)是TCP/IP协议的扩展,在RFC 3168 (2001) 中进行了定义。ECN支持端到端的网络拥塞通知。

它通过在IP头部嵌入一个拥塞指示器和在TCP头部嵌入一个拥塞确认实现。兼容ECN的交换机和路由器会在检测到拥塞时对网络数据包打标记。IP头部的拥塞指示也可以用于RoCEv2的拥塞控制。下面是IP头部的前四个帧的格式:

img

通常情况下,当网络中出现拥塞的时候,TCP/IP会主动丢弃数据包。源端检测到丢包后,就会减小拥塞窗口,降低传输速率。

但如果端到端能成功协商ECN的话,支持ECN的路由器就可以发生拥塞时在IP报头中设置一个标记,发出即将发生拥塞的信号,而不是直接丢弃数据包。

ECN减少了TCP的丢包数量,通过避免重传,减少了延迟(尤其是抖动),提升了应用的性能。

  • ECN需要主动队列管理AQM策略结合才能发挥作用。路由器在队列溢出前检测到拥塞,在IP报头中设置Congestion Experienced (CE) Codepoint代码点来指示正在发生拥塞。
  • 在当前的Internet上,丢包是对端节点进行拥塞通知的重要机制,解决路由器”满队列”的方法便是在队列充满之前丢包,这样端节点便能在队列溢出前对拥塞做出反应。这种方法便称为”主动式队列管理”(Active Queue Management)。AQM是一族基于FIFO调度策略的队列管理机制,使得路由器能够控制在什么时候丢多少包,以支持端到端的拥塞控制。

RED 随机早期检测

RED(随机早期检测)可以有效防止TCP全局同步。其做法是在队列满之前就对已入队的报文进行随机丢弃,RED的特点在于“早期”和“随机”,这使得不同的流量在不同的时刻以“无规律”的方式丢弃,从而有效避免了所有的TCP连接发生同步震荡。

在这里插入图片描述

  • Low-Limit:最低丢弃门限,平均队列长度超过门限时,RED开始丢弃报文,值越低,队列越早开始丢弃报文
  • High-Limit:最高丢弃门限,平均队列长度超过此门限时,RED将丢弃所有到来的报文。
  • Pmax:最大丢弃概率,即RED丢弃报文条件下报文被丢弃的最大概率,这个值通常不为100%。
  • 当前平均队列长度小于Low-Limit时,不丢弃报文
  • 当前平均队列长度超过High-Limit时,丢弃所有到来的报文
  • 当前平均队列长度在Low-Limit和High-Limit之间时,开始随机丢弃到来的报文。

尾部丢弃

在这里插入图片描述

队列]满时路由器进行尾丢弃,即新到的所有数据包都全部丢弃,丢弃的结果造成高延迟、高抖动、丧失服务保证、TCP全局同步、TCP饿死等问题,从而导致应用超时、数据重传和实时业务不可用等一系列问题

TCP全局同步:没有差别的丢弃会造成所有TCP流的报文几乎在同一时刻丢弃,TCP又几乎在同一时刻重传。TCP窗口会在几乎同一时刻缩小,然后又几乎同一时刻增大,这将造成所有TCP连接的流量以相同的“频率”持续震荡。TCP全局同步的结果是TCP传输效率急剧下降,并且带宽的平均利用率大大降低。

增加队列长度可以减少丢弃,但无法从根本上解决问题,队列长度受限于资源,不能无限制增加,增加队列长度也增加了报文的平均延迟和抖动,在尾丢弃发生前,使不同TCP连接的报文在不同时刻被丢弃,则各个TCP连接的流量震荡就不会同步。

TCP什么时候会把一个包分成多个发送

一般情况下,如果TCP数据包的大小小于网络链路的MTU大小,则不需要分包。但是,在某些情况下,即使TCP数据包的大小小于MTU,也可能发生分包。例如,在某些网络中,可能会对数据包进行分段,以便进行负载均衡或实现其他网络优化。此外,在某些安全设备中,也可能会对数据包进行拆分和重新组装,以便进行检查和过滤。因此,虽然TCP数据包小于MTU大小,但在某些情况下可能仍然会发生分包。

TCP 11种状态

详解TCP的11种状态 - 腾讯云开发者社区-腾讯云 (tencent.com)

数据库MySQL

B+树如何减少IO

在InnoDB引擎中存放数据的单位是页,每一页的大小是16KB。

对于InnoDB引擎而言,每一个数据页都存放的是同一个高度的树结点。从根页开始,通过指针寻找到下一层高度的索引页时,要从硬盘里把索引页拿出来,就产生了IO。

红黑树太高了,IO太多。B树非叶结点有数据(存储了当前节点对应的索引的数据),所以层数还是比B+树高,因为阶少1。以及,B+树叶子节点有双向链表,能做范围查询。

为什么用B+树不用哈希表

  • 哈希索引不支持排序与范围查找,因为哈希表是无序的。
  • 因为哈希表中会存在哈希冲突,所以哈希索引的性能是不稳定的,而B+树索引的性能是相对稳定的,每次查询都是从根节点到叶子节点。
  • 哈希索引不支持模糊查询及多列索引的最左前缀匹配。

innodb 和 myisam 的区别

img

关于索引,innodb索引到数据,myisam索引到地址

innodb增删改更快:MyIsam是表级锁,如果在增删改频繁操作的场景下,会慢。

myisam查询更快:

  • 1)数据块,InnoDB要缓存,MyISAM只缓存索引块, 这中间还有换进换出的减少;
  • 2)InnoDB寻址要映射到块,再到行,MyISAM记录的直接是文件的OFFSET,定位比InnoDB要快
  • 3)InnoDB还需要维护MVCC一致; 虽然你的场景没有,但他还是需要去检查和维护

InnoDB 的 MVCC

它是一种用来解决读-写冲突的无锁并发控制机制。在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。

  1. 隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。

  2. 基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。

  3. ReadView(一致性视图):通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。

连接池最大最小连接

这实际上是动态控制连接数,在资源和响应时间中做一个均衡。

数据库连接池最小连接数和最大连接数:

  • 最小连接数是连接池一直保持的数据连接。如果应用程序对数据库连接的使用量不大的话,一直维持较多的连接数就会有大量的数据库连接资源被浪费掉。
  • 最大连接数是连接池能申请的最大连接数。如果数据连接请求超过此数,后面的数据连接请求将被加入到等待队列中,这会影响之后的数据库操作。
  • 超过最小连接数量的连接请求等价于建立一个新的数据库连接。这些大于最小连接数的数据库连接在使用完不会马上被释放,它将被放到连接池中等待重复使用或是空闲超时后被释放

上面的解释,可以这样理解:数据库池连接数量一直保持一个不少于最小连接数的数量,当数量不够时,数据库会创建一些连接,直到一个最大连接数,之后连接数据库就会等待。

对于连接的管理可使用空闲池。即把已经创建但尚未分配出去的连接按创建时间存放到一个空闲池中。

  • 每当用户请求一个连接时,系统首先检查空闲池内有没有空闲连接。
  • 如果有就把建立时间最长(通过容器的顺序存放实现)的那个连接分配给他(实际是先做连接是否有效的判断,如果可用就分配给用户,如不可用就把这个连接从空闲池删掉,重新检测空闲池是否还有连接);
  • 如果没有则检查当前所开连接池是否达到连接池所允许的最大连接数(maxconn)如果没有达到,就新建一个连接,如果已经达到,就等待一定的时间(timeout)。
  • 如果在等待的时间内有连接被释放出来就可以把这个连接分配给等待的用户,如果等待时间超过预定时间timeout 则返回空值(null)。
  • 系统对已经分配出去正在使用的连接只做计数,当使用完后再返还给空闲池。对于空闲连接的状态,可开辟专门的线程定时检测,这样会花费一定的系统开销,但可以保证较快的响应速度。也可采取不开辟专门线程,只是在分配前检测的方法。

Linux 内核

huge page

64 位的 Linux 系统中(英特尔 x64 CPU),虚拟内存地址转换成物理内存地址的过程:

页表 分为 4 级:页全局目录页上级目录页中间目录页表 目的是为了减少内存消耗。

img

有些场景我们希望使用更大的内存页作为映射单位(如 2MB)。使用更大的内存页作为映射单位有如下好处:

  • 减少 TLB(Translation Lookaside Buffer) 的失效情况。
  • 减少 页表 的内存消耗。

Tips:TLB 是一块高速缓存,TLB 缓存虚拟内存地址与其映射的物理内存地址。MMU 首先从 TLB 查找内存映射的关系,如果找到就不用回溯查找页表。否则,只能根据虚拟内存地址,去页表中查找其映射的物理内存地址。

比如要查第100页实际的物理页,那么TLB就记录了(100,x)的映射,x就是物理页。如果TLB没有记录的话,就要去页表找,一方面页表比较大,一方面内存比缓存慢,都导致性能下降。

因为映射的内存页越大,所需要的 页表 就越小(条目少),大大减少由内核加载的映射表的数量;页表 越小,TLB 失效的情况就越少。

使用大于 4KB 的内存页作为内存映射单位的机制叫 HugePages,目前 Linux 常用的 HugePages 大小为 2MB 和 1GB,我们以 2MB 大小的内存页作为例子。

要映射更大的内存页,只需要增加偏移量部分

img

在linux中,使用分三步:

  • 挂载 Hugetlb 文件系统:

    • ```shell
      $ mkdir /mnt/huge
      $ mount none /mnt/huge -t hugetlbfs
      1
      2
      3
      4
      5

      * **初始化**HugePages:`/proc/sys/vm/nr_hugepages` 文件保存了内核可以使用的 HugePages 数量

      * ```shell
      $ echo 20 > /proc/sys/vm/nr_hugepages //设置了可用的 HugePages 数量为 20 个
  • 使用mmap:要使用 HugePages,必须使用 mmap 系统调用把虚拟内存映射到 Hugetlb 文件系统中的文件

epoll

select和epoll的区别

  • 首先select是posix支持的,而epoll是linux特定的系统调用,因此,epoll的可移植性就没有select好,但是考虑到epoll和select一般用作服务器的比较多,而服务器中大多又是linux,所以这个可移植性的影响应该不会很大。
  • 其次,select可以监听的文件描述符有限,最大值为1024,而epoll可以监听的文件描述符则是系统对整个进程限制的最大文件描述符。
  • 接下来就要谈epoll和select的性能比较了,这个一般情况下应该是epoll表现好一些,否则linux也不会去特定实现epoll函数了,那么epoll为什么比select更高效呢?原因有很多,第一点,epoll通过每次有就绪事件时都将其插入到一个就绪队列中,使得epoll_wait的返回结果中只存储了已经就绪的事件,而select则返回了所有被监听的事件,事件是否就绪需要应用程序去检测,那么如果已被监听但未就绪的事件较多的话,对性能的影响就比较大了。第二点,每一次调用select获得就绪事件时都要将需要监听的事件重复传递给操作系统内核,而epoll对监听文件描述符的处理则和获得就绪事件的调用分开,这样获得就绪事件的调用epoll_wait就不需要重新传递需要监听的事件列表,这种重复的传递需要监听的事件也是性能低下的原因之一。除此之外,epoll的实现中使用了mmap调用使得内核空间和用户空间共享内存,从而避免了过多的内核和用户空间的切换引起的开销。
  • 然后就是epoll提供了两种工作模式,一种是水平触发模式,这种模式和select的触发方式是一样的,要只要文件描述符的缓冲区中有数据,就永远通知用户这个描述符是可读的,这种模式对block和noblock的描述符都支持,编程的难度也比较小;而另一种更高效且只有epoll提供的模式是边缘触发模式,只支持nonblock的文件描述符,他只有在文件描述符有新的监听事件发生的时候(例如有新的数据包到达)才会通知应用程序,在没有新的监听时间发生时,即使缓冲区有数据(即上一次没有读完,或者甚至没有读),epoll也不会继续通知应用程序,使用这种模式一般要求应用程序收到文件描述符读就绪通知时,要一直读数据直到收到EWOULDBLOCK/EAGAIN错误,使用边缘触发就必须要将缓冲区中的内容读完,否则有可能引起死等,尤其是当一个listen_fd需要监听到达连接的时候,如果多个连接同时到达,如果每次只是调用accept一次,就会导致多个连接在内核缓冲区中滞留,处理的办法是用while循环抱住accept,直到其出现EAGAIN。这种模式虽然容易出错,但是性能要比前面的模式更高效,因为只需要监听是否有事件发生,发生了就直接将描述符加入就绪队列即可。

select的缺点

select的缺点:

  1. 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024)
  2. 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
  3. (不是返回就绪数组)select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
  4. select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。

相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。

拿select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到10万级别的并发访问,是一个很难完成的任务。

1
2
3
4
5
通俗版本可能是这样的:
应用程序拿着一张纸:内核哥,我想知道这张纸上面三个表格中画1的地方对应的文件描述符,有没有发生啥事件。
内核说:程序弟,稍等。。。,我给你把没有事件的地方画上0,有事件的地方保留1.
内核在纸上一顿涂改操作,把没事件发生的地方改成了0,有事件的地方保留1,然后把纸交给程序。
然后,程序拿着纸,看着有1的地方就知道这地方发生了事件,他要去处理了。

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?

在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

epoll底层

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:

1
2
3
4
5
6
7
8
9
10
11
struct eventpoll{//置于缓存中,很快
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
/*
* 当事件到来或结束时,会用到红黑树的插入删除
*/

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

1
2
3
4
5
有的人误以为 epoll 高效的全部因为这棵红黑树,这就有点夸大红黑树的作用了。
其实红黑树的作用是仅仅是在管理大量连接的情况下,添加和删除 socket 非常的高效。
如果 epoll 管理的 socket 固定的话,在数据收发的事件管理过程中其实红黑树是没有起作用的。
内核在socket上收到数据包以后,可以直接找到 epitem(epoll item),并把它插入到就绪队列里,然后等用户进程把事件取走。
这个过程中,红黑树的作用并不会得到体现。

所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。也是通过这个回调关系(更具体来说是一个指针),根据socket能直接找到epitem(我们创建记录的fd只是为了能找到socket),epitem中又包含了fd供用户使用。那为什么又要红黑树呢?因为要管理这些事件,当事件要关闭时还要找得到事件来关闭。

在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:

1
2
3
4
5
6
7
struct epitem{//事件对应一个fd
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

在这里插入图片描述

image-20230305170006227

api

  • int epoll_create(int size)

    • 创建一个指示epoll内核事件表的文件描述符,该描述符将用作其他epoll系统调用的第一个参数,size不起作用。(从Linux 2.6.8开始,max_size参数将被忽略,但必须大于零。)
    • 成功时,返回一个非负文件描述符。发生错误时,返回-1,并且将errno设置为指示错误
  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

    • 该函数用于操作内核事件表监控的文件描述符上的事件:注册、修改、删除

    • epfd:为epoll_creat的句柄

    • op:表示动作,用3个宏来表示:

      • EPOLL_CTL_ADD (注册新的fd到epfd),相当于把fd加到epfd这棵红黑树上
      • EPOLL_CTL_MOD (修改已经注册的fd的监听事件),
      • EPOLL_CTL_DEL (从epfd删除一个fd);
    • fd:文件描述符

    • event:告诉内核需要监听的事件,结构如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      typedef union epoll_data {
      void *ptr;
      int fd;
      __uint32_t u32;
      __uint64_t u64;
      } epoll_data_t;

      struct epoll_event {
      __uint32_t events; /* Epoll events,是一串比特,设置类型时把类型或起来 */
      epoll_data_t data; /* User data variable */
      };
    • events描述事件类型,其中epoll事件类型有以下几种

      • EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
      • EPOLLOUT:表示对应的文件描述符可以写
      • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
      • EPOLLERR:表示对应的文件描述符发生错误
      • EPOLLHUP:表示对应的文件描述符被挂断;
      • EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
      • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
  • int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

    • 该函数用于等待所监控文件描述符上有事件的产生,返回就绪的文件描述符个数

      • events:用来存内核得到事件的集合,

      • maxevents:告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,

      • timeout:是超时时间

        • -1:阻塞
        • 0:立即返回,非阻塞
        • >0:指定毫秒,没有事件触发会等待,但有事件触发就立即返回
      • 返回值:成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1

触发模式:

  • LT水平触发模式

    • 当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll还会再次向应用程序通知此事件,直到该事件被处理完毕。
  • ET边缘触发模式

    • 当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。
    • 必须要一次性将数据读取完,使用非阻塞I/O,读取到出现eagain
  • ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,故效率要比LT模式高。LT模式是epoll的默认工作模式

  • EPOLLONESHOT

    • 一个线程读取某个socket上的数据后开始处理数据,在处理过程中该socket上又有新数据可读,此时另一个线程被唤醒读取,此时出现两个线程处理同一个socket
    • 我们期望的是一个socket连接在任一时刻都只被一个线程处理,通过epoll_ctl对该文件描述符注册epolloneshot事件,一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件

ET和LT模式的应用场景分别是

  • ET模式适用于高并发、高性能的网络服务器,如Nginx、Redis等。ET模式可以避免重复处理相同的事件,减少系统开销。但是ET模式需要注意数据的完整读写,否则可能会导致数据丢失或错乱。
  • LT模式适用于低并发、低性能的网络服务器,如Apache等。LT模式可以简化编程逻辑,保证数据的完整读写。但是LT模式可能会造成事件的频繁触发,增加系统开销

unix域套接字

多进程reactor模式下,主进程要给其他进程传送文件描述符时,需要使用unix域套接字。因为线程是共享文件描述符的,同一个int对应的文件是相同的,但不同进程间相同的文件描述符对应的文件可能是不同的,所以不能简单传送一个数字

进程间传递打开的文件描述符,并不是传递文件描述符的值。

文件描述符的值与文件没有关系,只是文件在该进程中的一个标志。同一个文件在不同进程中的文件描述符的值可能不一样,且一样的文件描述符的值可能指向不同的文件。

img

  • 文件描述符表(项):存在于进程中,不同的进程有各自的文件描述符表,每个表项存放者文件描述相关的结构,包括fd值即文件表指针。
  • 文件表(项):存在内核中,进程中每个打开的文件生成的文件描述符表项都在内核会关联一个文件表项,包括当前文件偏移量,这样才能使每个进程都有它自己的对该文件的当前偏移量。V结点指针:指向的是同一V结点
  • V结点表(项):存在于内核中,每个打开的文件只有一个V结点,包含文件类型,对文件进行操作的函数指针,有的还包括 i 节点。

UNIX域套接字用于在同一台机器上运行的进程之间的通信。UNIX域套接字提供流和数据包两种接口。UNIX域套接字是套接字和管道之间的混合物。为了创建一对非命名的、相互连接的UNIX域套接字,可以使用socketpair函数。

1
2
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int sockfd[2]);

pipe创建的管道,第一描述符的写端和第二描述符的读端都被关闭,socketpair创建的则是全双工UNIX域套接字。

使用面向网络的域套接字接口,可以创建命名UNIX域套接字,使无关进程之间也可以用UNIX域套接字进行通信。UNIX域套接字的地址由sockaddr_un结构表示:

1
2
3
4
5
#include <sys/un.h>
struct sockaddr_un {
sa_family_t sun_family; //AF_UNIX
char sun_path[108]; //pathname
}

UNIX域套接字没有端口号,依靠唯一的路径名来标识。

UNIX域套接字可以用来传送文件描述符。描述符传递不是简单的传送一个int类型的描述符的值,而是在接收进程中创建一个新的描述符,这个描述符与发送进程的描述符指向内核文件表中的相同项。

当发送进程将描述符传送给接收进程后,通常它关闭该描述符。被发送者关闭的描述符并不真正关闭文件或设备,因为描述符在接收进程里仍视为打开的,即使接收者还没有明确地收到这个描述符。

惊群效应

深入浅出 Linux 惊群:现象、原因和解决方案 - 腾讯云开发者社区-腾讯云 (tencent.com)

惊群效应也有人叫做雷鸣群体效应,不过叫什么,简言之,惊群现象就是多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只可能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群。

惊群效应到底消耗了什么?

  • 系统对用户进程/线程频繁地做无效的调度,上下文切换系统性能大打折扣。
  • 为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

Accept”惊群”现象

在网络分组通信中,网络数据包的接收是异步进行的,因为你不知道什么时候会有数据包到来。因此,网络收包大体分为两个过程:

1
2
[1] 数据包到来后的事件通知
[2] 收到事件通知的Task执行流,响应事件并从队列中取出数据包

img

对于高性能的服务器而言,为了利用多 CPU 核的优势,大多采用多个进程(线程)同时在一个 listen socket 上进行 accept 请求。多个进程阻塞在 Accept 调用上,那么在协议栈将 Client 的请求 socket 放入 listen socket 的 accept 队列的时候,是要唤醒一个进程还是全部进程来处理呢?

linux 内核通过睡眠队列来组织所有等待某个事件的 task,而 wakeup 机制则可以异步唤醒整个睡眠队列上的 task,wakeup 逻辑在唤醒睡眠队列时,会遍历该队列链表上的每一个节点,调用每一个节点的 callback,从而唤醒睡眠队列上的每个 task。这样,在一个 connect 到达这个 lisent socket 的时候,内核会唤醒所有睡眠在 accept 队列上的 task。N 个 task 进程(线程)同时从 accept 返回,但是,只有一个 task 返回这个 connect 的 fd,其他 task 都返回-1**(EAGAIN)**。这是典型的 accept”惊群”现象。

在linux2.6版本以后,linux内核已经解决了accept()函数的“惊群”现象,大概的处理方式就是,当内核接收到一个客户连接后,只会唤醒等待队列上的第一个进程(线程),所以如果服务器采用accept阻塞调用方式,在最新的linux系统中已经没有“惊群效应”了

select/poll/Epoll “惊群”现象

通常一个 server 有很多其他网络 IO 事件要处理,我们并不希望 server 阻塞在 accept 调用上,为提高服务器的并发处理能力,我们一般会使用 select/poll/epoll I/O 多路复用技术,同时为了充分利用多核 CPU,服务器上会起多个进程(线程)同时提供服务。于是,在某一时刻多个进程(线程)阻塞在 select/poll/epoll_wait 系统调用上,当一个请求上来的时候,多个进程都会被 select/poll/epoll_wait 唤醒去 accept,然而只有一个进程(线程 accept 成功,其他进程(线程 accept 失败,然后重新阻塞在 select/poll/epoll_wait 系统调用上。

在多进程服务中使用 epoll 的时候,是先 epoll_create 得到 epoll fd 后在 fork 子进程,还是先 fork 子进程,然后每个子进程在 epoll_create 自己独立的 epoll fd 呢?有什么异同?

先 epoll_create 后 fork

这样,多个进程公用一个 epoll 实例(父子进程的 epoll fd 指向同一个内核 epoll 对象),这种情况下,epoll 有以下这些特性:

  • [1] epoll在ET模式下不存在“惊群”现象,LT模式是epoll“惊群”的根源,并且LT模式下的“惊群”没办法避免。
  • [2] LT的“惊群”是链式唤醒的,唤醒过程直到当前epi的事件被处理了,无法获得到新的事件才会终止唤醒过程。 例如有A、B、C、D…等多个进程task睡眠在epoll的睡眠队列上,并且都监控同一个listen fd的可读事件。一个请求上来,会首先唤醒A进程,A在epoll_wait的处理过程中会唤醒进程B,这样进程B在epoll_wait的处理过程中会唤醒C,这个时候A的epoll_wait处理完成返回,进程A调用accept读取了当前这个请求,进程C在自己的epoll_wait处理过程中,从epi中获取不到事件了,于是终止了整个链式唤醒过程。 (因为LT会一直触发直到事件完成,这是epoll产生的惊群,而非fd唤醒导致的
  • [3] ET模式下,一个fd上的同事多个事件上来,只会唤醒一个睡眠在epoll上的task,如果该task没有处理完这些事件,在没有新的事件上来前,epoll不会在通知task去处理。

由于 ET 的事件通知模式,通常在 ET 模式下的 epoll_wait 返回,我们会循环 accept 来处理所有未处理的请求,直到 accept 返回 EAGAIN 才退出 accept 流程。否则,没处理遗留下来的请求,这个时候如果没有新的请求过来触发 epoll_wait 返回,这样遗留下来的请求就得不到及时处理。这种处理模式,会带来一种类”惊群”现象。考虑,下面的一个处理过程:A、B、C三个进程在监听listen fd的EPOLLIN事件,都睡眠在epoll_wait上,都是ET模式。

  • [1] listen fd上一个请求C_1上来,该请求唤醒了A进程,A进程从epoll_wait返回准备去accept该请求来处理。
  • [2] 这个时候,第二个请求C_2上来,由于睡眠队列上是B、C,于是epoll唤醒B进程,B进程从epoll_wait返回准备去accept该请求来处理。
  • [3] A进程在自己的accept循环中,首选accept得到C_1,接着A进程在第二个循环继续accept,继续得到C_2。
  • [4] B进程在自己的accept循环中,调用accept,由于C_2已经被A拿走了,于是B进程accept返回EAGAIN错误,于是B进程退出accept流程重新睡眠在epoll_wait上。
  • [5] A进程继续第三个循环,这个时候已经没有请求了, accept返回EAGAIN错误,于是A进程也退出accept处理流程,进入请求的处理流程。

可以看到,B 进程被唤醒了,但是并没有事情可以做,同时,epoll 的 ET 这样的处理模式,负载容易出现不均衡。(可以用epolloneshot

先 fork 后 epoll_create

用法上,通常是在父进程创建了 listen fd 后,fork 多个 worker 子进程来共同处理同一个 listen fd 上的请求。这个时候,A、B、C…等多个子进程分别创建自己独立的 epoll fd,然后将同一个 listen fd 加入到 epoll 中,监听其可读事件。这种情况下,epoll 有以下这些特性:

  • [1] 由于相对同一个listen fd而言, 多个进程之间的epoll是平等的,于是,listen fd上的一个请求上来,会唤醒所有睡眠在listen fd睡眠队列上的epoll,epoll又唤醒对应的进程task,从而唤醒所有的进程(这里不管listen fd是以LT还是ET模式加入到epoll)。 (这是fd导致的惊群,会唤醒挂载在fd下的所有epoll)
  • [2] 多个进程间的epoll是独立的,对epoll fd的相关epoll_ctl操作相互独立不影响。

在使用友好度方面,多进程独立 epoll 实例要比共用 epoll 实例的模式要好很多。独立 epoll 模式要解决 fd 的排他唤醒 epoll 即可。

linux4.5 以后的内核版本中,增加了 EPOLLEXCLUSIVE, 该选项只能通过 EPOLL_CTL_ADD 对需要监控的 fd(例如 listen fd)设置 EPOLLEXCLUSIVE 标记。这样 epoll entry 是通过排他方式挂载到 listen fd 等待队列的尾部的,睡眠在 listen fd 的等待队列上的 epoll entry 会加上 WQ_FLAG_EXCLUSIVE 标记。listen fd 上的事件上来,在遍历并唤醒等待队列上的 entry 的时候,遇到并唤醒第一个带 WQ_FLAG_EXCLUSIVE 标记的 entry 后,就结束遍历唤醒过程。于是,多进程独立 epoll 的”惊群”问题得到解决。

“惊群”之 SO_REUSEPORT

对于大多采用 MPM 机制(multi processing module)TCP 服务而言,基本上都是多个进程或者线程同时在一个 Listen socket 上进行监听请求。根据前面介绍的 Linux 睡眠队列的唤醒方式,基本睡眠在这个 listen socket 上的 Task 只能要么全部被唤醒,要么被唤醒一个。

这是因为单个fd会唤醒挂载在自身上的睡眠的epoll,epoll唤醒task,那如果每个task对应一个fd,就解决了惊群效应。

于是,基本的解决方案是起多个 listen socket,好在我们有 SO_REUSEPORT(linux 3.9 以上内核支持),它支持多个进程或线程 bind 相同的 ip 和端口,支持以下特性:

  • [1] 允许多个socket bind/listen在相同的IP,相同的TCP/UDP端口
  • [2] 目的是同一个IP、PORT的请求在多个listen socket间负载均衡
  • [3] 安全上,监听相同IP、PORT的socket只能位于同一个用户下

于是,在一个多核 CPU 的服务器上,我们通过 SO_REUSEPORT 来创建多个监听相同 IP、PORT 的 listen socket,每个进程监听不同的 listen socket。这样,在只有 1 个新请求到达监听的端口的时候,内核只会唤醒一个进程去 accept,而在同时并发多个请求来到的时候,内核会唤醒多个进程去 accept,并且在一定程度上保证唤醒的均衡性。SO_REUSEPORT 在一定程度上解决了”惊群”问题,但是,由于 SO_REUSEPORT 根据数据包的四元组和当前服务器上绑定同一个 IP、PORT 的 listen socket 数量,根据固定的 hash 算法来路由数据包的,其存在如下问题:

  • [1] Listen Socket数量发生变化的时候,会造成握手数据包的前一个数据包路由到A listen socket,而后一个握手数据包路由到B listen socket,这样会造成client的连接请求失败。
  • [2] 短时间内各个listen socket间的负载不均衡

NAPI

关于epoll,NAPI技术可以一次收集多个请求,加入到就绪队列后再返回。

NAPI是linux新的网卡数据处理API,据说是由于找不到更好的名字,所以就叫NAPI(New API),在2.5之后引入。简单来说,NAPI是综合中断方式与轮询方式的技术。

中断的好处是响应及时,如果数据量较小,则不会占用太多的CPU事件;缺点是数据量大时,会产生过多中断,而每个中断都要消耗不少的CPU时间,从而导致效率反而不如轮询高。轮询方式与中断方式相反,它更适合处理大量数据,因为每次轮询不需要消耗过多的CPU时间;缺点是即使只接收很少数据或不接收数据时,也要占用CPU时间。

NAPI 是 Linux 上采用的一种提高网络处理效率的技术,它的核心概念就是不采用中断的方式读取数据,而代之以首先采用中断唤醒数据接收的服务程序,然后 POLL 的方法来轮询数据

随着网络的接收速度的增加,NIC 触发的中断能做到不断减少,目前 NAPI 技术已经在网卡驱动层和网络层得到了广泛的应用,驱动层次上已经有 E1000 系列网卡,RTL8139 系列网卡,3c50X 系列等主流的网络适配器都采用了这个技术,而在网络层次上,NAPI 技术已经完全被应用到了著名的netif_rx 函数中间,并且提供了专门的 POLL 方法–process_backlog 来处理轮询的方法;根据实验数据表明采用NAPI技术可以大大改善短长度数据包接收的效率,减少中断触发的时间。

NAPI 对数据包到达的事件的处理采用轮询方法,在数据包达到的时候,NAPI 就会强制执行dev->poll方法。而和不像以前的驱动那样为了减少包到达时间的处理延迟,通常采用中断的方法来进行。

以前的网络设备驱动程序架构已经不能适用于每秒产生数千个中断的高速网络设备,并且它可能导致整个系统处于饥饿状态(译者注:饥饿状态的意思是系统忙于处理中断程序,没有时间执行其他程序)。有些网络设备具有中断合并,或者将多个数据包组合在一起来减少中断请求这种高级功能。

NAPI 存在一些比较严重的缺陷:

1. 对于上层的应用程序而言,系统不能在每个数据包接收到的时候都可以及时地去处理它,而且随着传输速度增加,累计的数据包将会耗费大量的内存,经过实验表明在 Linux 平台上这个问题会比在 FreeBSD 上要严重一些;
2. 另外一个问题是对于大的数据包处理比较困难,原因是大的数据包传送到网络层上的时候耗费的时间比短数据包长很多(即使是采用 DMA 方式)使得轮询等待时间长(因为在网络层轮询等待数据包),所以正如前面所说的那样,NAPI 技术适用于对高速率的短长度数据包的处理

Linux中线程为什么也称为轻量级进程

线程是概念上的,Linux中线程也是一个进程,只是这些进程之间互相共享数据,也就称为轻量级进程

线程的创建

在Linux系统中,线程是通过POSIX提供的线程库创建的,它与进程中的其他线程共享数据段,但线程拥有自己的线程栈以及独立的运行序列。Linux线程的创建实在内核外进行的,有POSIX提供的线程库实现。在进程创建时,内核提供的两个系统调用分别为**_clone()fork()最终都用不同的参数对应到do_fork()**这个内核API。

do_fork()提供很多参数选项,即CLONE_VM(共享内存空间)、CLONE_FS(共享文件系统信息)、CLONE_FILES(共享文件描述符表)、CLONE_SIGHAND(共享信号句柄表)和CLONE_PID(共享进程ID,仅对核内进程,即0号进程有效)等。

当执行fork()时,对应内核调用do_fork()时不使用上述的任何共享属性,这也导致进程拥有独立的运行环境。相反,在通过pthread_create()来创建线程时,则通过选项设置所有这些共享属性来调用__clone(),而这些参数又全部传给内核态的do_fork(),从而导致所创建的“进程”拥有共享的运行环境。因此在Linux系统中,线程通常被称为“轻量级进程”

线程的管理

在Linux内核中,线程是以轻量级进程的形式存在的,拥有独立的进程表项;而所有的线程创建、同步、删除等操作都在核外pthread库中进行。这种模式称为基于核心轻量级进程的**”一对一”线程模型**。

轻量级进程是建立在内核线程的基础上的,内核线程运行在内核态中,线程最终的调度都落在内核线程的调度上,比如一个内核线程负责IO、一个内核线程负责内存管理等等。linux内是一对一(一个lwp对应一个内核线程)

image-20230313110826049

image-20230313110849627

用户线程为什么必须映射到内核线程

  • 只有内核线程才是处理器分配的单位。
  • 用户线程对用户不透明,但对os是透明的(看不到),os只能看到内核线程。
  • 对于用户级线程来说,用户程序运行用户级线程,必须要通过映射到内核级线程后,在内核级线程上运行它。内核线程将被操作系统调度器指派到处理器内核。

具体来说:

  • 用户线程是由用户在用户库的帮助下创建的线程,只对创建进程和它的运行环境可见(内核不知道这些线程的创建)。用户线程只是停留在创建进程的地址空间中,由创建进程运行和管理,没有内核的干预,也就是说,这些线程的执行出现的任何问题都不是内核的问题。
  • 内核线程是由内核创建的,对它来说是可见的。一个用户进程在所提供的库的帮助下,要求内核为该进程创建一个可执行的线程,而内核则代表该进程创建该线程,并将其放在现有的可执行线程列表中。在这个过程中,线程的创建、执行和管理是由内核负责的。
  • 作为内核的一部分,调度器只知道内核级的线程,因为如前所述,内核不知道用户线程的存在,因为它们是在创建进程的地址空间中创建的,因此内核对它们没有控制权。内核中的CPU调度程序只是在其拥有的线程 “列表 “中查看可供执行的线程列表,并开始调度它们。

如果没有为线程映射到内核线程:

  • 内存中的每个进程都是一个 “内核线程”,这意味着该进程也在内核的线程列表中。因此,这意味着内核将用户进程映射到其中一个内核线程中去执行它。
  • 一个进程所创建的所有用户线程都在指定给整个进程的同一个内核级线程上执行。每当轮到指定的进程在CPU上执行时,它的内核线程就会被安排到CPU上,从而执行该进程。
  • 因为所有的线程都是由创建进程本身控制的,用户线程将被逐一映射到指定的内核线程上,从而被执行。

简而言之,用户线程需要被映射到内核线程,因为是内核将线程安排到CPU上执行,为此它必须知道它所安排的线程。对于一个简单的进程来说,内核只知道这个进程的存在,而不知道在这个进程中创建的用户线程,所以内核只会把这个进程的线程安排到CPU上,所有在这个进程中的其他用户线程如果要被执行,就必须一个一个地映射到指定给创建进程的内核线程。


内核线程有两种,一种是完成特定工作的,一种是与用户进程做映射的。

用户进程和内核线程是一体的,调度内核线程相当于调度用户进程。执行普通代码的时候在用户空间,就不需要内核线程。执行系统调用就需要进入内核态,这时就调度内核线程来完成。内核线程的堆栈就是因为有函数操作什么的要保存变量

优先级调度和完全公平调度差别

  1. 调度策略:传统的优先级调度算法根据进程的优先级来决定调度顺序,而完全公平调度算法采用时间片轮转的策略,平等地为每个进程分配 CPU 时间。
  2. 公平性:传统的优先级调度算法可能会导致低优先级进程长时间得不到调度,出现“饥饿”现象,而完全公平调度算法可以保证所有进程都能够得到公平的 CPU 时间分配,避免了饥饿现象。
  3. 响应时间:传统的优先级调度算法可以让高优先级进程更快地得到响应和执行,但可能会导致低优先级进程的响应时间较长;而完全公平调度算法可以保证所有进程的响应时间相对平均,但短进程的等待时间可能会增加,影响系统的响应速度。
  4. 预测性:传统的优先级调度算法通常需要根据应用的特点和需求来调整优先级,而完全公平调度算法不需要做出太多的调整和优化,更容易保持稳定。

进程静态优先级和动态优先级

在操作系统中,进程的优先级通常可以分为静态优先级和动态优先级两种类型。静态优先级决定时间片大小,动态优先级决定进程的调度。

静态优先级是指进程在创建时就确定的优先级,一旦确定就不会发生变化。通常情况下,静态优先级由进程的创建者指定,可以通过系统调用如 nice() 或 setpriority() 来设置。在 Linux 系统中,进程的静态优先级范围是从 -20(最高优先级)到 19(最低优先级),默认值为 0。

动态优先级则是指进程在运行时根据系统负载情况和进程运行状态等动态调整的优先级。在 Linux 系统中,动态优先级主要由 CFS 调度器进行控制,CFS 调度器使用一种称为虚拟运行时间(virtual runtime)的机制来计算进程的运行时间,并根据进程的虚拟运行时间来决定其动态优先级。当进程的虚拟运行时间越长时,其动态优先级就会越低,从而使得其他优先级更高的进程能够获得更多的 CPU 时间。

需要注意的是,静态优先级和动态优先级并不是相互独立的,而是相互影响的。在 CFS 调度器中,进程的静态优先级会被用来计算其初始的虚拟运行时间,从而影响其动态优先级的计算。因此,静态优先级越高的进程通常会获得更多的 CPU 时间,而动态优先级则可以在系统负载高峰时更加灵活地分配 CPU 时间,以保证系统的响应性能和公平性。

C++ 程序占cpu过高如何排查

  • 1.定位程序
    • 监控cpu运行状,显示进程运行信息列表:top -c
    • 按CPU使用率排序,键入大写的P
    • 此时知道了最高的进程的PID
  • 2.查看进程中线程的信息
    • top -Hp 进程号。 同样输入大写P,top的输出会按使用cpu多少排序,获取最高的那个线程号(H是线程模式)
      • -c: 命令行列显示程序名以及参数
      • -d: 启动时设置刷新时间间隔
      • -H: 设置线程模式
      • -i: 只显示活跃进程
      • -n: 显示指定数量的进程
      • -p: 显示指定PID的进程
      • -u: 显示指定用户的进程
  • 查看线程堆栈
    • pstack 进程号,会输出所有线程的堆栈信息
    • 在信息中搜索线程号,查看对应的堆栈,看看是哪一行代码的问题

linux查看内存

free命令

命令格式: free –m

  • -b  以Byte为单位显示内存使用情况。
  • -k  以KB为单位显示内存使用情况。
  • -m  以MB为单位显示内存使用情况。
  • -h  以合适的单位显示内存使用情况,最大为三位数,自动计算对应的单位值。

用途:用于检查有关系统RAM的使用情况(查看系统的可用和已用内存)

可用内存计算公式:

可用内存 =free +buffers +cached, 实际操作即:215 +11+57 =253MB;

检查Linux内存占用的 5 大命令,你知道几个?

vmstat 指令

命令格式:vmstat -s(参数)

用途: 用于查看系统的内存存储信息,是一个报告虚拟内存统计信息的小工具,vmstat 命令报告包括:进程、内存、分页、阻塞 IO、中断、磁盘、CPU。

从图中我们可以看出可用内存和可用交换内存条数目,即系统中的可用内存。

检查Linux内存占用的 5 大命令,你知道几个?

/proc/meminfo 指令

命令格式:cat /proc/meminfo

用途:用于从/proc文件系统中提取与内存相关的信息。这些文件包含有系统和内核的内部信息。

从中我们可以很清晰明了的看出内存中的各种指标情况,例如 MemFree的空闲内存和SwapFree中的交换内存。

PS:你还可以使用命令 less /proc/meminfo 直接读取该文件。通过使用 less 命令,可以在长长的输出中向上和向下滚动,找到你需要的内容哦~

检查Linux内存占用的 5 大命令,你知道几个?

top 指令

命令格式:top

用途: 用于打印系统中的CPU和内存使用情况。

小试牛刀:

检查Linux内存占用的 5 大命令,你知道几个?

输出结果中,可以很清晰的看出已用和可用内存的资源情况。top 最好的地方之一就是发现可能已经失控的服务的进程 ID 号(PID)。有了这些 PID,你可以对有问题的任务进行故障排除(或 kill)。

PS:如果你想让 top 显示更友好的内存信息,使用命令 top -o %MEM,这会使 top 按进程所用内存对所有进程进行排序。

htop 指令

命令格式:htop

用途:详细分析CPU和内存使用情况。

检查Linux内存占用的 5 大命令,你知道几个?

程序是如何跑起来的

文件角度:ELF文件格式,装入内存

生成进程、调度,程序计数器

linux下查看端口命令

netstat命令参数:

  -t : 指明显示TCP端口

  -u : 指明显示UDP端口

  -l : 仅显示监听套接字(所谓套接字就是使应用程序能够读写与收发通讯协议(protocol)与资料的程序)

  -p : 显示进程标识符和程序名称,每一个套接字/端口都属于一个程序。

  -n : 不进行DNS轮询,显示IP(可以加速操作)

即可显示当前服务器上所有端口及进程服务,于grep结合可查看某个具体端口及服务情况··

1
netstat -ntlp``//查看当前所有tcp端口·``netstat -ntulp |grep80``//查看所有80端口使用情况·``netstat -an | grep3306``//查看所有3306端口使用情况·

查看一台服务器上面哪些服务及端口

1
netstat -lanp

查看一个服务有几个端口。比如要查看mysqld

1
ps -ef |grep mysqld

查看某一端口的连接数量,比如3306端口

1
netstat -pnt |grep :3306|wc

查看某一端口的连接客户端IP 比如3306端口

1
2
netstat -anp |grep3306
netstat -an 查看网络端口

lsof -i :port,使用lsof -i :port就能看见所指定端口运行的程序,同时还有当前连接。

1
nmap 端口扫描``netstat -nupl (UDP类型的端口)``netstat -ntpl (TCP类型的端口)``netstat -anp 显示系统端口使用情况

业务

Web 安全

HTTP状态

HTTP CODE 2xx

状态码:200 ok

含义:客户端请求成功

状态码:204 No Content

含义:请求处理成功,但没有资源返回。204不允许返回任何实体的主体

状态码:206 Partial Content

含义:客户发送了一个带有Range头的GET请求,服务器完成了它。使用video去播放视频,返回206,说明视频范围

HTTP CODE 3xx

状态码:301 Moved Permanently

含义:永久重定向。该状态吗表示请求的资源已被分配了新的URI,以后应按 Location 首部字段提示的 URI 重新保存。

状态码:302 Found

含义:和 301 Moved Permanently 状态码相似,但 302 状态码代表的资源不是被永久移动,只是临时性质的。

状态码:303 See Other

含义:303 状态码和 302 Found 状态码有着相同的功能,但 303 状态码明确表示客户端应当采用 GET 方法获取资源。

状态码:304 Not Modified

含义:

1、304 虽然被划分在 3XX 类别中,但是和重定向没有关系。

2、资源已找到,但未符合条件请求。

条件请求是啥:

采用 GET方法的请求报文中包含 If-Match,If-ModifiedSince,If-None-Match,If-Range,If-Unmodified-Since
中任一首部。

HTTP CODE 4xx

状态码:400 Bad Request

含义:请求报文中存在语法错误。当错误发生是,需要修改请求的内容后再次发送请求。

另外,浏览器会像200 OK一样对待该状态码。

状态码:401 Unauthorized

含义:返回含有 401 的响应必须包含一个适用于被请求资源的 WWW-Authenticate
首部用以质询(challenge)用户信息。当浏览器初次接收到 401 响应,会弹出认证用的对话窗口。

状态码:403 Forbidden

含义:该状态码表明对请求资源的访问被服务器拒绝了。服务器端没有必要给出拒绝的详细理由。

未获得文件系统的访问授权,访问权限出现某些问题(从未授权的发送源 IP 地址试图访问)等列举的情况都可能是发生 403 的原因。

状态码:404 Not Found

含义:该状态吗表明服务器上无法找到请求的资源。

HTTP CODE 5xx

状态码:500 Intertnal Server Error

含义:服务器本身发生错误。也有可能是 Web应用存在的 bug 或某些临时的故障

状态码:503 Intertnal Server Error

含义:该状态码表明服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。

web安全漏洞

1. SQL 注入

SQL 注入就是通过给 web 应用接口传入一些特殊字符,达到欺骗服务器执行恶意的 SQL 命令。

SQL 注入漏洞属于后端的范畴,但前端也可做体验上的优化。

原因

当使用外部不可信任的数据作为参数进行数据库的增、删、改、查时,如果未对外部数据进行过滤,就会产生 SQL 注入漏洞。

比如:

1
2
3
name = "外部输入名称";

sql = "select * from users where name=" + name;

上面的 SQL 语句目的是通过用户输入的用户名查找用户信息,因为由于 SQL 语句是直接拼接的,也没有进行过滤,所以,当用户输入 '' or '1'='1' 时,这个语句的功能就是搜索 users 全表的记录。

1
select * from users where name='' or '1'='1';

解决方案

具体的解决方案很多,但大部分都是基于一点:不信任任何外部输入。

1.预编译

1
2
3
4
String sql = "select id, no from user where id=?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setInt(1, id);
ps.executeQuery();

将sql语句预先编译好,也就是SQL引擎会预先进行语法分析,产生语法树,生成执行计划,也就是说,后面你输入的参数,无论你输入的是什么,都不会影响该sql语句的 语法结构了,因为语法分析已经完成了,而语法分析主要是分析sql命令,比如 select ,from ,where ,and, or ,order by 等等。所以即使你后面输入了这些sql命令,也不会被当成sql命令来执行了,因为这些sql命令的执行, 必须先的通过语法分析,生成执行计划,既然语法分析已经完成,已经预编译过了,那么后面输入的参数,是绝对不可能作为sql命令来执行的,只会被当做字符串字面值参数

2.类型检查,只允许某种类型的输入

1
2
3
$uid=checkuid($uid);    //检测$uid是不是数字类型,不是不继续往下运行

$sql = "SELECT uid,username FROM user WHERE uid='{$uid}‘;

这段语句是为了保证了id是数字类型,checkid是一个自定义的函数,但是千万别直接里面写一个is_numeric就结束了,这很容易就可以用16进制或者是科学计数法去绕过的。

3.过滤特殊字符,相当于提前定义黑名单,但出现纰漏还是能绕过去。

2. XSS 攻击

XSS 攻击全称跨站脚本攻击(Cross-Site Scripting),简单的说就是攻击者通过在目标网站上注入恶意脚本并运行,获取用户的敏感信息如 Cookie、SessionID 等,影响网站与用户数据安全。

XSS 攻击更偏向前端的范畴,但后端在保存数据的时候也需要对数据进行安全过滤。


反射型的 XSS 攻击,主要是由于服务端接收到客户端的不安全输入在客户端触发执行从而发起 Web 攻击。

具体而言,反射型 XSS 只是简单地把用户输入的数据 “反射” 给浏览器,这种攻击方式往往需要攻击者诱使用户点击一个恶意链接,或者提交一个表单,或者进入一个恶意网站时,注入脚本进入被攻击者的网站。这是一种非持久型的攻击。

比如:在某购物网站搜索物品,搜索结果会显示搜索的关键词。搜索关键词填入<script>alert('handsome boy')</script>,点击搜索。页面没有对关键词进行过滤,这段代码就会直接在页面上执行,弹出 alert。


基于存储的 XSS 攻击,是通过提交带有恶意脚本的内容存储在服务器上当其他人看到这些内容时发起 Web 攻击。一般提交的内容都是通过一些富文本编辑器编辑的,很容易插入危险代码。

比较常见的一个场景是攻击者在社区或论坛上写下一篇包含恶意 JavaScript 代码的文章或评论,文章或评论发表后,所有访问该文章或评论的用户,都会在他们的浏览器中执行这段恶意的 JavaScript 代码。这是一种持久型的攻击。


基于 DOM 的 XSS 攻击是指通过恶意脚本修改页面的 DOM 结构,是纯粹发生在客户端的攻击

DOM 型 XSS 跟前两种 XSS 的区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞

1
2
3
4
5
<script> 
btn.addEventListener('click', () => {
div.innerHTML = `<a href=${val}>testLink</a>`
}, false);
</script>

点击 Submit 按钮后,会在当前页面插入一个链接,其地址为用户的输入内容。如果用户在输入时构造了如下内容:

1
" onclick=alert(/xss/)

用户提交之后,页面代码就变成了:

1
<a href onlick="alert(/xss/)">testLink</a>

此时,用户点击生成的链接,就会执行对应的脚本。DOM 型 XSS 攻击,实际上就是网站前端 JavaScript 代码本身不够严谨,把不可信的数据当作代码执行了。在使用 .innerHTML.outerHTMLdocument.write() 时要特别小心,不要把不可信的数据作为 HTML 插到页面上

原因

当攻击者通过某种方式向浏览器页面注入了恶意代码,并且浏览器执行了这些代码。

比如:

在一个文章应用中(如微信文章),攻击者在文章编辑后台通过注入 script 标签及 js 代码,后端未加过滤就保存到数据库,前端渲染文章详情的时候也未加过滤,这就会让这段 js 代码执行,引起 XSS 攻击。

防御 XSS 的根本之道

通过前面的介绍可以得知,XSS 攻击有两大要素:

  1. 攻击者提交恶意代码。
  2. 浏览器执行恶意代码。

根本的解决方法:从输入到输出都需要过滤、转义。

对于输入来讲可以编码、转义、过滤;对于输出来讲,可以编码、转义

一些危险的标签也需要禁止,例如: <iframe><script><base><form>

3. CSRF 攻击

[(42条消息) CSRF攻击与防御(写得非常好)_涛歌依旧的博客-CSDN博客](https://blog.csdn.net/stpeace/article/details/53512283#:~:text=防御CSRF攻击: 目前防御 CSRF 攻击主要有三种策略:验证 HTTP Referer 字段;在请求地址中添加 token,根据 HTTP 协议,在 HTTP 头中有一个字段叫 Referer,它记录了该 HTTP 请求的来源地址。)

CSRF 攻击全称跨站请求伪造(Cross-site Request Forgery),简单的说就是攻击者盗用了你的身份,以你的名义发送恶意请求。

原因

一个典型的 CSRF 攻击有着如下的流程:

  • 受害者登录 a.com,并保留了登录凭证(Cookie)
  • 攻击者引诱受害者访问了 b.com
  • b.coma.com 发送了一个请求:a.com/act=xx(浏览器会默认携带 a.com 的 Cookie)
  • a.com 接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求
  • a.com 以受害者的名义执行了 act=xx
  • 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让 a.com 执行了自己定义的操作

受害者 Bob 在银行有一笔存款,通过对银行的网站发送请求 http://bank.example/withdraw?account=bob&amount=1000000&for=bob2 可以使 Bob 把 1000000 的存款转到 bob2 的账号下。通常情况下,该请求发送到网站后,服务器会先验证该请求是否来自一个合法的 session,并且该 session 的用户 Bob 已经成功登陆。

​ 黑客 Mallory 自己在该银行也有账户,他知道上文中的 URL 可以把钱进行转帐操作。Mallory 可以自己发送一个请求给银行:http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory。但是这个请求来自 Mallory 而非 Bob,他不能通过安全认证,因此该请求不会起作用。

​ 这时,Mallory 想到使用 CSRF 的攻击方式,他先自己做一个网站,在网站中放入如下代码: src=”http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory ”,并且通过广告等诱使 Bob 来访问他的网站。当 Bob 访问该网站时,上述 url 就会从 Bob 的浏览器发向银行,而这个请求会附带 Bob 浏览器中的 cookie 一起发向银行服务器。大多数情况下,该请求会失败,因为他要求 Bob 的认证信息。但是,如果 Bob 当时恰巧刚访问他的银行后不久,他的浏览器与银行网站之间的 session 尚未过期,浏览器的 cookie 之中含有 Bob 的认证信息。这时,悲剧发生了,这个 url 请求就会得到响应,钱将从 Bob 的账号转移到 Mallory 的账号,而 Bob 当时毫不知情。等以后 Bob 发现账户钱少了,即使他去银行查询日志,他也只能发现确实有一个来自于他本人的合法请求转移了资金,没有任何被攻击的痕迹。而 Mallory 则可以拿到钱后逍遥法外。

解决方案

(1)验证 HTTP Referer 字段

​ 根据 HTTP 协议,在 HTTP 头中有一个字段叫 Referer,它记录了该 HTTP 请求的来源地址。在通常情况下,访问一个安全受限页面的请求来自于同一个网站,比如需要访问 http://bank.example/withdraw?account=bob&amount=1000000&for=Mallory,用户必须先登陆 bank.example,然后通过点击页面上的按钮来触发转账事件。这时,该转帐请求的 Referer 值就会是转账按钮所在的页面的 URL,通常是以 bank.example 域名开头的地址。而如果黑客要对银行网站实施 CSRF 攻击,他只能在他自己的网站构造请求,当用户通过黑客的网站发送请求到银行时,该请求的 Referer 是指向黑客自己的网站。因此,要防御 CSRF 攻击,银行网站只需要对于每一个转账请求验证其 Referer 值,如果是以 bank.example 开头的域名,则说明该请求是来自银行网站自己的请求,是合法的。如果 Referer 是其他网站的话,则有可能是黑客的 CSRF 攻击,拒绝该请求。

​ 这种方法的显而易见的好处就是简单易行,网站的普通开发人员不需要操心 CSRF 的漏洞,只需要在最后给所有安全敏感的请求统一增加一个拦截器来检查 Referer 的值就可以。特别是对于当前现有的系统,不需要改变当前系统的任何已有代码和逻辑,没有风险,非常便捷。

​ 然而,这种方法并非万无一失。Referer 的值是由浏览器提供的,虽然 HTTP 协议上有明确的要求,但是每个浏览器对于 Referer 的具体实现可能有差别,并不能保证浏览器自身没有安全漏洞。使用验证 Referer 值的方法,就是把安全性都依赖于第三方(即浏览器)来保障,从理论上来讲,这样并不安全。事实上,对于某些浏览器,比如 IE6 或 FF2,目前已经有一些方法可以篡改 Referer 值。如果 bank.example 网站支持 IE6 浏览器,黑客完全可以把用户浏览器的 Referer 值设为以 bank.example 域名开头的地址,这样就可以通过验证,从而进行 CSRF 攻击。

即便是使用最新的浏览器,黑客无法篡改 Referer 值,这种方法仍然有问题。因为 Referer 值会记录下用户的访问来源,有些用户认为这样会侵犯到他们自己的隐私权,特别是有些组织担心 Referer 值会把组织内网中的某些信息泄露到外网中。因此,用户自己可以设置浏览器使其在发送请求时不再提供 Referer。当他们正常访问银行网站时,网站会因为请求没有 Referer 值而认为是 CSRF 攻击,拒绝合法用户的访问。

(2)在请求地址中添加 token 并验证

​ CSRF 攻击之所以能够成功,是因为黑客可以完全伪造用户的请求,该请求中所有的用户验证信息都是存在于 cookie 中,因此黑客可以在不知道这些验证信息的情况下直接利用用户自己的 cookie 来通过安全验证。要抵御 CSRF,关键在于在请求中放入黑客所不能伪造的信息,并且该信息不存在于 cookie 之中。可以在 HTTP 请求中以参数的形式加入一个随机产生的 token,并在服务器端建立一个拦截器来验证这个 token,如果请求中没有 token 或者 token 内容不正确,则认为可能是 CSRF 攻击而拒绝该请求。

​ 这种方法要比检查 Referer 要安全一些,token 可以在用户登陆后产生并放于 session 之中,然后在每次请求时把 token 从 session 中拿出,与请求中的 token 进行比对,但这种方法的难点在于如何把 token 以参数的形式加入请求。对于 GET 请求,token 将附在请求地址之后,这样 URL 就变成 http://url?csrftoken=tokenvalue。 而对于 POST 请求来说,要在 form 的最后加上 <input type="hidden" name="csrftoken" value="tokenvalue"/>,这样就把 token 以参数的形式加入请求了。但是,在一个网站中,可以接受请求的地方非常多,要对于每一个请求都加上 token 是很麻烦的,并且很容易漏掉,通常使用的方法就是在每次页面加载时,使用 javascript 遍历整个 dom 树,对于 dom 中所有的 a 和 form 标签后加入 token。这样可以解决大部分的请求,但是对于在页面加载之后动态生成的 html 代码,这种方法就没有作用,还需要程序员在编码时手动添加 token。

​ 该方法还有一个缺点是难以保证 token 本身的安全。特别是在一些论坛之类支持用户自己发表内容的网站,黑客可以在上面发布自己个人网站的地址。由于系统也会在这个地址后面加上 token,黑客可以在自己的网站上得到这个 token,并马上就可以发动 CSRF 攻击。为了避免这一点,系统可以在添加 token 的时候增加一个判断,如果这个链接是链到自己本站的,就在后面添加 token,如果是通向外网则不加。不过,即使这个 csrftoken 不以参数的形式附加在请求之中,黑客的网站也同样可以通过 Referer 来得到这个 token 值以发动 CSRF 攻击。这也是一些用户喜欢手动关闭浏览器 Referer 功能的原因。

​ (3) 验证码,验证码提供一个操作确认,那用户就知道这个网站背后在搞什么鬼。

4.SSRF 攻击

服务端请求伪造(Server-Side Request Forgery),指的是攻击者在未能取得服务器所有权限时,利用服务器漏洞以服务器的身份发送一条构造好的请求给服务器所在内网。SSRF攻击通常针对外部网络无法直接访问的内部系统。

很多web应用都提供了从其他的服务器上获取数据的功能。使用指定的URL,web应用便可以获取图片,下载文件,读取文件内容等。SSRF的实质是利用存在缺陷的web应用作为代理攻击远程和本地的服务器。一般情况下, SSRF攻击的目标是外网无法访问的内部系统,黑客可以利用SSRF漏洞获取内部系统的一些信息(正是因为它是由服务端发起的,所以它能够请求到与它相连而与外网隔离的内部系统)。SSRF形成的原因大都是由于服务端提供了从其他服务器应用获取数据的功能且没有对目标地址做过滤与限制。

攻击者想要访问主机B上的服务,但是由于存在防火墙或者主机B是属于内网主机等原因导致攻击者无法直接访问主机B。而服务器A存在SSRF漏洞,这时攻击者可以借助服务器A来发起SSRF攻击,通过服务器A向主机B发起请求,从而获取主机B的一些信息。

img

案例:

1.探测内部主机端口信息

提交参数值为url:port,根据返回错误不同,可对内网状态进行探测如端口开放状态等。访问一个可以访问的IP:PORT,如http://127.0.0.1:7001。根据返回错误不同,可对内网状态进行探测如端口开放状态等。

当我们访问一个不存在的端口时,比如 http://127.0.0.1:7000,将会返回:could not connect over HTTP to server

当我们访问存在的端口时,比如 http://127.0.0.1:7001。可访问的端口将会得到错误,一般是返回status code

2.获取内网主机敏感信息

在服务器上有一个ssrf.php的页面,该页面的功能是获取URL参数,然后将URL的内容显示到网页页面上。我们访问该链接:http://127.0.0.1/ssrf.php?url=http://127.0.0.1/test.php ,它会将test.php页面显示

可以将URL参数换成内网的地址,则会泄露服务器内网的信息。将URL换成file://的形式,就可以读取本地文件。

img

如何防御SSRF

1、限制ip如127.0.0.1

2、禁用除http和https外的协议,如:file://gopher://dict://等。

3、限制请求的端口为http常用的端口,如 80、443、8080。

4、统一错误信息,避免用户可以根据错误信息来判断远程服务器的端口状态。

5、对请求地址设置白名单或者限制内网IP,以防止对内网进行攻击。

5. DDoS 攻击

DoS 攻击全称拒绝服务(Denial of Service),简单的说就是让一个公开网站无法访问,而 DDoS 攻击(分布式拒绝服务 Distributed Denial of Service)是 DoS 的升级版。

这个就完全属于后端的范畴了。

原因

攻击者不断地提出服务请求,让合法用户的请求无法及时处理,这就是 DoS 攻击。

攻击者使用多台计算机或者计算机集群进行 DoS 攻击,就是 DDoS 攻击。

解决方案

防止 DDoS 攻击的基本思路是限流,限制单个用户的流量(包括 IP 等)。

高并发

好文:我没有高并发项目经验,但是面试的时候经常被问到高并发、性能调优方面的问题,有什么办法可以解决吗? - 知乎 (zhihu.com)

通用的设计方法

通用的设计方法主要是从「纵向」和「横向」两个维度出发,俗称高并发处理的两板斧:纵向扩展)和横向扩展。

纵向扩展(scale-up)

它的目标是提升单机的处理能力,方案又包括:

1、提升单机的硬件性能:通过增加内存、 CPU核数、存储容量、或者将磁盘 升级成SSD 等堆硬 件 的 方 式 来 提升 。

2、提升单机的软件性能:使用缓存减少IO次数,使用并发或者异步的方式增加吞吐量。

横向扩展(scale-out)

因为单机性能总会存在极限,所以最终还需要引入横向扩展,通过集群部署以进一步提高并发处理能力,又包括以下2个方向:

1、做好分层架构:这是横向扩展的前提,因为高并发系统往往业务复杂,通过分层处理可以简化复杂问题,更容易做到横向扩展。

img

上面这种图是互联网最常见的分层架构,当然真实的高并发系统架构会在此基础上进一步完善。比如会做动静分离并引入CDN,反向代理层可以是LVS+Nginx,Web层可以是统一的API网关,业务服务层可进一步按垂直业务做微服务化,存储层可以是各种异构数据库。

2、各层进行水平扩展:无状态水平扩容,有状态做分片路由。业务集群通常能设计成无状态的,而数据库和缓存往往是有状态的,因此需要设计分区键做好存储分片,当然也可以通过主从同步、读写分离)的方案提升读性能。

具体的实践方案

高性能的实践方案

集群、并行计算(多线程、池化)、缓存(多级、预热)减少IO、异步化

1、集群部署,通过负载均衡减轻单机压力。

2、多级缓存,包括静态数据使用CDN、本地缓存、分布式缓存等,以及对缓存场景中的热点key、缓存穿透、缓存并发、数据一致性等问题的处理。

3、分库分表和索引优化,以及借助搜索引擎解决复杂查询问题。

4、考虑NoSQL数据库的使用,比如HBase、TiDB等,但是团队必须熟悉这些组件,且有较强的运维能力。

5、异步化,将次要流程通过多线程、MQ、甚至延时任务进行异步处理。

6、限流,需要先考虑业务是否允许限流(比如秒杀场景是允许的),包括前端限流、Nginx接入层的限流、服务端的限流。

7、对流量进行 削峰填谷 ,通过 MQ承接流量。

8、并发处理,通过多线程将串行逻辑并行化。

9、预计算,比如抢红包场景,可以提前计算好红包金额缓存起来,发红包时直接使用即可。

10、 缓存预热 ,通过异步 任务 提前 预热数据到本地缓存或者分布式缓存中。

11、减少IO次数,比如数据库和缓存的批量读写、RPC的批量接口支持、或者通过冗余数据的方式干掉RPC调用。

12、减少IO时的数据包大小,包括采用轻量级的通信协议、合适的数据结构、去掉接口中的多余字段、减少缓存key的大小、压缩缓存value等。

13、程序逻辑优化,比如将大概率阻断执行流程的判断逻辑前置、For循环的计算逻辑优化,或者采用更高效的算法。

14、各种池化技术的使用和池大小的设置,包括HTTP请求池、线程池(考虑CPU密集型还是IO密集型设置核心参数)、数据库和Redis连接池等。

15、JVM优化,包括新生代和老年代的大小、GC算法的选择等,尽可能减少GC频率和耗时。

16、锁选择,读多写少的场景用乐观锁,或者考虑通过分段锁的方式减少锁冲突。

上述方案无外乎从计算和 IO 两个维度考虑所有可能的优化点,需要有配套的监控系统实时了解当前的性能表现,并支撑你进行性能瓶颈分析,然后再遵循二八原则,抓主要矛盾进行优化。

高可用的实践方案

1、对等节点的故障转移,Nginx和服务治理框架均支持一个节点失败后访问另一个节点。

2、非对等节点的故障转移,通过心跳检测并实施主备切换(比如redis的哨兵模式或者集群模式、MySQL的主从切换等)。

3、接口层面的超时设置、重试策略和幂等设计。

4、降级处理:保证核心服务,牺牲非核心服务,必要时进行熔断;或者核心链路出问题时,有备选链路。

5、限流处理:对超过系统处理能力的请求直接拒绝或者返回错误码。

6、MQ场景的消息可靠性保证,包括producer端的重试机制、broker侧的持久化、consumer端的ack机制)等。

7、灰度发布,能支持按机器维度进行小流量部署,观察系统日志)和业务指标,等运行平稳后再推全量。

8、监控报警:全方位的监控体系,包括最基础的CPU、内存、磁盘、网络的监控,以及Web服务器、JVM、数据库、各类中间件的监控和业务指标的监控。

9、灾备演练:类似当前的“混沌工程”,对系统进行一些破坏性手段,观察局部故障是否会引起可用性问题。

高可用的方案主要从冗余、取舍、系统运维3个方向考虑,同时需要有配套的值班机制和故障处理流程,当出现线上问题时,可及时跟进处理。

高扩展的实践方案

1、合理的分层架构:比如上面谈到的互联网最常见的分层架构,另外还能进一步按照数据访问层、业务逻辑层对微服务做更细粒度的分层(但是需要评估性能,会存在网络多一跳的情况)。

2、存储层的拆分:按照业务维度做垂直拆分、按照数据特征维度进一步做水平拆分(分库分表)。

3、业务层的拆分:最常见的是按照业务维度拆(比如电商场景的商品服务、订单服务等),也可以按照核心接口和非核心接口拆,还可以按照请求源拆(比如To C和To B,APP和H5 )。

高并发确实是一个复杂且系统性的问题,如果业务场景不同,高并发的落地方案也会存在差异,但是总体的设计思路和可借鉴的方案基本类似。

高并发设计同样要秉承架构设计的3个原则:简单、合适和演进。” 过早的优化是万恶之源 “,不能脱离业务的实际情况,更不要过度设计,合适的方案就是最完美的。


  • 多进程:指在同一个时间里,同一个计算机系统中允许两个或两个以上的进程处于运行状态。多进程可以提高系统的并发能力,但也会消耗更多的资源。
  • 多线程:指在一段完整的代码中,利用多个独立运行的程序片段(线程)来完成多项任务。多线程可以提高资源使用效率和系统性能,但也会带来同步和安全问题。
  • 异步IO:指操作系统在接收到IO请求后,不需要等待IO操作完成就返回给用户程序,而是在IO操作完成后再通知用户程序。异步IO可以避免用户程序阻塞等待IO结果,提高响应速度和吞吐量。
  • 缓存:指将数据或计算结果存储在内存或其他快速访问的介质中,以减少重复读取或计算的开销。缓存可以显著提高系统性能和用户体验,但也需要考虑缓存失效、更新、一致性等问题。
  • 负载均衡:指将请求或任务分配到多个服务器或节点上,以实现负载平衡、故障转移、扩展性等目标。负载均衡可以提高系统可用性和扩展性,但也需要考虑负载均衡算法、策略、状态等问题。
  • 集群:指将多台服务器或节点组织成一个逻辑单元,以实现高可用、高性能、高扩展等目标。集群可以提高系统容错能力和并发能力,但也需要考虑集群管理、协调、通信等问题。
  • 无状态:指服务不保存任何客户端请求相关的数据或状态信息,而是根据每次请求携带的全部信息进行处理。无状态可以简化服务逻辑和部署方式,提高服务可扩展性和可维护性。
  • 微服务:指将一个大型复杂的应用拆分成若干个小型独立的服务,每个服务负责一个特定功能,并通过轻量级协议进行通信。微服务可以提高应用模块化、灵活性、可测试性等优点,但也需要考虑服务划分、治理、监控等问题。
  • 消息队列:指使用先进先出(FIFO)原则对消息进行排队处理,并允许多个生产者发送消息给多个消费者接收消息的机制。消息队列可以实现异步处理、解耦合、流量削峰等功能,但也需要考虑消息可靠性、顺序性、延迟性等问题。

负载均衡

负载均衡器:DNS、硬件、LVS、Nginx该如何搭配? - 知乎 (zhihu.com)

DNS负载均衡的原理是利用DNS系统本身的分布式特性,将同一个域名解析为不同的IP地址,从而将用户请求分发到不同的服务器上,实现负载均衡。

DNS负载均衡的优点是实现简单,成本低,无需额外的设备或软件。DNS负载均衡的缺点是服务器故障切换延迟大,流量调度不均衡,流量分配策略太简单。

软件负载均衡的原理是在普通的服务器上运行负载均衡软件,实现负载均衡功能。常见的软件负载均衡有Nginx、HAproxy、LVS等。

软件负载均衡的优点是可扩展性强,灵活性高,成本低。软件负载均衡的缺点是性能不如硬件负载均衡,对服务器资源消耗较大,配置和管理相对复杂。

nginx和lvs是两种常用的软件负载均衡,它们的实现原理有以下区别:

  • nginx是基于应用层的负载均衡,它通过代理模式,将用户请求转发到后端服务器,并将响应结果返回给用户。lvs是基于网络层的负载均衡,它通过修改数据包的目标地址,将用户请求直接发送到后端服务器,并让后端服务器直接返回响应结果给用户。
  • nginx支持多种负载均衡算法,如轮询、加权轮询、最少连接、ip_hash等。lvs支持三种负载均衡模式,如NAT、DR、TUN等。
  • nginx可以对请求和响应进行处理,如缓存、压缩、重写等。 lvs只对数据包进行转发,不对内容进行处理。
  • nginx可以检测后端服务器的健康状态,并在故障时自动切换。lvs需要配合keepalived或其他工具来实现健康检查和故障切换。
  • nginx消耗较多的服务器资源,性能受限于代理模式和IO操作。lvs消耗较少的服务器资源,性能较高且稳定。

消息队列

深入消息队列MQ,看这篇就够了! - 知乎 (zhihu.com)

十道经典消息队列面试题 - 知乎 (zhihu.com)

base理论适用场景

分布式之 BASE理论 - 码农教程 (manongjc.com)

base理论的详细解释如下:

  • 基本可用(Basically Available):基本可用是指分布式系统在出现故障时,允许损失部分可用性,即保证核心功能可用。例如,eBay在出现网络分区时,可以关闭部分非核心功能,如评论、推荐等,保证用户可以正常浏览和购买商品。
  • 软状态(Soft State):软状态是指分布式系统中的数据存在中间状态,并且该状态不会影响系统整体可用性。例如,微博中的用户关注关系,在缓存和数据库之间可能存在不一致的情况,但这并不影响用户查看自己或者其他人的微博。
  • 最终一致性(Eventually Consistent):最终一致性是指分布式系统中的数据在经过一定时间或者一定操作后,最终能够达到一致的状态。例如,淘宝中的订单状态,在用户付款后可能需要经过多个步骤才能更新为已付款,但最终会达到与支付宝一致的状态。

场景:

  • eBay:eBay使用了多个数据库来存储不同类型的数据,例如商品信息、用户信息、交易信息等。eBay允许数据在不同数据库之间存在不一致的情况,例如用户在一个数据库中修改了地址,但在另一个数据库中还没有更新。这就是基本可用的体现,即eBay保证了用户可以正常浏览和购买商品,但不保证所有的数据都是最新的。eBay通过异步消息和定期同步的方式来保证数据最终一致,即经过一段时间后,所有的数据库都会收到用户修改地址的消息,并更新相应的数据。这就是软状态和最终一致性的体现,即eBay允许数据存在中间状态,并且该状态不会影响系统整体可用性。
  • 微博:微博使用了分布式缓存和数据库来存储用户的微博和关注关系。微博允许用户在缓存中看到自己发表的微博,但其他用户可能需要等待一段时间才能看到。这就是基本可用的体现,即微博保证了用户可以正常发表和查看自己或者其他人的微博,但不保证所有的用户都能实时看到最新的微博。微博通过缓存失效和后台同步的方式来保证数据最终一致,即经过一段时间后,所有的缓存都会失效,并从数据库中获取最新的数据。这就是软状态和最终一致性的体现,即微博允许数据存在中间状态,并且该状态不会影响系统整体可用性。
  • 淘宝:淘宝使用了分布式事务服务来处理订单、支付、库存等业务。淘宝允许订单在不同状态之间存在短暂的不一致,例如用户付款后,订单状态可能还没有及时更新。这就是基本可用的体现,即淘宝保证了用户可以正常下单和付款,但不保证所有的订单都能实时反映支付情况。淘宝通过补偿机制和人工干预的方式来保证数据最终一致,即经过一定操作或者时间后,所有的订单都会达到与支付宝一致

缓存穿透、击穿、雪崩

缓存穿透

如果出现以下这两种特殊情况,比如:

  1. 用户请求的id在缓存中不存在。
  2. 恶意用户伪造不存在的id发起请求。

这样的用户请求导致的结果是:每次从缓存中都查不到数据,而需要查询数据库,同时数据库中也没有查到该数据,也没法放入缓存。也就是说,每次这个用户请求过来的时候,都要查询一次数据库

很显然,缓存根本没起作用,好像被穿透了一样,每次都会去访问数据库。

解决手段:

  • 校验:可以对用户id做检验。比如合法id是15xxxxxx,以15开头的。如果用户传入了16开头的id,比如:16232323,则参数校验失败,直接把相关请求拦截掉。这样可以过滤掉一部分恶意伪造的用户id
  • 把空对象缓存起来。当第一次从数据库中查询出来的结果为空时,我们就将这个空对象加载到缓存,并设置合理的过期时间,这样,就能够在一定程度上保障后端数据库的安全。但是缓存空对象要占用缓存空间,这种空对象太多了性能就不高。
  • 布隆过滤器,布隆过滤器可以针对大数据量的、有规律的键值进行处理。一条记录是不是存在,本质上是一个 Bool 值,只需要使用 1bit 就可以存储。我们可以使用布隆过滤器将这种表示是、否等操作,压缩到一个数据结构中。

布隆过滤器第一次初始化的时候,会把数据库中所有已存在的key,经过一些列的hash算法(比如:三次hash算法)计算,每个key都会计算出多个位置,然后把这些位置上的元素值设置成1。

img

之后,有用户key请求过来的时候,再用相同的hash算法计算位置。

  • 如果多个位置中的元素值都是1,则说明该key在数据库中已存在。这时允许继续往后面操作。
  • 如果有1个以上的位置上的元素值是0,则说明该key在数据库中不存在。这时可以拒绝该请求,而直接返回。
  1. 存在误判的情况。 因为是哈希,有冲突。
  2. 存在数据更新问题。如果增加了一个用户,但是同步布隆过滤器的过程中网络异常了,同步失败,那么接下来用户的请求就会被拦截。

所以布隆过滤器也要看业务需求。

缓存击穿

我们在访问热点数据时,比如我们在某个商城购买某个热门商品,为了保证访问速度,通常情况下,商城系统会把商品信息放到缓存中。但如果某个时刻,该商品到了过期时间失效了。此时,如果有大量的用户请求同一个商品,但该商品在缓存中失效了,一下子这些用户请求都直接怼到数据库,可能会造成瞬间数据库压力过大,而直接挂掉。

img

解决办法:

  • 对于比较热点的数据,我们可以在缓存中设置这些数据永不过期(热点过去后再手动释放);
  • 自动续期:本质上也是保证缓存不失效。可以用job给指定key自动续期。比如说,我们有个分类功能,设置的缓存过期时间是30分钟。但有个job每隔20分钟执行一次,自动更新缓存,重新设置过期时间为30分钟。
  • 使用分布式锁,保证对于每个 Key 同时只有一个线程去查询后端的服务,某个线程在查询后端服务的同时,其他线程没有获得分布式锁的权限,需要进行等待。不过在高并发场景下,这种解决方案对于分布式锁的访问压力比较大。

缓存雪崩

缓存雪崩是缓存击穿的升级版,缓存击穿说的是某一个热门key失效了,而缓存雪崩说的是有多个热门key同时失效

缓存雪崩目前有两种:

  1. 有大量的热门缓存,同时失效。会导致大量的请求,访问数据库。而数据库很有可能因为扛不住压力,而直接挂掉。
  2. 缓存服务器down机了,可能是机器硬件问题,或者机房网络问题。总之,造成了整个缓存的不可用。

归根结底都是有大量的请求,透过缓存,而直接访问数据库了。

img

解决办法:

  • 高可用:针对缓存服务器down机的情况,在前期做系统设计时,可以做一些高可用架构。比如可以用缓存集群等,避免出现单节点故障导致整个服务不可用的情况。

  • 过期时间加上随机数:可以在设置的过期时间基础上,再加个160秒的随机数。实际过期时间 = 过期时间 + 160秒的随机数这样即使在高并发的情况下,多个请求同时设置过期时间,由于有随机数的存在,也不会出现太多相同的过期key。

  • 服务降级:我们需要配置一些默认的兜底数据。程序中有个全局开关,比如有10个请求在最近一分钟内,从redis中获取数据失败,则全局开关打开。后面的新请求,就直接从配置中心中获取默认的数据。

    img

微服务雪崩

【原创】谈谈服务雪崩、降级与熔断 - 孤独烟 - 博客园 (cnblogs.com)

假设存在如下调用链
img

而此时,Service A的流量波动很大,流量经常会突然性增加!那么在这种情况下,就算Service A能扛得住请求,Service BService C未必能扛得住这突发的请求。
此时,如果Service C因为抗不住请求,变得不可用。那么Service B的请求也会阻塞,慢慢耗尽Service B的线程资源,Service B就会变得不可用。紧接着,Service A也会不可用,这一过程如下图所示
img

如上图所示,一个服务失败,导致整条链路的服务都失败的情形,我们称之为服务雪崩。

服务熔断

服务熔断:当下游的服务因为某种原因突然变得不可用响应过慢,上游服务为了保证自己整体服务的可用性,不再继续调用目标服务,直接返回,快速释放资源。如果目标服务情况好转则恢复调用。

服务熔断重点在“”,切断对下游服务的调⽤,直接返回错误信息或其他信息。

服务降级

这里有两种场景:

  • 当下游的服务因为某种原因响应过慢,下游服务主动停掉一些不太重要的业务,释放出服务器资源,增加响应速度!
  • 当下游的服务因为某种原因不可用,上游主动调用本地的一些降级逻辑,避免卡顿,迅速返回给用户!

其实乍看之下,很多人还是不懂熔断和降级的区别!

其实应该要这么理解:

  • 服务降级有很多种降级方式!如开关降级、限流降级、熔断降级!
  • 服务熔断属于降级方式的一种!

从实现上来说,熔断和降级必定是一起出现。因为当发生下游服务不可用的情况,这个时候为了对最终用户负责,就需要进入上游的降级逻辑了。因此,将熔断降级视为降级方式的一种,也是可以说的通的。

服务降级大多是属于一种业务级别的处理。这里要讲的是另一种降级方式,也就是开关降级。这也是我们生产上常用的另一种降级方式。

做法很简单,做个开关,然后将开关放配置中心。在配置中心更改开关,决定哪些服务进行降级。那么,在应用程序中部下开关的这个过程,业内也有一个名词,称为埋点

那接下来最关键的一个问题,哪些业务需要埋点?

(1)简化执行流程:自己梳理出核心业务流程和非核心业务流程。然后在非核心业务流程上加上开关,一旦发现系统扛不住,关掉开关,结束这些次要流程。

(2)关闭次要功能:一个微服务下肯定有很多功能,那自己区分出主要功能和次要功能。然后次要功能加上开关,需要降级的时候,把次要功能关了吧!

(3)降低一致性:假设业务上发现执行流程没法简化了,也没啥次要功能可以关了,那只能降低一致性了,即将核心业务流程的同步改异步,将强一致性改最终一致性

服务限流

限流是指上游服务对本服务请求 QPS 超过阙值时,通过一定的策略(如延迟处理、拒绝处理)对上游服务的请求量进行限制,以保证本服务不被压垮,从而持续提供稳定服务。常见的限流算法有滑动窗口、令牌桶、漏桶等。

1.计算器方式(滑动计数器):定义一个原子类,针对于某一个服务实现次数记录,一旦达到阈值之后,这时候可以直接走服务降级(返回一个友好提示给客户端)。

举个例子:限制每60秒内只能接受客户端10个请求,如果超过10个请求则直接拒绝访问服务。固定速率 10R/M。

滑动窗口计数器算法原理:创建6个独立的格子,每个格子都有自己独立的计数器。每个格子独立计数10秒。

2.令牌桶算法(Token):令牌桶分为2个动作,动作1(固定速率往桶中存入令牌)、动作2(客户端如果想访问请求,先从桶中获取token)。

  1. 漏桶算法

以固定速率从桶中流出水滴,以任意速率往桶中放入水滴,桶容量大小是不会发生改变的。

流入:以任意速率往桶中放入水滴。

流出:以固定速率从桶中流出水滴。

水滴:是唯一不重复的标识。

因为桶中的容量是固定的,如果流入水滴的速率>流出的水滴速率,桶中的水滴可能会溢出。那么溢出的水滴请求都是拒绝访问的,或者直接调用服务降级方法。前提是同一时刻。

限流的目的:为了保护服务,避免服务宕机。

措施 产生原因 针对服务
熔断 下游服务不可用 下游服务
降级 自身服务的处理能力不够 自身服务
限流 上游服务请求增多 上游服务

用户密码应该如何存储

用户密码到底要怎么加密存储? - 知乎 (zhihu.com)

用户密码保存到数据库时,常见的加密方式有哪些,我们该采用什么方式来保护用户的密码呢?以下几种方式是常见的密码保存方式:

1、直接明文保存,比如用户设置的密码是“123456”,直接将“123456”保存在数据库中,这种是最简单的保存方式,也是最不安全的方式。但实际上不少互联网公司,都可能采取的是这种方式。

2、使用对称加密算法来保存,比如3DES、AES等算法,使用这种方式加密是可以通过解密来还原出原始密码的,当然前提条件是需要获取到密钥。不过既然大量的用户信息已经泄露了,密钥很可能也会泄露,当然可以将一般数据和密钥分开存储、分开管理,但要完全保护好密钥也是一件非常复杂的事情,所以这种方式并不是很好的方式。

img

3、使用MD5、SHA1等单向HASH算法保护密码,使用这些算法后,无法通过计算还原出原始密码,而且实现比较简单,因此很多互联网公司都采用这种方式保存用户密码,曾经这种方式也是比较安全的方式,但随着彩虹表技术的兴起,可以建立彩虹表进行查表破解,目前这种方式已经很不安全了。

img

彩虹表就是把简单的数字密码组合(和各种常见密码)的哈希先尽可能的计算出来,这些明文和哈希结果的对应关系就是一张彩虹表。由于大家喜欢使用简单好记的密码,所以试着计算出一个常用范围内的所有字母组合的哈希的彩虹表,可以破解绝大多数人的密码。当彩虹表足够大时,这种存储方式实际上与明文无异。

4、特殊的单向HASH算法,由于单向HASH算法在保护密码方面不再安全,于是有些公司在单向HASH算法基础上进行了加盐

加盐哈希是目前业界最常见的做法。

加盐哈希的步骤如下:

  • 用户注册时,给他随机生成一段字符串,这段字符串就是(Salt)
  • 把用户注册输入的密码和盐拼接在一起,叫做加盐密码
  • 对加盐密码进行哈希,并把结果和盐都储存起来

在登陆时,先取出盐,再同样进行拼接、计算哈希,就能判断密码的合法性。

加盐哈希的做法,既保证了储存数据的不可逆,又防止了上一章的彩虹表攻击方式。这种方式下,黑客拿到数据库后,如果再要用遍历所有常用的密码组合的方式做彩虹表,那他需要对所有常用密码+盐值进行哈希运算。而每个用户的盐值都不相同,之前彩虹表的「一次运算无数次使用」变成了「一次运算一次使用」。这样的成本是难以接受的,由于攻击成本远高于收益,系统达到相对安全,所以这是一个比较安全的做法。

img

5、PBKDF2算法,该算法原理大致相当于在HASH算法基础上增加随机盐,并进行多次HASH运算,随机盐使得彩虹表的建表难度大幅增加,而多次HASH也使得建表和破解的难度都大幅增加。使用PBKDF2算法时,HASH算法一般选用sha1或者sha256,随机盐的长度一般不能少于8字节,HASH次数至少也要1000次,这样安全性才足够高。

img

一次密码验证过程进行1000次HASH运算,对服务器来说可能只需要1ms,但对于破解者来说计算成本增加了1000倍,而至少8字节随机盐,更是把建表难度提升了N个数量级,使得大批量的破解密码几乎不可行,该算法也是美国国家标准与技术研究院推荐使用的算法。

6、bcrypt、scrypt等算法,这两种算法也可以有效抵御彩虹表,使用这两种算法时也需要指定相应的参数,使破解难度增加。

下表对比了各个算法的特性:

img

软件工程

类图关系

在UML类图中表示具体类

具体类在类图中用矩形框表示,矩形框分为三层:第一层是类名字。第二层是类的成员变量;第三层是类的方法。成员变量以及方法前的访问修饰符用符号来表示:

  • “+”表示 public
  • “-”表示 private
  • “#”表示 protected
  • 不带符号表示 default

img

在UML类图中表示抽象类

抽象类在UML类图中同样用矩形框表示,但是抽象类的类名以及抽象方法的名字都用斜体字表示,如图所示。

img

在UML类图中表示接口

接口在类图中也是用矩形框表示,但是与类的表示法不同的是,接口在类图中的第一层顶端用构造型 <>表示,下面是接口的名字,第二层是方法,如图所示。此外,接口还有另一种表示法,俗称棒棒糖表示法,就是类上面的一根棒棒糖(圆圈+实线)。圆圈旁为接口名称,接口方法在实现类中出现。

img

在类图中表示关系

类和类、类和接口、接口和接口之间存在一定关系,UML类图中一般会有连线指明它们之间的关系。关系共有六种类型,分别是实现关系、泛化关系、关联关系、依赖关系、聚合关系、组合关系,如图所示。

img

实现关系

实现关系是指接口及其实现类之间的关系。在UML类图中,实现关系用空心三角和虚线组成的箭头来表示,从实现类指向接口。

img

泛化关系

泛化关系(Generalization)是指对象与对象之间的继承关系。如果对象A和对象B之间的“is a”关系成立,那么二者之间就存在继承关系,对象B是父对象,对象A是子对象。例如,一个年薪制员工“is a”员工,很显然年薪制员工Salary对象和员工Employee对象之间存在继承关系,Employee对象是父对象,Salary对象是子对象。

在UML类图中,泛化关系用空心三角和实线组成的箭头表示,从子类指向父类。

img

关联关系

关联关系(Association)是指对象和对象之间的连接,它使一个对象知道另一个对象的属性和方法。也就是说,如果一个对象的类代码中,包含有另一个对象的引用,那么这两个对象之间就是关联关系。

关联关系有单向关联和双向关联。如果两个对象都知道(即可以调用)对方的公共属性和操作,那么二者就是双向关联。如果只有一个对象知道(即可以调用)另一个对象的公共属性和操作,那么就是单向关联。大多数关联都是单向关联,单向关联关系更容易建立和维护,有助于寻找可重用的类

在UML图中,双向关联关系用带双箭头的实线或者无箭头的实线双线表示。单向关联用一个带箭头的实线表示,箭头指向被关联的对象。这就是导航性(Navigatity)。

img

一个对象可以持有其它对象的数组或者集合。在UML中,通过放置多重性(multipicity)表达式在关联线的末端来表示。多重性表达式可以是一个数字、一段范围或者是它们的组合。多重性允许的表达式示例如下:

  • 数字:精确的数量
  • *或者0..*:表示0到多个
  • 0..1:表示0或者1个
  • 1..*:表示1到多个

关联关系又分为依赖关联、聚合关联和组合关联三种类型。

依赖关系

依赖(Dependency)关系是一种弱关联关系。如果对象A用到对象B,但是和B的关系不是太明显的时候,就可以把这种关系看作是依赖关系。如果对象A依赖于对象B,则 A “use a” B。比如驾驶员和汽车的关系,驾驶员使用汽车,二者之间就是依赖关系。

在UML类图中,依赖关系用一个带虚线的箭头表示,由使用方指向被使用方,表示使用方对象持有被使用方对象的引用,如图所示。

img

依赖关系在具体代码表现形式为B为A的构造器方法中的局部变量方法或构造器的参数方法的返回值,或者A调用B的静态方法

聚合关系

聚合(Aggregation)是关联关系的一种特例,它体现的是整体与部分的拥有关系,即 “has a” 的关系。此时整体与部分之间是可分离的,它们可以具有各自的生命周期,部分可以属于多个整体对象,也可以为多个整体对象共享,所以聚合关系也常称为共享关系。例如,公司部门与员工的关系,一个员工可以属于多个部门,一个部门撤消了,员工可以转到其它部门。

在UML图中,聚合关系用空心菱形加实线箭头表示,空心菱形在整体一方,箭头指向部分一方,如图所示。

img

组合关系

组合(Composition)也是关联关系的一种特例,它同样体现整体与部分间的包含关系,即 “contains a” 的关系。但此时整体与部分是不可分的部分也不能给其它整体共享,作为整体的对象负责部分的对象的生命周期。这种关系比聚合更强,也称为强聚合。如果A组合B,则A需要知道B的生存周期,即可能A负责生成或者释放B,或者A通过某种途径知道B的生成和释放。

例如,人包含头、躯干、四肢,它们的生命周期一致。当人出生时,头、躯干、四肢同时诞生。当人死亡时,作为人体组成部分的头、躯干、四肢同时死亡。

在UML图中,组合关系用实心菱形加实线箭头表示,实心菱形在整体一方,箭头指向部分一方,如图所示。

img

在实际应用开发时,两个对象之间的关系到底是聚合还是组合,有时候很难区别。在代码中,仅从类代码本身是区分不了聚合和组合的。如果一定要区分,那么如果在删除整体对象的时候,必须删掉部分对象,那么就是组合关系,否则可能就是聚合关系。从业务角度上来看,如果作为整体的对象必须要部分对象的参与,才能完成自己的职责,那么二者之间就是组合关系,否则就是聚合关系。

例如,汽车与轮胎,汽车作为整体,轮胎作为部分。如果用在二手车销售业务环境下,二者之间就是聚合关系。因为轮胎作为汽车的一个组成部分,它和汽车可以分别生产以后装配起来使用,但汽车可以换新轮胎,轮胎也可以卸下来给其它汽车使用。如果用在驾驶系统业务环境上,汽车如果没有轮胎,就无法完成行驶任务,二者之间就是一个组合关系。

再比如网上书店业务中的订单和订单项之间的关系,如果订单没有订单项,也就无法完成订单的业务,所以二者之间是组合关系。而购物车和商品之间的关系,因为商品的生命周期并不被购物车控制,商品可以被多个购物车共享,因此,二者之间是聚合关系。

编程题笔试复盘

米哈游笔试

米哈游遇到一个没见过的题,有思路就挺简单,题目意思是:

给两个字符串s,t,问s能不能通过增加或删除”mhy”这个子序列若干次,变成t。因为是子序列,所以m、h、y不用连续。

如果没见过这道题是话,就容易陷入子序列的顺序性上,实际上序列是“mhy”和“hmy”是一样的,因为“hmy”可以加一个“mhy”变成“mhmyhy”,然后再删掉一个“mhy”就变成了“mhy”。

所以在这道题上,不用考虑顺序,只用考虑出现的次数,如果不这样想是很难很难写的。

总结一下:对于子序列能添加、删除若干次的话,那么可能可以不用考虑这个序列中字符的顺序。

背景

随着信息化的浪潮和互联网的兴起,传统的RDBMS(关系型数据库管理系统)在一些业务上开始出现问题。

  • 首先,对数据库存储的容量要求越来越高,单机无法满足需求,很多时候需要用集群来解决问题,而RDBMS由于要支持join,union等操作,一般不支持分布式集群
  • 其次,在大数据大行其道的今天,很多的数据都“频繁读和增加,不频繁修改”,而RDBMS对所有操作一视同仁,这就带来了优化的空间。
  • 另外,互联网时代业务的不确定性导致数据库的存储模式也需要频繁变更,不自由的存储模式增大了运维的复杂性和扩展的难度。

现在的大数据的特点是,数据维度比较多(宽行),但是每一行数据的却并不是所有信息都具备的,于是就形成稀疏矩阵。如果采取过去的存储方式的话,将会浪费大量的空间,在存储时,需要将没有数据内容置空等(这里的置空也是需要消耗存储空间的,并且也会增加寻址的时间),在小数据量的情况下,这样也没有什么劣势,但是到了大数据情况下,积少成多,便变得明显了起来。

传统的关系型数据库还存在以下缺点:

  • 大数据场景下 I/O 较高 - 因为数据是按行存储,即使只针对其中某一列进行运算,关系型数据库也会将整行数据从存储设备中读入内存,导致 I/O 较高。
  • 存储的是行记录,无法存储数据结构
  • 表结构 schema 扩展不方便 - 如要需要修改表结构,需要执行执行 DDL(data definition language),语句修改,修改期间会导致锁表,部分服务不可用。
  • 存储和处理复杂关系型数据功能较弱 - 许多应用程序需要了解和导航高度连接数据之间的关系,才能启用社交应用程序、推荐引擎、欺诈检测、知识图谱、生命科学和 IT/网络等用例。然而传统的关系数据库并不善于处理数据点之间的关系。它们的表格数据模型和严格的模式使它们很难添加新的或不同种类的关联信息。

理论基础

分布式系统

分布式系统的核心理念是让多台服务器协同工作,完成单台服务器无法处理的任务,尤其是高并发或者大数据量的任务。分布式是NoSQL数据库的必要条件

分布式系统由独立的服务器通过网络松散耦合组成的。每个服务器都是一台独立的PC机,服务器之间通过内部网络连接,内部网络速度一般比较快。因为分布式集群里的服务器是通过内部网络松散耦合,各节点之间的通讯有一定的网络开销,因此分布式系统在设计上尽可能减少节点间通讯。此外,因为网络传输瓶颈,单个节点的性能高低对分布式系统整体性能影响不大。

因此,分布式系统每个节点一般不采用高性能的服务器,而是使用性能相对一般的普通PC服务器。提升分布式系统的整体性能是通过横向扩展(增加更多的服务器),而不是纵向扩展(提升每个节点的服务器性能)实现。

分布式系统最大的特点是可扩展性,它能够适应需求变化而扩展。

CAP理论与BASE理论

关系型数据库一般为了保证事务可靠,需要具备ACID四个特性。ACID,是指数据库管理系统(DBMS)在写入或更新资料的过程中,为保证事务(transaction)是正确可靠的,所必须具备的四个特性:原子性(atomicity,或称不可分割性)、一致性(consistency)、隔离性(isolation,又称独立性)、持久性(durability)。

NoSQL的基本需求就是支持分布式存储,严格一致性与可用性需要互相取舍,由此延伸出了CAP理论来定义分布式存储遇到的问题。CAP理论告诉我们:一个分布式系统不可能同时满足一致性(C:Consistency)、可用性(A:Availability)、分区容错性(P:Partitiontolerance)这三个基本需求,并且最多只能满足其中的两项

  • C – Consistency – 一致性(与ACID的C完全不同,那里的C是保证数据库数据之间规则没有被破坏)

一致性是指“all nodes see the same data at the same time”,即更新操作成功并返回客户端完成后,所有节点在同一时间的数据完全一致。对于一致性,可以分为从客户端和服务端两个不同的视角。

  • 从客户端来看,一致性主要指的是多并发访问时更新过的数据如何获取的问题。
  • 从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。一致性是因为有并发读写才有的问题,因此在理解一致性的问题时,一定要注意结合考虑并发读写的场景。
  • 从客户端角度,多进程并发访问时,更新过的数据在不同进程如何获取的不同策略,决定了不同的一致性。对于关系型数据库,要求更新过的数据能被后续的访问都能看到,这是强一致性。如果能容忍后续的部分或者全部访问不到,则是弱一致性。如果经过一段时间后要求能访问到更新后的数据,则是最终一致性
  • A – Availability – 可用性

可用性是指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。

对于一个可用性的分布式系统,每一个非故障的节点必须对每一个请求作出响应。也就是说,该系统使用的任何算法必须最终终止。当同时要求分区容忍性时,这是一个很强的定义:即使是严重的网络错误,每个请求必须完成。

好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。在通常情况下,可用性与分布式数据冗余、负载均衡等有着很大的关联。

  • P – Partition tolerance – 分区容错性

分区容错性是指“the system continues to operate despite arbitrary message loss or failureof part of the system”,即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。

分区容错性和扩展性紧密相关。在分布式应用中,可能因为一些分布式的原因导致系统无法正常运转。好的分区容错性要求能够使应用虽然是一个分布式系统,但看上去却好像是一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其它剩下的机器还能够正常运转满足系统需求,或者是机器之间有网络异常,将分布式系统分隔成未独立的几个部分,各个部分还能维持分布式系统的运作,这样就具有好的分区容错性。


  • CA without P

如果不要求P(不允许分区),则C(强一致性)和A(可用性)是可以保证的。但其实分区不是你想不想的问题,而是始终会存在,因此CA的系统更多的是允许分区后各子系统依然保持CA

  • CP without A

如果不要求A(可用),相当于每个请求都需要在Server之间强一致,而P(分区)会导致同步时间无限延长,如此CP也是可以保证的。很多传统的数据库分布式事务都属于这种模式。

  • AP without C

要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。现在众多的NoSQL都属于此类。

CAP理论定义了分布式存储的根本问题,但并没有指出一致性和可用性之间到底应该如何权衡。于是出现了BASE理论,给出了权衡A与C的一种可行方案。


权衡一致性与可用性 - BASE理论

Base = Basically Available + Soft state + Eventually consistent 基本可用性+软状态+最终一致性,由eBay架构师DanPritchett提出。Base是对CAP中一致性A和可用性C权衡的结果,源于提出者自己在大规模分布式系统上实践的总结。核心思想是无法做到强一致性,但每个应用都可以根据自身的特点,采用适当方式达到最终一致性。

  • BA - Basically Available - 基本可用

基本可用。这里是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心功能或者当前最重要功能可用。对于用户来说,他们当前最关注的功能或者最常用的功能的可用性将会获得保证,但是其他功能会被削弱。

  • S – Soft State - 软状态

允许系统数据存在中间状态,但不会影响到系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步时存在延时

  • E - Eventually Consistent - 最终一致性

要求系统数据副本最终能够一致,而不需要实时保证数据副本一致。最终一致性是弱一致性的一种特殊情况。

总结:保证核心功能可用+允许同步延时(中间状态)+节点数据最终一致

一致性算法

在一个分布式数据库系统中,如果各节点的初始状态一致,每个节点执行相同的操作序列,那么他们最后能得到一个一致的状态。

通信

节点之间需要进行操作指令的同步,存在两种模型:

  • 共享内存
  • 消息传递

分区

原来所有的数据都是在一个数据库上的,网络IO及文件IO都集中在一个数据库上的,因此CPU、内存、文件IO、网络IO都可能会成为系统瓶颈。而分区的方案就是把某一个表或某几个相关的表的数据放在一个独立的数据库上,这样就可以把CPU、内存、文件IO、网络IO分解到多个机器中,从而提升系统处理能力。

分片

分区有两种模式,一种是主从模式,用于做读写分离;另外一种模式是分片模式,也就是说把一个表中的数据分解到多个表中。一个分区只能是其中的一种模式,要么主从(副本),一个读一个写;要么分片,把数据分解。

一致性哈希

将数据分离后,需要映射到不同的节点上,可用使用一致性哈希,允许动态扩展,每个节点仅需要维护少量相邻节点的信息。

优缺点

关系型数据库的优势:

  1. 便于理解:二维表构造非常贴近逻辑;
  2. 应用方便:支持通用的SQL(结构化查询语言)语句;
  3. 易于维护:全部由表结构组成,文件格式一致;
  4. 事务管理:促使针对安全性性能很高的数据信息浏览规定得到完成。

关系型数据库存在的不足

  1. 读写性能差,尤其是海量信息的效率高读写能力;
  2. 格式不灵活,固定不动的表构造;
  3. 高并发读写时,硬盘I/O存在瓶颈;
  4. 可扩展性不足,不像web server和app server那样简单的添加硬件和服务节点来拓展性能和负荷工作能力(数据之间耦合度高,不易扩展)

非关系型数据库的优点

  1. 格式灵活:数据存储格式非常多样,应用领域广泛,而关系型数据库则只适用基础的关系模型。
  2. 性能优越:NOSQL得益于无关系性,数据结构简单,NoSQL的Cache是记录级的,是一种细粒度的Cache。一般MySQL使用Query Cache,每次表更新Cache就失效,是一种大粒度的Cache,针对web2.0的交互频繁的应用,Cache性能不高。
  3. 可扩展性:数据之间无关系,数据之间耦合度极低,因此容易水平扩展。
  4. 低成本:非关系型数据库部署简易,且大部分可以开源使用。
  5. 高可用:NoSQL在不太影响性能的情况下,就可以方便地实现高可用的架构。

非关系型数据库的不足:

  1. 不支持sql,学习和运用成本比较高;
  2. 特性不够丰富,产品不够成熟。
  3. 不支持事务。

关系型与非关系型数据库的区别:

  1. 成本:Nosql数据库易部署,不用像Oracle那般花费较高成本选购。
  2. 查询速率:Nosql数据库将数据储存于缓存当中,不用历经SQL层的分析;关系型数据库将数据储存在电脑硬盘中,查询速率远不如Nosql数据库。
  3. 储存格式:Nosql的储存文件格式是key,value方式、文本文档方式、照片方式这些,能储存的对象种类灵活;关系数据库则只适用基础类型。
  4. 可扩展性:关系型数据库有join那样的多表查询机制限定造成拓展性较差。Nosql依据键值对,数据中间沒有耦合度,因此容易水平拓展。
  5. 数据一致性:非关系型数据库注重最终一致性;关系型数据库注重数据整个生命周期的强一致性。
  6. 事务处理:SQL数据库支持事务原子性粒度控制,且方便进行事务回滚;NoSQL也支持事务处理,但可靠性不足,其价值在于可扩展性和大数据量处理。

NoSQL与SQL的对比

RDBMS NoSQL
模式 预定义的模式 没有预定义的模式
查询语言 结构化查询语言(SQL) 没有声明性查询语言
一致性 严格的一致性 最终一致性
事务 支持 不支持
理论基础 ACID CAP, BASE
扩展 纵向扩展 横向扩展(分布式)

K-V数据库

K-V 数据库特性

  • 用一个key标识一行数据(一个数据实例)。
  • 在一行数据的列部分,每个列的数据都包含元数据(键),数据(值),时间戳(为了某些其他目的,非必须)。

优点如下:

  • 避免造成数据表稀疏矩阵,高效利用空间。
  • 大数据时代需求变化快,能灵活更改结构。比如新增一个属性,传统数据库模型需要对表中所有行同时增加一列(不需要的置空或者默认),而kv存储只需要在列族中添加一个列信息,找到要修改的一行即可。

缺点如下:

  • 针对 ACID,Redis 事务不能支持原子性和持久性(A 和 D),只支持隔离性和一致性(I 和 C)
  • 特别说明一下,这里所说的无法保证原子性,是针对 Redis 的事务操作,因为事务是不支持回滚(roll back),而因为 Redis 的单线程模型,Redis 的普通操作是原子性的

大部分业务不需要严格遵循 ACID 原则,例如游戏实时排行榜,粉丝关注等场景,即使部分数据持久化失败,其实业务影响也非常小。因此在设计方案时,需要根据业务特征和要求来做选择

K-V 数据库使用场景

  • 适用场景 - 储存用户信息(比如会话)、配置文件、参数、购物车等等。这些信息一般都和 ID(键)挂钩。
  • 不适用场景
  • 需要通过值来查询,而不是键来查询。Key-Value 数据库中根本没有通过值查询的途径
  • 需要储存数据之间的关系。在 Key-Value 数据库中不能通过两个或以上的键来关联数据
  • 需要事务的支持。在 Key-Value 数据库中故障产生时不可以进行回滚

Redis存储简单介绍

redis快的原因

单线程的处理机制

  • 一个主线程负责读写数据,其他附属的线程负责维护 Redis 服务的稳定,单线程的一个好处就是没有线程资源竞争的问题,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。Redis 6.0 支持了多线程,具体大家可以自行了解。

io 的多路复用模型

  • 使用了 epoll 多路复用的模型,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果,本质上就是通过事件监听的方式,如果有个连接没有请求,那就进入休眠状态,让出时间片,等待被激活。

优秀的底层数据结构

  • 一个好的数据存储结构决定了一半的性能,Redis 底层实现了多种数据结构,包括 简单动态字符串(SNS),压缩列表,双向链表,哈希表,跳表,整形数组等底层数据结构,当然它也同样支持我们将自己实现的数据结构也添加到底层中,Redis 会根据性能来判断选择使用哪一个数据结构作为存储,并作自动转换,除了字符串类型统一使用了SNS,其他的类型(list,hash, set ,sortset )均有两种底层结构的支持

基于内存的读写

  • Redis 是直接读写内存中的数据的,我们知道 只有内存中的数据才能够被程序使用,很多存储都有基于内存的优化策略,像 MySQL 的buffer机制,有效的利用内存,可以减少大量的 磁盘随机 io 读取,毕竟从文件中读取数据到内存是一个十分消耗性能的操作,为了数据的稳定,Redis 也提供了 RDB 和 AOF 机制,来保证数据的稳定性

过期删除

过期数据的清除从来不容易,为每一条key设置一个timer,到点立刻删除的消耗太大,每秒遍历所有数据消耗也大,Redis使用了一种相对务实的做法: 当client主动访问key会先对key进行超时判断,过时的key会立刻删除。

如果clien永远都不再get那条key呢? 它会在Master的后台,每秒10次的执行如下操作: 随机选取100个key校验是否过期,如果有25个以上的key过期了,立刻额外随机选取下100个key。

存储原理

存储结构

Redis 的存储方式使用的是散列表(哈希桶)的形式。

在这里插入图片描述

  • redis的存储结构从外层往内层依次是redisDb、dict、dictht、dictEntry。
  • redis的Db默认情况下有16个,每个redisDb内部包含一个dict的数据结构。
  • redis的dict内部包含dictht的数组,数组个数为2,主要用于hash扩容使用。
  • dictht内部包含dictEntry的数组,可以理解就是hash的桶,然后如果冲突通过挂链法解决。

在这里插入图片描述

整体架构如下:

img

  • dict是一个用于维护key和value映射关系的数据结构,底层用来存数据的hash表 2个, dictht。至于为什么是2个,主要涉及到hash表的扩容和缩容。
  • dictht 定义了一个hash表的结构,表中保存的是指向dictEntry的指针。如果产生了hash冲突,这些dictEntry会采用链表的方式使用next指针来连接。
  • dictEntry是redis中key和value的结合。
    • key是字符串,但是Redis没有直接使用C的字符数组,而是存储在自定义的SDS中。同一个桶中key是不一样的,key要保存起来作为rehash的根据
    • value既不是直接作为字符串存储,也不是直接存储在SDS中,而是存储在redisObject中。实际上五种常用的数据类型的任何一种,都是通过redisObject来存储的。

img

SDS

为什么Redis要用SDS实现字符串?

C语言本身没有字符串类型(只能用字符数组char[]实现)。

1、使用字符数组必须先给目标变量分配足够的空间,否则可能会溢出

2、如果要获取字符长度,必须遍历字符数组,时间复杂度是O(n)。

3、C字符串长度的变更会对字符数组做内存重分配

4、通过从字符串开始到结尾碰到的第一个’\0’来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全

SDS的特点:

1、不用担心内存溢出问题,如果需要会对SDS进行扩容。

2、获取字符串长度时间复杂度为O(1),因为定义了len属性

3、通过“空间预分配”(sdsMakeRoomFor)和“惰性空间释放”,防止多次重分配内存。

4、判断是否结束的标志是len属性(它同样以’\0’结尾是因为这样就可以使用C语言中函数库操作字符串的函数了),可以包含’\0’。

redisObject

redisObject 是 Redis 类型系统的核心, 数据库中的每个键、值, 以及 Redis 本身处理的参数, 都表示为这种数据类型。比如list,set, hash等redis支持的数据类型,在底层都会以redisObject的方式来存储。

1
2
3
4
5
6
7
8
9
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;

unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;

type: 4位,表示具体的数据类型,比如String, Hash, List, Set, Sorted Set等。

encoding: 表示该值具体的编码方式或使用的数据结构,比如sds,压缩列表, hash表等

lru:最近一次被访问的时间。用于通过LRU算法进行内存淘汰的时候使用

refcount: 对象引用计数

ptr: 指向真正数据的指针,在我们通过key获取value时,其实最终就是通过这个ptr指针找到真正的数据。

数据类型

在这里插入图片描述

扩容与缩容

rehash

当存在冲突的时候,会将对应的冲突实体放在一起搞成一个链表,此时哈希桶的指针会增加一个next 指针,用来指向下一个冲突实体,当数据量足够大的时候,这个链表可能会被拉的越来越长,因为链表的查询只能从头结点开始遍历下去,也就是 O(n) 的复杂度(n为链表长度),所以当链表过长时,会影响最终的查询速度。所以这时候不能袖手旁观。

因此,在这种情况下,Redis 会有另外一个线程专门去处理。我们称这种方式为 rehash ,和很多语言的数组底层一样,这种rehash 机制也是基于复制状态机的,说白了就是申请另一块空间(通常是原来的两倍),然后把数据信息复制过去,再释放原来的空间。通常来说程序不会快死的时候才想着救自己,当数值达到预警值时,就会开始自救,我们常常将其称之为装填因子,计算方式十分的简单,就是 use/total, 不同的存储对装填因子的判断规则不同,内容也比较底层,这里我们就不展开叙述了。

所以在 Redis 中,会存在另一张全局哈希表,其实它就是一个备胎,每次需要拓容的时候,这个备胎往往空间要比原配更大,rehash 线程会将原配的数据复制到备胎中,然后备胎就可以转正了。但是在复制的过程中,会有两个问题,一个是内存占用率会突然飙升,另一个就是Redis 阻塞的问题,复制的操作又耗时又耗空间,因此我们还需要更加聪明一点,能不能让一次的操作分成多步呢?温水煮青蛙听过没,如果每次来一个请求我就迁徙一点,这样的话,是不是慢慢的我就复制完了。这就是 Redis 的渐进式 rehash。

渐进式rehash

假设我要取 key 为 csdn 的 value ,而通过hash 算法得到的 索引位置为 1,但是该索引上有一个三个 entry, 此时处理的线程正常的去遍历这个链表拿到真正正确的值,此时 rehash进程 顺便把这个索引的 entry 从 ht0 复制到 ht1 中。并且释放 ht0 该索引的空间。

在这里插入图片描述

设计模式五大原则

  • 单一职责:对于一个类来说,应该仅有一个引起他变化的原因,功能要单一,降低耦合性。
  • 开放-封闭原则:对于扩展开放,对于更改时封闭的
  • 依赖倒转原则:高层模块不应该依赖低层模块,应该都依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。
  • 里氏替换原则:子类必须能够替换掉他们的父类;子类继承父类,所以字类拥有父类所有非私有方法。
  • 迪米特法则:强调了类之间的松耦合。如果两个类不必彼此直接通信,那么这两个类就不应该发生直接的相互作用。如果其中一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。

简单工厂模式

简单工厂模式又叫 静态方法模式,因为工厂类中定义了一个静态方法用于创建对象。比如,一台咖啡机就可以理解为一个工厂模式,你只需要按下想喝的咖啡品类的按钮(摩卡或拿铁),它就会给你生产一杯相应的咖啡,你不需要管它内部的具体实现,只要告诉它你的需求即可。

优点

  • 工厂类含有必要的判断逻辑,可以决定在什么时候创建哪一个产品类的实例,客户端可以免除直接创建产品对象的责任,而仅仅“消费”产品;简单工厂模式通过这种做法实现了对责任的分割,它提供了专门的工厂类用于创建对象;
  • 客户端无须知道所创建的具体产品类的类名,只需要知道具体产品类所对应的参数即可,对于一些复杂的类名,通过简单工厂模式可以减少使用者的记忆量;
  • 通过引入配置文件,可以在不修改任何客户端代码的情况下更换和增加新的具体产品类,在一定程度上提高了系统的灵活性。

缺点

  • 工厂类集中了所有实例(产品)的创建逻辑,一旦这个工厂不能正常工作,整个系统都会受到影响;
  • 违背“开放 - 关闭原则”,一旦添加新产品就不得不修改工厂类的逻辑,这样就会造成工厂逻辑过于复杂,比如多加一个case。当产品类型较多时,简单工厂的判断将会非常多,不容易维护。
  • 简单工厂模式由于使用了静态工厂方法,静态方法不能被继承和重写,会造成工厂角色无法形成基于继承的等级结构。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Factory {
public static String createProduct(String product) {
String result = null;
switch (product) {
case "Mocca":
result = "摩卡";
break;
case "Latte":
result = "拿铁";
break;
default:
result = "其他";
break;
}
return result;
}
}

工厂模式

工厂方法模式(Factory Method Pattern)又称为工厂模式,也叫多态工厂(Polymorphic Factory)模式,它属于类创建型模式。

在工厂方法模式中,工厂父类负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象, 这样做的目的是将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪一个具体产品类。

优点:遵循了开闭原则,扩展性极强。比如现在要增加一个绿皮肤的人类,我们只需要增加一个创建绿皮肤人类的工厂,这个工厂继承自抽象工厂即可,不需要改变原有代码,可维护性高。

缺点:增加了类的数量,当有成千上万个类型的产品时,就需要有成千上万个工厂类来生产这些产品。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//抽象对象接口
class Waiter {
public:
void sweep() {
cout << "扫地" << endl;
}
void wash() {
cout << "洗碗" << endl;
}
void send() {
cout << "送菜" << endl;
}
};
//具体对象接口
class WaiterA :public Waiter {};
class WaiterB:public Waiter{};
//抽象工厂类
class WaiterFactory {
public:
virtual Waiter* createWaiter() = 0;
};
//具体工厂类
class WaiterAFactory :public WaiterFactory {
public:
Waiter* createWaiter() {
return new WaiterA;
}
};
//具体工厂类
class WaiterBFactory :public WaiterFactory {
public:
Waiter* createWaiter() {
return new WaiterB;
}
};

抽象工厂模式

抽象工厂模式(Abstract Factory Pattern),提供一个创建一系列相关或相互依赖对象的接口,而无须指定它们具体的类。

在工厂方法模式中具体工厂负责生产具体的产品,每一个具体工厂对应一种具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或者一组重载的工厂方法。 但是有时候我们需要一个工厂可以提供多个产品对象,而不是单一的产品对象。

一个工厂可以生产多个对象,这些对象被称为一个产品族。比如这里有两个工厂,每个工厂生产一套产品(产品族)。

优点:

  • 隔离了具体类的生成,使得客户端并不需要知道什么被创建
  • 当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端始终只使用同一个产品族中的对象
  • 增加新的产品族很方便,无须修改已有系统,符合开闭原则。(这里说符合开闭原则是针对产品族的增加,即多一个品牌方)

缺点:

  • 增加新的产品等级结构麻烦,需要对原有系统进行较大的修改,甚至需要修改抽象层代码,这显然会带来较大的不便,违背了开闭原则。(这里是违背的,是因为如果我们增加了一个产品,比如鞋子,其实就要修改抽象类,修改具体类,也就是修改了源码,所以其实是违背了开闭原则)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 武器工厂
public abstract class WuQiFactory { }
// 头饰工厂
public abstract class TouShiFactory { }
// 身体工厂
public abstract class ShenTiFactory { }

// 皮肤工厂
public abstract class PiFuFactory
{
// 创建武器,头饰,身体
public abstract WuQiFactory CreateWuQi();
public abstract TouShiFactory CreateTouShi();
public abstract ShenTiFactory CreateShenTi();
}

// 圣诞武器具体实现
public class ShengDanWuQi : WuQiFactory { }
// 圣诞头饰
public class ShengDanTouShi : TouShiFactory { }
// 圣诞身体
public class ShengDanShenTi : ShenTiFactory { }

// 皮肤工厂
public class ShengDanPiFuFactory : PiFuFactory
{
// 创建武器,头饰,身体
public override WuQiFactory CreateWuQi()
{
Console.WriteLine("... 创建圣诞武器 ...");
return new ShengDanWuQi();
}
public override TouShiFactory CreateTouShi()
{
Console.WriteLine("... 创建圣诞头饰 ...");
return new ShengDanTouShi();
}
public override ShenTiFactory CreateShenTi()
{
Console.WriteLine("... 创建圣诞身体 ...");
return new ShengDanShenTi();
}
}

// 青春武器具体实现
public class QinChunWuQi : WuQiFactory { }
// 青春头饰
public class QinChunTouShi : TouShiFactory { }
// 青春身体
public class QinChunShenTi : ShenTiFactory { }

// 皮肤工厂
public class QinChunFuFactory : PiFuFactory
{
// 创建武器,头饰,身体
public override WuQiFactory CreateWuQi()
{
Console.WriteLine(" --- 创建青春武器 --- ");
return new QinChunWuQi();
}
public override TouShiFactory CreateTouShi()
{
Console.WriteLine(" --- 创建青春头饰 --- ");
return new QinChunTouShi();
}
public override ShenTiFactory CreateShenTi()
{
Console.WriteLine(" --- 创建青春身体 --- ");
return new QinChunShenTi();
}
}

工厂方法总结

一.特点
简单工厂模式:实质是由一个工厂类根据传入的参数,动态决定应该创建哪一个产品类(这些产品类继承自一个父类或接口)的实例。在这个模式中,工厂类是整个模式的关键所在。它包含必要的判断逻辑,能够根据外界给定的信息,决定究竟应该创建哪个具体类的对象。用户在使用时可以直接根据工厂类去创建所需的实例,而无需了解这些对象是如何创建以及如何组织的。有利于整个软件体系结构的优化。

工厂方法模式:工厂方法是粒度很小的设计模式,因为模式的表现只是一个抽象的方法。提前定义用于创建对象的接口,让子类(具体工厂)决定实例化具体的某一个类,实现了可扩展。在这个模式中,工厂类和产品类往往可以依次对应。即一个抽象工厂对应一个抽象产品,一个具体工厂对应一个具体产品,这个具体的工厂就负责生产对应的产品。

抽象工厂模式:抽象工厂模式是所有形态的工厂模式中最为抽象和最具一般性的一种形态。抽象工厂模式是指当有多个抽象角色时,使用的一种工厂模式。抽象工厂模式可以向客户端提供一个接口,使客户端在不必指定产品的具体的情况下,创建多个产品族中的产品对象。它有多个抽象产品类,每个抽象产品类可以派生出多个具体产品类,一个抽象工厂类,可以派生出多个具体工厂类,每个具体工厂类可以创建多个具体产品类的实例。每一个模式都是针对一定问题的解决方案,工厂方法模式针对的是一个产品等级结构;而抽象工厂模式针对的是多个产品等级结果。

二.优点
简单工厂模式:工厂类含有必要的判断逻辑,可以决定在什么时候创建哪一个产品类的实例,客户端可以免除直接创建产品对象的责任,而仅仅”消费”产品。简单工厂模式通过这种做法实现了对责任的分割。简单工厂模式能够根据外界给定的信息,决定究竟应该创建哪个具体类的对象。通过它,外界可以从直接创建具体产品对象的尴尬局面中摆脱出来。外界与具体类隔离开来,偶合性低。明确区分了各自的职责和权力,有利于整个软件体系结构的优化。

工厂方法模式:工厂方法模式是为了克服简单工厂模式的缺点(主要是为了满足OCP)而设计出来的。简单工厂模式的工厂类随着产品类的增加需要增加很多方法(或代码),而工厂方法模式每个具体工厂类只完成单一任务,代码简洁。工厂方法模式完全满足OCP,即它有非常良好的扩展性

抽象工厂模式:抽象工厂模式主要在于应对“新系列”的需求变化。分离了具体的类,抽象工厂模式帮助你控制一个应用创建的对象的类,因为一个工厂封装创建产品对象的责任和过程。它将客户和类的实现分离,客户通过他们的抽象接口操纵实例,产品的类名也在具体工厂的实现中被分离,它们不出现在客户代码中。它使得易于交换产品系列。一个具体工厂类在一个应用中仅出现一次——即在它初始化的时候。这使得改变一个应用的具体工厂变得很容易。它只需改变具体的工厂即可使用不同的产品配置,这是因为一个抽象工厂创建了一个完整的产品系列,所以整个产品系列会立刻改变。它有利于产品的一致性。当一个系列的产品对象被设计成一起工作时,一个应用一次只能使用同一个系列中的对象,这一点很重要,而抽象工厂很容易实现这一点。抽象工厂模式有助于这样的团队的分工,降低了模块间的耦合性,提高了团队开发效率。

三.缺点
简单工厂模式:当产品有复杂的多层等级结构时,工厂类只有自己,以不变应万变,就是模式的缺点。因为工厂类集中了所有产品创建逻辑,一旦增加产品或者删除产品,整个系统都要受到影响。系统扩展困难,一旦添加新产品就不得不修改工厂逻辑,有可能造成工厂逻辑过于复杂,违背了”开放–封闭”原则(OCP).另外,简单工厂模式通常使用静态工厂方法,这使得无法由子类继承,造成工厂角色无法形成基于继承的等级结构。

工厂方法模式:假如某个具体产品类需要进行一定的修改,很可能需要修改对应的工厂类。当同时需要修改多个产品类的时候,对工厂类的修改会变得相当麻烦。比如说,每增加一个产品,相应的也要增加一个子工厂,会加大了额外的开发量。

抽象工厂模式:抽象工厂模式在于难于应付“新对象”的需求变动。难以支持新种类的产品。难以扩展抽象工厂以生产新种类的产品。这是因为抽象工厂几乎确定了可以被创建的产品集合,支持新种类的产品就需要扩展该工厂接口,这将涉及抽象工厂类及其所有子类的改变。

四.适用范围
简单工厂模式:工厂类负责创建的对象比较少,客户只知道传入了工厂类的参数,对于始何创建对象(逻辑)不关心。

工厂方法模式:支持多扩展少修改的OCP原则。

  • 客户只知道创建产品的工厂名,而不知道具体的产品名。如 TCL 电视工厂、海信电视工厂等。
  • 创建对象的任务由多个具体子工厂中的某一个完成,而抽象工厂只提供创建产品的接口。
  • 客户不关心创建产品的细节,只关心产品的品牌。

抽象工厂模式:

  1. 当需要创建的对象是一系列相互关联或相互依赖的产品族时,如电器工厂中的电视机、洗衣机、空调等。
  2. 系统中有多个产品族,但每次只使用其中的某一族产品。如有人只喜欢穿某一个品牌的衣服和鞋。
  3. 系统中提供了产品的类库,且所有产品的接口相同,客户端不依赖产品实例的创建细节和内部结构。

建造者模式

建造者模式(Builder Pattern)将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象。

优点

  • 封装性好,构建和表示分离
  • 扩展性好,各个具体的建造者相互独立,有利于系统的解耦
  • 控制细节风险,客户端无需详知细节,建造者细化创建过程

缺点

  • 产品的组成部分必须相同,这限制了其使用范围
  • 产品内部发生变化,建造者需同步修改,后期维护成本较大

适用场景 :

  • 结构复杂 : 对象 有非常复杂的内部结构 , 有很多属性 ;
  • 分离创建和使用 : 想把复杂对象的创建和使用分离 ;
  • 当创造一个对象需要很多步骤时 , 适合使用建造者模式 ;
  • 当创造一个对象 只需要一个简单的方法就可以完成 , 适合使用工厂模式 ;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/// 产品角色(Product)
class ProductMeal {
public:
ProductMeal() {
std::cout << "ProductMeal Hello" << std::endl;
};
~ProductMeal() {
std::cout << "ProductMeal Bye" << std::endl;
};
void setBurger(const std::string &iburger) {
mBurger = iburger;
}
void setDrink(const std::string &idrink) {
mDrink = idrink;
}
void setSnacks(const std::string &isnacks) {
mSnacks = isnacks;
}

void showMeal(){
std::cout << "Burger is " << mBurger << std::endl;
std::cout << "Drink is " << mDrink << std::endl;
std::cout << "Snacks is " << mSnacks << std::endl;
}

private:
std::string mBurger;
std::string mDrink;
std::string mSnacks;
};
/// 抽象建造者(AbstractBuilder)
class AbstractBuilder
{
public:
virtual ~AbstractBuilder() = default;
//抽象方法:
virtual void buildBurger() = 0;
virtual void buildDrink() = 0;
virtual void buildSnacks() = 0;
virtual ProductMeal* getMeal() = 0;

protected:
AbstractBuilder()= default;

};
/// 具体建造者(ConcreteBuilder)
class ConcreteBuilderMeal_A : public AbstractBuilder{ /// 套餐A
public:
ConcreteBuilderMeal_A(){
std::cout << "ConcreteBuilderMeal_A Hello" << std::endl;
meal = new ProductMeal();
}
~ConcreteBuilderMeal_A() override{
std::cout << "ConcreteBuilderMeal_A Bye" << std::endl;
delete meal;
}
void buildBurger() override {
meal->setBurger("Veg Burger");
};
void buildDrink() override {
meal->setDrink("coke");
}
void buildSnacks() override {
meal->setSnacks("French fries");
}
ProductMeal* getMeal() override {
return meal;
}
private:
ProductMeal *meal;
};

class ConcreteBuilderMeal_B : public AbstractBuilder{ /// 套餐B
public:
ConcreteBuilderMeal_B(){
std::cout << "ConcreteBuilderMeal_B Hello" << std::endl;
meal = new ProductMeal();
}
~ConcreteBuilderMeal_B() override{
std::cout << "ConcreteBuilderMeal_B Bye" << std::endl;
delete meal;
}
void buildBurger() override {
meal->setBurger("Chicken Burger");
};
void buildDrink() override {
meal->setDrink("pepsi");
}
void buildSnacks() override {
meal->setSnacks("Onion rings");
}
ProductMeal* getMeal() override {
return meal;
}
private:
ProductMeal *meal;
};
class ConcreteBuilderMeal_C : public AbstractBuilder{ /// 套餐C
public:
ConcreteBuilderMeal_C(){
std::cout << "ConcreteBuilderMeal_C Hello" << std::endl;
meal = new ProductMeal();
}
~ConcreteBuilderMeal_C() override{
std::cout << "ConcreteBuilderMeal_C Bye" << std::endl;
delete meal;
}
void buildBurger() override {
meal->setBurger("Veg Burger");
};
void buildDrink() override {
meal->setDrink("cafe");
}
void buildSnacks() override {
meal->setSnacks("French fries");
}
ProductMeal* getMeal() override {
return meal;
}
private:
ProductMeal *meal;
};
/// 指挥者(Director)
class Director
{
public:
Director() {
std::cout << "Director Hello" << std::endl;
};
~Director() {
std::cout << "Director Bye" << std::endl;
}
//具体实现方法
void setBuilder(AbstractBuilder *iBuilder){
this->builder = iBuilder;
}
//封装组装流程,返回建造结果
ProductMeal *construct(){
assert(builder!= nullptr);
builder->buildBurger(); /// 制作顺序
builder->buildDrink();
builder->buildSnacks();
return builder->getMeal();
}
private:
AbstractBuilder *builder= nullptr;
};

单例模式

单例模式(Singleton Pattern)是一种常用的模式,有一些对象我们往往只需要一个,比如全局缓存、浏览器中的 window 对象等。单例模式用于保证一个类仅有一个实例,并提供一个访问它的全局访问点。懒汉模式使用static可以很简单地实现

优点:不会频繁地创建和销毁对象,浪费系统资源。

使用场景:IO 、数据库连接、Redis 连接等。

  • 需要频繁实例化然后销毁的对象。
  • 创建对象时耗时过多或耗资源过多,但又经常用到的对象。
  • 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器或资源管理器,或者需要考虑资源消耗太大而只允许创建一个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>

using namespace std;

//懒汉式单例模式
class SingletonLazy {
private:
static SingletonLazy* instance;
// 构造函数私有化, 不让外界利用new创建实例
SingletonLazy() {
cout << "懒汉式单例模式\n";
}
public:
static SingletonLazy* getInstance() {
//第一次引用时才被实例化
if (instance == nullptr) {
instance = new SingletonLazy;
}
return instance;
}
};

SingletonLazy* SingletonLazy::instance = nullptr;

//饿汉式单例模式
class SingletonHungry {
private:
static SingletonHungry* instance2;
SingletonHungry() {
cout << "饿汉式单例模式\n";
}
public:
static SingletonHungry* getInstance() {
return instance2;
}
};
//加载时实例化
SingletonHungry* SingletonHungry::instance2 = new SingletonHungry;


int main() {

cout << "action: \n";

return 0;
}

适配器模式

在实际生活中,也存在适配器的使用场景,比如:港式插头转换器、电源适配器和 USB 转接口。而在软件工程中,适配器模式的作用是解决两个软件实体间的接口不兼容的问题。 使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体就可以一起工作。

主要优点:

  • 将目标类和适配者类解耦,通过引入一个适配器类来重用现有的适配者类,无须修改原有结构。
  • 增加了类的透明性和复用性,将具体的业务实现过程封装在适配者类中,对于客户端类而言是透明的,而且提高了适配者的复用性,同一个适配者类可以在多个不同的系统中复用。
  • 灵活性和扩展性都非常好,通过使用配置文件,可以很方便地更换适配器,也可以在不修改原有代码的基础上增加新的适配器类,完全符合“开闭原则”。

适用场景

  • 系统需要使用一些现有的类,而这些类的接口(如方法名)不符合系统的需要,甚至没有这些类的源代码。
  • 想创建一个可以重复使用的类,用于与一些彼此之间没有太大关联的一些类,包括一些可能在将来引进的类一起工作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>
#include <memory>
#include <string>

class Target
{
public:
virtual bool NewRequest(const int value)
{
std::cout << "Calling new request method!" << std::endl;
return true;
}

virtual ~Target(){}
};

class Adaptee
{
public:
bool OldRequest(const std::string& strValue)
{
std::cout << "Calling old request method!" << std::endl;
return true;
}
};

class Adaptor : public Target
{
private:
std::shared_ptr<Adaptee> m_smartAdaptee;

public:
Adaptor(std::shared_ptr<Adaptee> adaptee)
{
m_smartAdaptee = std::move(adaptee);
}

bool NewRequest(const int value)
{
std::cout << "I'm new request method" << std::endl;
std::string strValue = std::to_string(value);
m_smartAdaptee->OldRequest(strValue);

return true;
}
};

int main()
{
std::shared_ptr<Target> target(new Adaptor(std::make_shared<Adaptee>()));
target->NewRequest(1);

system("pause");
return 0;
}

观察者模式

观察者模式是定义对象间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。观察者模式又叫做发布-订阅(Publish/Subscribe)模式、模型-视图(Model/View)模式、源-监听器(Source/Listener)模式或从属者(Dependents)模式。

img

优点

  • 观察者模式可以实现表示层和数据逻辑层的分离,并定义了稳定的消息更新传递机制,抽象了更新接口,使得可以有各种各样不同的表示层作为具体观察者角色;
  • 观察者模式在观察目标和观察者之间建立一个抽象的耦合;
  • 观察者模式支持广播通信;
  • 观察者模式符合开闭原则(对拓展开放,对修改关闭)的要求。

缺点

  • 如果一个观察目标对象有很多直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间;
  • 如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃;
  • 观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。

应用场景

  • 一个对象的行为依赖于另一个对象的状态。或者换一种说法,当被观察对象(目标对象)的状态发生改变时 ,会直接影响到观察对象的行为。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/\* \* 观察者(消息接收方) \*/
interface Observer {
public void update(String message);
}
/\* \* 具体的观察者(消息接收方) \*/
class ConcrereObserver implements Observer {
private String name;

public ConcrereObserver(String name) {
this.name = name;
}

@Override
public void update(String message) {
System.out.println(name + ":" + message);
}
}

/\* \* 被观察者(消息发布方) \*/
interface Subject {
// 增加订阅者
public void attach(Observer observer);
// 删除订阅者
public void detach(Observer observer);
// 通知订阅者更新消息
public void notify(String message);
}
/\* \* 具体被观察者(消息发布方) \*/
class ConcreteSubject implements Subject {
// 订阅者列表(存储信息)
private List<Observer> list = new ArrayList<Observer>();
@Override
public void attach(Observer observer) {
list.add(observer);
}
@Override
public void detach(Observer observer) {
list.remove(observer);
}
@Override
public void notify(String message) {
for (Observer observer : list) {
observer.update(message);
}
}
}

发布者订阅模式

在软件架构中,发布/订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,然后分别发送给不同的订阅者。 同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者存在。

应用场景

  • 对象间存在一对多关系,一个对象的状态发生改变会影响其他对象。
  • 作为事件总线,来实现不同组件间或模块间的通信。

img

策略模式

策略模式是指定义一系列算法,将每个算法都封装起来,并且使他们之间可以相互替换。

  • 优点:策略模式提供了对“开闭原则”的完美支持,用户可以在不修改原有系统的基础上选择算法或行为。干掉复杂难看的if-else。
  • 缺点:调用时,必须提前知道都有哪些策略模式类,才能自行决定当前场景该使用何种策略。

应用场景

  • 一个系统需要动态地在几种算法中选择一种时,可将每个算法封装到策略类中。
  • 多个类只区别在表现行为不同,可以使用策略模式,在运行时动态选择具体要执行的行为。
  • 一个类定义了多种行为,并且这些行为在这个类的操作中以多个条件语句的形式出现,可将每个条件分支移入它们各自的策略类中以代替这些条件语句。

比如我们要出去旅游,选择性很多,可以选择骑车、开车、坐飞机、坐火车等,就可以使用策略模式,把每种出行作为一种策略封装起来,后面增加了新的交通方式了,如超级高铁、火箭等,就可以不需要改动原有的类,新增交通方式即可,这样也符合软件开发的开闭原则。 策略模式实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//Strategt类,定义所有支持的算法的公共接口
class Strategy {
public:
virtual ~Strategy() {};
virtual void AlgorithmInterface() = 0;
};

//ConcreteStrategy 封装了具体的算法或行为,继承Strategy
class ConcreteStrategyA : public Strategy{
void AlgorithmInterface() {
cout << "算法A实现" << endl;
}
};

class ConcreteStrategyB : public Strategy {
void AlgorithmInterface() {
cout << "算法B实现" << endl;
}
};

class ConcreteStrategyC : public Strategy {
void AlgorithmInterface() {
cout << "算法C实现" << endl;
}
};

//Context,用一个ConcreteStrategy来配置,维护一个对Strategy的引用
class Context {
public:
Context(Strategy* strategy) : m_strategy(strategy) {};
~Context() { free_ptr(m_strategy); }
void AlgorithmInterface() {
m_strategy->AlgorithmInterface();
};
private:
Strategy* m_strategy;
};

模板方法模式

模板方法模式由两部分结构组成:抽象父类和具体的实现子类。通常在抽象父类中封装了子类的算法框架,也包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

优点

  • 良好的扩展性,封装不变部分,扩展可变部分,把认为是不变部分的算法封装到父类实现,而可变部分的则可以通过继承来继续扩展。例如增加一个新的功能很简单,只要再增加一个子类,实现父类的基本方法就可以了。
  • 提取公共部分代码,便于维护,减小维护升级成本,基本操作由父类定义,子类实现
  • 基本方法是由子类实现的,因此子类可以通过扩展的方式增加相应的功能,符合开闭原则。

缺点:

  • 每一个不同的实现都需要一个子类来实现,导致类的个数增加,使得系统更加庞大。

使用场合

模板方法是一种代码复用的基本技术。它们在类库中尤为重要,它们提取了类库中的公共行为。在使用模板方法时,很重要的一点是模板方法应该指明哪些操作是可以被重定义的,以及哪些是必须被重定义的。要有效的重用一个抽象类,子类编写者必须明确了解哪些操作是设计为有待重定义的。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <iostream>
using namespace std;

class AbstractClass
{
public:
void TemplateMethod()
{
PrimitiveOperation1();
cout<<"TemplateMethod"<<endl;
PrimitiveOperation2();
}

protected:
virtual void PrimitiveOperation1()
{
cout<<"Default Operation1"<<endl;
}

virtual void PrimitiveOperation2()
{
cout<<"Default Operation2"<<endl;
}
};

class ConcreteClassA : public AbstractClass
{
protected:
virtual void PrimitiveOperation1()
{
cout<<"ConcreteA Operation1"<<endl;
}

virtual void PrimitiveOperation2()
{
cout<<"ConcreteA Operation2"<<endl;
}
};

class ConcreteClassB : public AbstractClass
{
protected:
virtual void PrimitiveOperation1()
{
cout<<"ConcreteB Operation1"<<endl;
}

virtual void PrimitiveOperation2()
{
cout<<"ConcreteB Operation2"<<endl;
}
};

int main()
{
AbstractClass *pAbstractA = new ConcreteClassA;
pAbstractA->TemplateMethod();

AbstractClass *pAbstractB = new ConcreteClassB;
pAbstractB->TemplateMethod();

if (pAbstractA) delete pAbstractA;
if (pAbstractB) delete pAbstractB;
}

代理模式

代理模式是给某一个对象提供一个代理,并由代理对象控制对原对象的引用。

优点

  • 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用;
  • 代理模式能够协调调用者和被调用者,在一定程度上降低了系统的耦合度;
  • 可以灵活地隐藏被代理对象的部分功能和服务,也增加额外的功能和服务。

缺点

  • 由于使用了代理模式,因此程序的性能没有直接调用性能高;
  • 使用代理模式提高了代码的复杂度。

使用场景:

  • 当我们想要隐藏某个类时,可以为其提供代理类
  • 当一个类需要对不同的调用者提供不同的调用权限时,可以使用代理类来实现(代理类不一定只有一个,我们可以建立多个代理类来实现,也可以在一个代理类中金进行权限判断来进行不同权限的功能调用)
  • 当我们要扩展某个类的某个功能时,可以使用代理模式,在代理类中进行简单扩展(只针对简单扩展,可在引用委托类的语句之前与之后进行)

举一个生活中的例子:比如买飞机票,由于离飞机场太远,直接去飞机场买票不太现实,这个时候我们就可以上携程 App 上购买飞机票,这个时候携程 App 就相当于是飞机票的代理商。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
using namespace std;

class Subject //Subject 定义了RealSubject和Proxy的共用接口..这样就在任何使用RealSubject的地方都可以使用Proxy
{
public:
virtual void func()
{
cout << "Subject" << endl;
}
};

class RealSubject :public Subject // RealSubject 定义proxy所代表的真实实体
{
public:
virtual void func()
{
cout << "RealSubject" << endl;
}
};

class Proxy : public Subject //proxy 保存一个引用使得代理可以访问实体,并且提供一个于Subject的接口相同的接口 这样代理就可以用来替代实体
{
RealSubject real;
public:
virtual void func()
{
cout << "Proxy" << endl;
real.func();
}
};

Note

这部分简单学习一些目前比较新的计网相关的技术,不作特别深入,但尽可能保证介绍明白。

kubernetes

简介

kubernetes 简称 k8s,是一个容器自动化运维平台,可以高效管理容器集群。k8s 位于容器编排层,只管理容器,而不提供容器引擎来负责容器生命周期的管理,因此 k8s 需要借助docker 这类容器引擎才能工作。简单来说,容器引擎提供应用级别的一个抽像去管理应用,而k8s 是提供应用级别集群的抽象去管理容器集群。

k8s 的用途有:跨主机编排容器、更充分地利用硬件资源来最大化地满足企业应用的需求、通过自动布局、自动重启、自动复制、自动伸缩实现应用的状态检查与自我修复、控制与自动化应用的部署与升级等等。在架构上,k8s 主要由 master 节点和 node 节点组成,master 管理 node 的功能实施:

  • master 节点组件:
    • (1) etcd:k8s 集中的状态存储,存储所有的集群状态数据;
    • (2) API server:k8s 的通讯接口和命令总线;
    • (3) scheduler:k8s 负责调度决策的组件,掌握着当前集群资源的使用情况;
    • (4) controller manager:通过 API server 监控集群的状态,确保集群实际状态和预期的一致。
  • node 节点组件:
    • (1) kubelet:资源管理,监听 API server 上的事件;
    • (2) kube-proxy:管理 k8s 上面的服务和网络;
    • (3) docker:容器对象,负责实施应用功能。

对比传统应用部署

部署 kubernetes 应用与部署传统应用的不同之处:

  • 传统应用会部署在一个操作系统上,程序员开发程序面临的接口是操作系统的 api,并在单一主机上部署和运行程序。由于不同操作系统之间的 ABI(二进制接口),导致应用程序的移植面临巨大困难。而部署 kubernetes 应用则使用 k8s 集群对外提供的 api 接口,应用程序开发出来天然适应于运行在云平台之上,而非传统的单机应用程序。
  • 部署 kubernetes 应用不需要考虑具体的环境。kubernetes 使用容器化解决方案,每个应用可以被打包成一个容器镜像,这便于管理、扩展和回收,也不用管在哪个机器执行,具体的环境是什么。而这些是部署传统应用需要考虑的东西。
  • 由于部署 kubernetes 应用不需要考虑具体的环境,在应用开发与部署的过程中,应用不需要与其余的应用堆栈组合,也不依赖于生产环境基础结构,这使得从研发到测试、生产能提供一致环境,能够实现服务的无缝迁移。
  • 传统应用是运行在操作系统上的一个或多个进程,不管需不需要服务,应用程序都始终运行着。而 kubernetes 平时并不运行应用程序,而是等到有客户去访问服务时,才运行一个微服务。一旦访问完毕,应用程序的使命结束后将停止运行。直到再次被调用时,才再次运行。此时,应用程序变成了函数,也就是所谓的函数即服务。

部署

  • 购买一台弹性云服务器(ECS)
  • 安装minikube,这是轻量化的k8s集群
  • 安装docker作为底层容器引擎
  • 安装kubectl,这是k8s集群的命令行管理工具
  • 安装coredns(插件式)作为集群的DNS服务器,用于服务发现,也就是服务(应用)之间相互定位的过程。

为什么需要服务发现

在K8S集群中,POD有以下特性:

Pod 是可以在 Kubernetes 中创建和管理的、最小的可部署的计算单元。

  1. 服务动态性强
    容器在k8s中迁移会导致POD的IP地址变化
  2. 更新发布频繁
    版本迭代快,新旧POD的IP地址会不同
  3. 支持自动伸缩
    大促或流量高峰需要动态伸缩,IP地址会动态增减

service资源解决POD服务发现:
为了解决pod地址变化的问题,需要部署service资源,用service资源代理后端pod,通过暴露service资源的固定地址(集群IP),来解决以上POD资源变化产生的IP变动问题,并且多个提供相同服务的pod可以用service实习负载均衡。

那service资源的服务发现呢?

service资源提供了一个不变的集群IP供外部访问,但

  1. IP地址毕竟难以记忆
  2. service资源可能也会被销毁和创建
  3. 能不能将service资源名称和service暴露的集群网络IP对应类似域名与IP关系,则只需记服务名就能自动匹配服务IP,岂不就达到了service服务的自动发现

在k8s中,coredns就是为了解决以上问题。

container

简介

容器是一种沙盒技术,主要目的是为了将应用运行在其中,与外界隔离;及方便这个沙盒可以被转移到其它宿主机器。本质上,它是一个特殊的进程。通过名称空间(Namespace)、控制组(Control groups)、切根(chroot)技术把资源、文件、设备、状态和配置划分到一个独立的空间。

捕获

与虚拟机不同,容器不提供操作系统级虚拟化,从而降低了初始化容器的开销。在实际场景中,一台物理计算机可能会运行数百个容器。

Docker是最著名的容器平台。在docker的生命周期中,镜像和容器是最重要的两部分。其中镜像是文件,是一个只读的模板,一个独立的文件系统,里面包含运行容器所需的数据,可以用来创建新的容器;而容器是基于镜像创建的进程,容器中的进程依赖于镜像中的文件,容器具有写的功能,可以根据需要改写里面的软件、配置等,并可以保存为新的镜像。如果是用import方法生成,则是一个完全新的镜像。如果用的是commit方法生成的新的镜像,则新镜像与原来的镜像之间存在着继承关系。

对比虚拟机

容器和虚拟机都是用于创建可在其中运行应用程序的隔离环境的技术。但是,它们有一些关键差异。容器和虚拟机之间的主要区别之一是它们的实现方式。容器是一个轻量级、独立的可执行包,它包含应用程序运行所需的所有内容,包括应用程序代码、库、依赖项和运行时。另一方面,虚拟机是一个成熟的独立操作系统,它运行在主机操作系统之上并虚拟化所有硬件资源。

另一个区别是提供的隔离级别。容器提供高级别的进程级隔离,这意味着每个容器运行自己的进程,并具有自己的文件系统和网络堆栈。但是,容器共享主机操作系统的内核,这意味着它们不提供与主机系统的完全隔离。另一方面,虚拟机提供与主机系统的完全隔离,因为它们有自己的内核和硬件虚拟化层。第三个区别是开销和资源要求。容器通常比虚拟机更轻量级,需要的资源更少,因为它们不需要虚拟化所有硬件资源。这使得它们的启动速度更快,运行效率更高。

总体而言,容器和虚拟机之间的主要区别在于它们的实现方式、它们提供的隔离级别以及开销和资源要求。

vxlan

overlay

可以将覆盖网络(overlay)视为位于另一个网络之上的计算机网络。叠加网络中的所有节点都通过逻辑或虚拟链路相互连接,并且每个链路都对应于底层网络中的路径。

image

vxlan简介

VXLAN 通常被描述为一种覆盖技术(也是一种隧道技术),因为它允许通过将以太网帧封装(隧道)到包含 IP 地址的 VXLAN 数据包中,从而在干预的第 2 层网络上延伸第 3 层连接。

image (1)

每个协议标头中 VXLAN 数据包的关键字段包括:

  • + 外部 MAC 标头(14 字节,4 字节可选)— 包含源 VTEP(VXLAN 隧道端点)的 MAC 地址和下一跃点路由器的 MAC 地址。数据包路径上的每个路由器都会重写此标头,以便源地址是路由器的 MAC 地址,目标地址是下一跃点路由器的 MAC 地址。
  • + 外部 IP 标头(20 字节)- 包含源和目标 VTEP 的 IP 地址。
  • +(外部)UDP 标头(8 字节)- 包含源和目标 UDP 端口:
    • – 源 UDP 端口:VXLAN 协议在 UDP 数据包标头中重新利用此标准字段。协议不会将此字段用于源 UDP 端口,而是将其用作 VTEP 之间特定流的数字标识符。VXLAN 标准没有定义如何派生此数字,但源 VTEP 通常根据来自内部第 2 层数据包和原始帧的第 3 层或第 4 层标头的字段组合的哈希值来计算它。
    • – 目标 UDP 端口:VXLAN UDP 端口。互联网号码分配机构 (IANA) 将端口 4789 分配给 VXLAN。
  • + VXLAN 标头(8 字节)- 包含 24 位 VNI。
  • + 原始以太网/L2 帧 – 包含原始第 2 层以太网帧。

总的来说,VXLAN 封装向原始以太网帧添加了 50 到 54 字节的额外标头信息。由于这可能导致以太网帧超过默认的 1514 字节 MTU,因此最佳做法是在整个网络中实现巨型帧。

并且,我们可以看到VXLAN数据包只不过是一个MAC-in-UDP封装的数据包。VXLAN 报头将添加到原始第 2 层帧,然后放置在 UDP-IP 数据包中。此封装允许 VXLAN 数据包通过第 2 层网络从第 3 层网络进行隧道传输。

解决虚拟局域网问题

在两个虚拟局域网中的两台主机是无法互相ping通网络的,假设有两台虚拟机(相当于两个虚拟局域网),并且虚拟机中使用mininet创建主机节点,那么不同虚拟机中的节点不能互相ping通。

网络不可达的原因是:

  • 在物理host主机里,两台虚拟机相当于在一个小型局域网下,它们之间是可以进行网络通信的(本身可达,即overlay下层网络underlay可达,只要建立上层逻辑层即可实现互联);
  • 而继续在虚拟机中使用mininet创建host时,mininet相当于在每个虚拟机下又创建了小型局域网,此时这两个小型局域网又是虚拟机中的内网,mininet创建的主机相当于两个内网下的主机,当然是不能进行网络通信的。

这时就可以利用VXLAN建立逻辑上的隧道,将两台虚拟机中的mininet互连。VXLAN在两个虚拟机上各自建立一个VTEP,所谓的VTEP(VXLAN Tunnel Endpoints,VXLAN隧道端点)就是VXLAN网络的边缘设备,是VXLAN隧道的起点和终点,VXLAN对用户原始数据帧的封装和解封装均在VTEP上进行。

有了VTEP后,mininet中的数据包就能通过VTEP转发到另一台虚拟机的隧道端点并在链路层转发。这本质上是由于底层的物理链路(underlay)本就是连通的,因此只要在两个mininet只要在逻辑上建立一条隧道(overlay),就能打破内网限制。

WIRESHARK 抓包

使用WIRESHARK抓取VM1中host1 ping VM2中host2时发送的ICMP协议数据帧,可以看到包已经进行了VXLAN封装:

  • 最外层是源和目的VTEP的MAC地址;
  • 往里一层是源和目的VTPE的IP地址,也就是虚拟机的IP地址;
  • 接着是UDP首部,仅包含了端口号而不包含IP;
  • 然后是VXLAN首部,包含24-bit的VNI;
  • 最后是原始帧,从IP地址可以看到使用的是各自的私有地址。

这次抓包显示了VTEP是如何工作的:将内部主机的报文进行VXLAN封装,通过配置的对端IP发送数据帧到对端VTEP上,对端的VTEP再进行VXLAN解包,再把原始的数据帧转发到内部主机。

image-20221112095122590

MTU相关问题

根据VXLAN 7348 RFC,VXLAN报文不建议分片,否则在接收端VTEP上会丢弃分片的报文

image-20221111212853142

MTU是单个IP数据包的最大值,而MSS通常是MTU减去40字节的TCP/IP首部。VTEP会进行VXLAN封装,通常情况下是增加50字节的首部内容,如果使用默认的MSS,那么数据帧的大小就会再加上40字节的TCP/IP首部和50字节的VXLAN首部。默认的MTU是1500字节默认的MSS是1460字节,如果使用VXLAN技术,就需要将MSS调整为1410字节,否则报文会丢失。

因此在部署VXLAN网络前需要对MTU进行全局规划,有两种方式:

  • 方式一:修改应用层服务器发送报文的长度值,修改后的长度值加上VXLAN封装的50字节后,需保证在整个承载网中,均小于设备的MTU值。使用此方法,修改难度低,需要IT侧配合。这种方式是比较推荐的。
  • 方式二:修改承载网中每一跳网络设备的MTU值,需保证MTU值大于收到的VXLAN报文长度,从而保证不分片。此方式常常受到约束:承载网络中设备众多、分布广泛,且涉及不同厂商,修改难度大;常常没有修改权限(他人资产,不可控)。

eBPF

eBPF的背景

eBPF 全称 extended Berkeley Packet Filter,中文意思是 扩展的伯克利包过滤器。一般来说,要向内核添加新功能,需要修改内核源代码或者编写 内核模块 来实现。而 eBPF 允许程序在不修改内核源代码,或添加额外的内核模块情况下运行。

BPF

BPF(Berkeley Packet Filter ),中文翻译为伯克利包过滤器,是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。BPF 在数据包过滤上引入了两大革新:

  • 一个新的虚拟机 (VM) 设计,可以有效地工作在基于寄存器结构的 CPU 之上;
  • 应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息。这样可以最大程度地减少BPF 处理的数据;

由于这些巨大的改进,所有的 Unix 系统都选择采用 BPF 作为网络数据包过滤技术,直到今天,许多 Unix 内核的派生系统中(包括 Linux 内核)仍使用该实现。

下面是tcpdump的运行架构。

image-20200419215511484

eBPF介绍

2014 年初,Alexei Starovoitov 实现了 eBPF(extended Berkeley Packet Filter)。经过重新设计,eBPF 演进为一个通用执行引擎,可基于此开发性能分析工具、软件定义网络等诸多场景。

eBPF 扩展到用户空间,这也成为了 BPF 技术的转折点。eBPF 不再局限于网络栈,已经成为内核顶级的子系统。eBPF 程序架构强调安全性和稳定性,看上去更像内核模块,但与内核模块不同,eBPF 程序不需要重新编译内核,并且可以确保 eBPF 程序运行完成,而不会造成系统的崩溃。

下面是ebpf的简单架构,作基础介绍。

bpf-basic-arch

  • 用户态
  1. 用户编写 eBPF 程序,可以使用 eBPF 汇编或者 eBPF 特有的 C 语言来编写。
  2. 使用 LLVM/CLang 编译器,将 eBPF 程序编译成 eBPF 字节码。
  3. 调用 bpf() 系统调用把 eBPF 字节码加载到内核。
  • 内核态
  1. 当用户调用 bpf() 系统调用把 eBPF 字节码加载到内核时,内核先会对 eBPF 字节码进行安全验证。
  2. 使用 JIT(Just In Time)技术将 eBPF 字节编译成本地机器码(Native Code)。
  3. 然后根据 eBPF 程序的功能,将 eBPF 机器码挂载到内核的不同运行路径上(如用于跟踪内核运行状态的 eBPF 程序将会挂载在 kprobes 的运行路径上)。当内核运行到这些路径时,就会触发执行相应路径上的 eBPF 机器码。

下面是ebpf的整体结构,这更为具体。

linux_ebpf_internals

eBPF 分为用户空间程序和内核程序两部分:

  • 用户空间程序负责加载 BPF 字节码至内核,如需要也会负责读取内核回传的统计信息或者事件详情;
  • 内核中的 BPF 字节码负责在内核中执行特定事件,如需要也会将执行的结果通过 maps 或者 perf-event 事件发送至用户空间;

其中用户空间程序与内核 BPF 字节码程序可以使用 map 结构实现双向通信,这为内核中运行的 BPF 字节码程序提供了更加灵活的控制。

用户空间程序与内核中的 BPF 字节码交互的流程主要如下:

  1. 可以使用 LLVM 或者 GCC 工具将编写的 BPF 代码程序编译成 BPF 字节码;
  2. 然后使用加载程序 Loader 将字节码加载至内核;内核使用验证器(verfier) 组件保证执行字节码的安全性,以避免对内核造成灾难,在确认字节码安全后将其加载对应的内核模块执行;BPF 观测技术相关的程序程序类型可能是 kprobes/uprobes/tracepoint/perf_events 中的一个或多个,其中:
    • kprobes:实现内核中动态跟踪。 kprobes 可以跟踪到 Linux 内核中的函数入口或返回点,但是不是稳定 ABI 接口,可能会因为内核版本变化导致,导致跟踪失效。
    • uprobes:用户级别的动态跟踪。与 kprobes 类似,只是跟踪的函数为用户程序中的函数。
    • tracepoints:内核中静态跟踪。tracepoints 是内核开发人员维护的跟踪点,能够提供稳定的 ABI 接口,但是由于是研发人员维护,数量和场景可能受限。
    • perf_events:定时采样和 PMC。
  3. 内核中运行的 BPF 字节码程序可以使用两种方式将测量数据回传至用户空间
    • maps 方式可用于将内核中实现的统计摘要信息(比如测量延迟、堆栈信息)等回传至用户空间;
    • perf-event 用于将内核采集的事件实时发送至用户空间,用户空间程序实时读取分析。

eBPF 的限制

eBPF 技术虽然强大,但是为了保证内核的处理安全和及时响应,内核中的 eBPF 技术也给予了诸多限制,当然随着技术的发展和演进,限制也在逐步放宽或者提供了对应的解决方案。

  • eBPF 程序不能调用任意的内核参数,只限于内核模块中列出的 BPF Helper 函数,函数支持列表也随着内核的演进在不断增加。(todo 添加个数说明)
  • eBPF 程序不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。
  • eBPF 程序中循环次数限制且必须在有限时间内结束,这主要是用来防止在 kprobes 中插入任意的循环,导致锁住整个系统;解决办法包括展开循环,并为需要循环的常见用途添加辅助函数。Linux 5.3 在 BPF 中包含了对有界循环的支持,它有一个可验证的运行时间上限。

CNI插件

CNI的全称是 Container Network Interface,即容器网络的 API 接口。最早是由CoreOS发起的容器网络规范,是Kubernetes网络插件的基础。其基本思想为:Container Runtime在创建容器时,先创建好network namespace,然后调用CNI插件为这个netns配置网络,其后再启动容器内的进程。现已加入CNCF,成为CNCF主推的网络模型。

背景

容器网络的配置是一个复杂的过程,为了应对各式各样的需求,容器网络的解决方案也多种多样,例如有flannel,calico,kube-ovn,weave等。同时,容器平台/运行时也是多样的,例如有Kubernetes,Openshift,rkt等。如果每种容器平台都要跟每种网络解决方案一一对接适配,这将是一项巨大且重复的工程。当然,聪明的程序员们肯定不会允许这样的事情发生。想要解决这个问题,我们需要一个抽象的接口层,将容器网络配置方案与容器平台方案解耦

CNI(Container Network Interface)就是这样的一个接口层,它定义了一套接口标准,提供了规范文档以及一些标准实现。采用CNI规范来设置容器网络的容器平台不需要关注网络的设置的细节,只需要按CNI规范来调用CNI接口即可实现网络的设置。

功能

CNI插件负责将网络接口插入容器网络命名空间(例如,veth对的一端,bridge网桥),并在主机上进行任何必要的改变(例如将veth的另一端连接到网桥)。然后将IP分配给接口,并通过调用适当的IPAM插件来设置与“IP地址管理”部分一致的路由。

  • 将容器添加至网络
  • 将容器从网络中删除
  • IP分配

DPU智能网卡

DPU(数据处理单元)

网卡发展

传统数据中心基于冯诺依曼架构,所有的数据都需要送到CPU进行处理。随着数据中心的高速发展,摩尔定律逐渐失效,CPU的增长速度无法满足数据的爆发式增长,CPU的处理速率已经不能满足数据处理的要求。计算架构从以CPU为中心的Onload模式,向以数据为中心的Offload模式转变,而给CPU减负的重任就落在了网卡(网络适配器)上,这也推动了网卡的高速发展;从服务器网卡的功能上看,可以分为三个阶段:

阶段1:基础功能网卡

基础功能网卡(即普通网卡)提供2x10G或2x25G带宽吞吐,具有较少的硬件卸载能力,主要是Checksum,LRO/LSO等,支持SR-IOV,以及有限的多队列能力。在云平台虚拟化网络中,基础功能网卡向虚拟机(VM)提供网络接入的方式主要是有三种:由操作系统内核驱动接管网卡并向虚拟机(VM)分发网络流量;由OVS-DPDK接管网卡并向虚拟机(VM)分发网络流量;以及高性能场景下通过SR-IOV的方式向虚拟机(VM)提供网络接入能力。

阶段2:硬件卸载网卡

在这种背景下,SmartNIC(智能网卡)应运而生。SmartNIC 技术诞生的初衷是以比普通CPU低得多的成本实现对各种虚拟化功能的支持,如SRIOV,overlay encap/decap,以及部分vSwitch处理逻辑的offload。在服务器侧引入智能网卡,将网络、存储、操作系统中不适合CPU处理的高性能数据处理功能卸载到硬件执行,提升数据处理能力,释放CPU算力。

阶段3:DPU智能网卡

传统智能网卡上没有CPU,需要Host CPU进行管理。传统智能网卡除了具备标准网卡的功能外,主要实现网络业务加速。随着网络速度的提高,传统智能网卡将消耗大量宝贵的CPU内核来进行流量的分类、跟踪和控制。

DPU的出现是为了解决数据中心中存在三个方面共五大问题:节点间:服务器数据交换效率低、数据传输可靠性低,节点内:数据中心模型执行效率低,I/O切换效率低、服务器架构不灵活,网络系统:不安全。

DPU区别于SmartNIC最显著的特点,DPU本身构建了一个新的网络拓扑,而不是简单的数据处理卸载计算;DPU可以脱离host CPU存在,而SmartNIC不行。这个本质的区别就是DPU可以构建自己的总线系统,从而控制和管理其他设备,也就是一个真正意义上的中心芯片。

传统智能网卡 vs DPU智能网卡

SmartNIC实现了部分卸载,即只卸载数据面,控制面仍然在Host CPU处理。从总体上来说SmartNIC的卸载操作是一个系统内的协作。

DPU实现了完全的卸载,服务器的数据面和控制面都卸载运行在DPU内部的嵌入式CPU中。DPU实现包括软件卸载和硬件加速两个方面,即将负载从Host CPU卸载到DPU的嵌入式CPU中,同时将负载数据面通过DPU内部的其他类型硬件加速引擎,如协处理器、GPU、FPGA、DSA等来处理。从总体上来说,DPU是两个系统间的协作,把一个系统卸载到另一个运行实体,然后通过特定的接口交互。

处理过程

主机上传统网卡处理报文的过程大致如下:

  1. 传统网卡接收来自网络或主机的数据包,并将其存储在网卡硬件缓存中。
  2. 传统网卡通过DMA(直接内存访问)将数据包从硬件缓存转移到服务器内存中的ring buffer,同时申请一个描述符指向数据包的物理地址。
  3. 传统网卡产生一个硬件中断,通知内核处理数据包。
  4. 内核根据中断号找到对应的中断处理函数,将数据包从ring buffer复制到socket buffer,并进行TCP/IP协议栈的逐层处理。
  5. 内核根据数据包的目标地址,将其转发到网络上的其他节点或者交给应用程序进行进一步的解析和处理。

主机上传统网卡与智能网卡或DPU智能网卡的区别在于,传统网卡依赖于CPU和内核进行数据包的处理,而智能网卡或DPU智能网卡可以在自身完成大部分网络功能,从而减少CPU和内核的负担。


主机上智能网卡处理过程:

主机上的智能网卡处理报文的过程大致如下:

  1. 智能网卡接收来自网络或主机的数据包,并对其进行解析、分类和过滤。
  2. 智能网卡根据数据包的类型和目标,执行相应的网络功能,如路由、转发、加密、负载均衡等。
  3. 智能网卡将处理后的数据包发送到目标地址,无论是网络上的其他节点还是主机内的其他设备。
  4. 智能网卡通过DMA(直接内存访问)将数据保存到主机内存中,并通知CPU处理。
  5. CPU通过中断或轮询的方式检查智能网卡的状态,并从内存中读取数据。
  6. CPU将数据交给应用层软件进行进一步的解析和处理。

主机上的智能网卡与DPU智能网卡的区别在于,主机上的智能网卡仍然依赖于CPU和内存进行数据处理,而DPU智能网卡可以独立于CPU和内存存在和运行。


主机上DPU智能网卡处理过程:

主机上的DPU智能

网卡处理报文的过程大致如下:

  1. DPU接收来自网络或主机的数据包,并对其进行解析、分类和过滤。
  2. DPU根据数据包的类型和目标,执行相应的网络功能,如路由、转发、加密、负载均衡等。
  3. DPU将处理后的数据包发送到目标地址,无论是网络上的其他节点还是主机内的其他设备。
  4. DPU通过自己的总线系统,管理和控制与主机或网络连接的其他设备,如存储、加速器等。
  5. DPU将数据处理/预处理结果发送给主机CPU,或者直接将数据发送给算力分布在更靠近数据源端的边缘计算节点。

主机上的DPU智能网卡与DPU智能网卡相同之处在于,它们都可以独立于CPU和内存存在和运行,并且都可以构建一个新的网络拓扑。主机上的DPU智能网卡与DPU智能网卡不同之处在于,它们所连接的设备类型和位置不同。

RDMA

参考:RDMA技术详解(一):RDMA概述 - 知乎 (zhihu.com)来点硬核的:什么是RDMA? - 腾讯云开发者社区-腾讯云 (tencent.com)

背景

RDMA(RemoteDirect Memory Access)技术全称远程直接内存访问,是为了解决网络传输中服务器端数据处理的延迟而产生的。它将数据直接从一台计算机的内存传输到另一台计算机内存,无需双方操作系统的介入。这允许高吞吐、低延迟的网络通信,尤其适合在大规模并行计算机集群中使用。

传统TCP/IP通信模式

  • 内核空间协议栈拷贝以及内核空间喝用户空间的上下文切换开销:

传统的TCP/IP网络通信,数据需要通过用户空间发送到远程机器的用户空间。数据发送方需要讲数据从用户应用空间Buffer复制到内核空间的Socket Buffer中。然后Kernel空间中添加数据包头,进行数据封装。通过一系列多层网络协议的数据包处理工作,这些协议包括传输控制协议(TCP)、用户数据报协议(UDP)、互联网协议(IP)以及互联网控制消息协议(ICMP)等,数据才被Push到NIC网卡中的Buffer进行网络传输。消息接受方接受从远程机器发送的数据包后,要将数据包从NIC buffer中复制数据到Socket Buffer。然后经过一些列的多层网络协议进行数据包的解析工作。解析后的数据被复制到相应位置的用户应用空间Buffer。这个时候再进行系统上下文切换,用户应用程序才被调用。以上就是传统的TCP/IP协议层的工作。如今随着社会的发展,我们希望更快和更轻量级的网络通信。


  • 当前以小消息的发送为主,处理开销占主导地位:

当今随着计算机网络的发展。消息通信主要分为两类消息,一类是Large messages,在这类消息通信中,网络传输延迟占整个通信中的主导位置。还有一类消息是Small messages,在这类消息通信中,消息发送端和接受端的处理开销占整个通信的主导地位。然而在现实计算机网络中的通信场景中,主要是以发送小消息为主。所有说发送消息和接受消息的处理开销占整个通信的主导的地位。具体来说,处理开销指的是buffer管理、在不同内存空间中消息复制、以及消息发送完成后的系统中断


  • 瓶颈在于消息经过内核进行一系列移动和复制:

传统的TPC/IP存在的问题主要是指I/O bottleneck瓶颈问题。在高速网络条件下与网络I/O相关的主机处理的高开销限制了可以在机器之间发送的带宽。这里感兴趣的高额开销是数据移动操作和复制操作。具体来讲,主要是传统的TCP/IP网络通信是通过内核发送消息。Messaging passing through kernel这种方式会导致很低的性能和很低的灵活性。性能低下的原因主要是由于网络通信通过内核传递,这种通信方式存在的很高的数据移动和数据复制的开销。并且现如今内存带宽性相较如CPU带宽和网络带宽有着很大的差异。很低的灵活性的原因主要是所有网络通信协议通过内核传递,这种方式很难去支持新的网络协议和新的消息通信协议以及发送和接收接口。

DMA简介

DMA(直接内存访问)是一种能力,允许在计算机主板上的设备直接把数据发送到内存中去,数据搬运不需要CPU的参与

传统内存访问需要通过CPU进行数据copy来移动数据,通过CPU将内存中的Buffer1移动到Buffer2中。DMA模式:可以同DMA Engine之间通过硬件将数据从Buffer1移动到Buffer2,而不需要操作系统CPU的参与,大大降低了CPU Copy的开销。

img

RDMA简介

RDMA是一种概念,在两个或者多个计算机进行通讯的时候使用DMA, 从一个主机的内存直接访问另一个主机的内存。

img

在实现上,RDMA实际上是一种智能网卡与软件架构充分优化的远端内存直接高速访问技术,通过将RDMA协议固化于硬件(即网卡)上,以及支持Zero-copyKernel bypass这两种途径来达到其高性能的远程直接数据存取的目标。 使用RDMA的优势如下:

  • 零拷贝(Zero-copy) - 应用程序能够直接执行数据传输,在不涉及到网络软件栈的情况下。数据能够被直接发送到缓冲区或者能够直接从缓冲区里接收,而不需要被复制到网络层。
  • 内核旁路(Kernel bypass) - 应用程序可以直接在用户态执行数据传输,不需要在内核态与用户态之间做上下文切换
  • 不需要CPU干预(No CPU involvement) - 应用程序可以访问远程主机内存而不消耗远程主机中的任何CPU。远程主机内存能够被读取而不需要远程主机上的进程(或CPU)参与。远程主机的CPU的缓存(cache)不会被访问的内存内容所填充。
  • 消息基于事务(Message based transactions) - 数据被处理为离散消息而不是流,消除了应用程序将流切割为不同消息/事务的需求。
  • 支持分散/聚合条目(Scatter/gather entries support) - RDMA原生态支持分散/聚合。也就是说,读取多个内存缓冲区然后作为一个流发出去或者接收一个流然后写入到多个内存缓冲区里去。

在具体的远程内存读写中,RDMA操作用于读写操作的远程虚拟内存地址包含在RDMA消息中传送,远程应用程序要做的只是在其本地网卡中注册相应的内存缓冲区。远程节点的CPU除在连接建立、注册调用等之外,在整个RDMA数据传输过程中并不提供服务,因此没有带来任何负载。

DPDK

转自:DPDK的基本原理、学习路线总结 - 知乎 (zhihu.com)

背景

网络设备(路由器、交换机、媒体网关、SBC、PS网关等)需要在瞬间进行大量的报文收发,因此在传统的网络设备上,往往能够看到专门的NP(Network Process)处理器,有的用FPGA,有的用ASIC。这些专用器件通过内置的硬件电路(或通过编程形成的硬件电路)高效转发报文,只有需要对报文进行深度处理的时候才需要CPU干涉。

但在公有云、NFV等应用场景下,基础设施以CPU为运算核心,往往不具备专用的NP处理器,操作系统也以通用Linux为主,网络数据包的收发处理路径如下图所示:

img

在虚拟化环境中,路径则会更长:

img

由于包处理任务存在内核态与用户态的切换,以及多次的内存拷贝,系统消耗变大,以CPU为核心的系统存在很大的处理瓶颈。为了提升在通用服务器(COTS)的数据包处理效能,Intel推出了服务于IA(Intel Architecture)系统的DPDK技术。

原理介绍

DPDK是Data Plane Development Kit(数据平面开发套件)的缩写。简单说,DPDK应用程序运行在操作系统的User Space,利用自身提供的数据面库进行收发包处理,绕过了Linux内核态协议栈,以提升报文处理效率。

DPDK是一组lib库和工具包的集合。最简单的架构描述如下图所示:

img

上图蓝色部分是DPDK的主要组件(更全面更权威的DPDK架构可以参考Intel官网),简单解释一下:

  1. PMD:Pool Mode Driver,轮询模式驱动,通过非中断,以及数据帧进出应用缓冲区内存的零拷贝机制,提高发送/接受数据帧的效率
  2. 流分类:Flow Classification,为N元组匹配和LPM(最长前缀匹配)提供优化的查找算法
  3. 环队列:Ring Queue,针对单个或多个数据包生产者、单个数据包消费者的出入队列提供无锁机制,有效减少系统开销
  4. MBUF缓冲区管理:分配内存创建缓冲区,并通过建立MBUF对象,封装实际数据帧,供应用程序使用
  5. EAL:Environment Abstract Layer,环境抽象(适配)层,PMD初始化、CPU内核和DPDK线程配置/绑定、设置HugePage大页内存等系统初始化

总结一下DPDK的核心思想:

  1. 用户态模式的PMD驱动,去除中断,避免内核态和用户态内存拷贝,减少系统开销,从而提升I/O吞吐能力
  2. 用户态有一个好处,一旦程序崩溃,不至于导致内核完蛋,带来更高的健壮性
  3. HugePage,通过更大的内存页(如1G内存页),减少TLB(Translation Lookaside Buffer,即快表) Miss,Miss对报文转发性能影响很大
  4. 多核设备上创建多线程,每个线程绑定到独立的物理核,减少线程调度的开销。同时每个线程对应着独立免锁队列,同样为了降低系统开销
  5. 向量指令集,提升CPU流水线效率,降低内存等待开销

下图简单描述了DPDK的多队列和多线程机制:

img

DPDK将网卡接收队列分配给某个CPU核,该队列收到的报文都交给该核上的DPDK线程处理。存在两种方式将数据包发送到接收队列之上:

  1. RSS(Receive Side Scaling,接收方扩展)机制:根据关键字,比如根据UDP的四元组进行哈希
  2. Flow Director机制:可设定根据数据包某些信息进行精确匹配,分配到指定的队列与CPU核

当网络数据包(帧)被网卡接收后,DPDK网卡驱动将其存储在一个高效缓冲区中,并在MBUF缓存中创建MBUF对象与实际网络包相连,对网络包的分析和处理都会基于该MBUF,必要的时候才会访问缓冲区中的实际网络包

img

记录一些bug和修改以及日志


部署问题

云服务器应用防火墙设置

在服务器上部署需要先设置端口才能连接

image-20221028144759472


客户端发布

  • 图标
    • 资源文件那添加资源,选icon,然后导入一个ico的文件即可
  • vs选择release x64,然后生成即可。

内网穿透-p2p文件发送

发文件时,客户端互相connect不上,listen端无所谓,发起connect的那方马上就发现无法连接就返回了。这是因为主机在内网的缘故。

NAT与内网穿透

假设A打算发文件给B,那么服务器只将客户端B连接服务器使用的ip发给了A,对于内网用户来说,B的这个ip是路由器网关的ip,要进行NAT转换才能到内网主机,NAT就是网络地址转换的意思,因此需要进行内网穿透。

对于内网用户来说,服务器接收到一个ip和一个端口,如果是公网用户,则这个ip是主机ip,端口是主机进程使用的端口;如果是内网用户,则这个ip是网关的ip,端口是映射表中的端口,根据这个端口,网关能知道要发送给哪个主机中的哪个进程。NAT转换表的映射如下:

img

内网穿透(Intranet penetration)就是通过一个公网服务器,让内网主机去连接服务器,服务器就能获取两个内网主机的ip和端口(实际上是各自的网关的ip和映射表中的端口),然后两个内网主机就可以通信了。

UDP打洞

这实际上就是内网穿透最通常的实现方式,服务器如何获取内网主机网关的ip和端口呢,总是要通过连接或者发送信息。因为TCP开销比UDP大得多,所以一般来讲都是使用UDP来实现内网穿透,所以也叫UDP打洞(UDP hole punching)。

当然使用TCP也是可以的。


测NAT类型,用miwifi.com测试,每次打开第一次都是端口限制圆锥形,然后之后都是完全圆锥形。这是软件的问题,不管怎么样,是cone类型即可。

image-20221031110618667

image-20221030210807715

客户端中的问题

原来我以为已经实现了内网穿透了…但刚开始没去细想,理解还是片面了,其实并没有实现。

注意这里的端口映射是映射到一个进程的,也就是说内网穿透实际上是进程(线程)与进程(线程)之间的互相穿透。

然而客户端只有主线程和接收线程连上了服务器,发送文件和接收文件的线程并没有!这两个线程才是真正需要p2p对端发送文件的,但由于接收文件线程是临时创建的,所以需要再内网穿透,因此要让接收文件线程也去连接服务器,让发送线程获取ip端口信息才行。回顾一下先前的设计:

  • 1)用户A发送sendfile请求,服务器发sendfilefrom给B
  • 2)用户B发送acceptfile给服务器,并开始listen等待连接
  • 3)服务器给A发一个sendfile accept + ip信息,A准备根据这个 ip 和sendfile port连接B(实际上该ip是B的网关ip,但是并没有端口映射到接收文件线程)
    • A首先根据ip连接到网关
    • 然后网关根据port查映射表
    • 但由于B接收文件线程没有在网关中添加映射,首先网关肯定不能映射到该线程;并且网关需要查询的port也不一定就是sendfile port(因为网关添加映射是它自己添加的,port怎么样在添加前并不清楚)

因此需要B去连接服务器,一方面让网关添加映射,一方面让服务器获取网关自己添加的映射表中的端口。

简单的想法是直接让接收文件线程去TCP连接服务器,然后让其发送acceptfile命令,这里实现UDP打洞。

当用户B发送acceptfile的命令时,还是让命令主线程直接发送,接着让接收线程创建socket后直接向服务器的端口发一个信息。服务器在获取这个信息时,知道这个命令就是接收文件线程发过来的,就直接把ip和port发给发送文件线程。

更具体的实现是:

  • 首先服务器新建一个listen套接字(和服务器一起初始化),这是因为另外两个套接字在连接时都会做一些后续动作(添加映射表和向epoll注册事件),这里并不需要,因为连接是一次性的。
  • 然后当服务器收到acceptfile请求后,不像之前一下拼接ip就发回去了,而是调用recvfrom阻塞(如果客户端发的比较慢)等待或直接获取(如果客户端发得快)客户端sendto发送的信息(因为udp不确保正确,所以随便发就可以了),然后把ip和port拼接发给目标就可以了。
  • 客户端直接创建完socket就sendto,不管服务器有没有收到,然后进行listen等待对端传输文件。

注意这里的问题不能在用户较多并且同时使用acceptfile时区分是哪个用户,这也是UDP打洞的问题所在,除非再添加其他实现。这里不搞那么复杂。


实现

定义端口号为10000,在云服务器防火墙添加UDP规则。

UDP流程如下:

image-20221030200457821

主要介绍两个函数:

1
2
3
4
5
6
7
8
9
10
11
int recvfrom(int sockfd, void * buf, size_t len, int flags, struct sockaddr * src_addr, socklen_t * addrlen);
/*
recvfrom: 用于接收数据
- sockfd:用于接收UDP数据的套接字;
- buf:保存接收数据的缓冲区地址;
- len:可接收的最大字节数(不能超过buf缓冲区的大小);
- flags:可选项参数,若没有可传递0;
- src_addr:存有发送端地址信息的sockaddr结构体变量的地址;
- addrlen:保存参数 src_addr的结构体变量长度的变量地址值。
*/
返回值:成功为发送的字节数,失败为-1,失败原因存于errno
1
2
3
4
5
6
7
8
9
10
11
int sendto(int sockfd, const void * buf, size_t len, int flags, const struct sockaddr * dest_addr, socklen_t addrlen);
/*
sendto:用于发送数据
- sockfd:用于传输UDP数据的套接字;
- buf:保存待传输数据的缓冲区地址;
- len:带传输数据的长度(以字节计);
- flags:可选项参数,若没有可传递0;
- dest_addr:存有目标地址信息的 sockaddr 结构体变量的地址;
- addrlen:传递给参数 dest_addr的地址值结构体变量的长度。
*/
返回值:成功为发送的字节数,失败为-1,失败原因存于errno

recvfrom相当于把accept的事情做了(保存了客户端地址端口),sendto相当于把connect的事情做了(用到了服务器的ip和port)

对于listen来说,该bind还是要bind,才能启动监听,但不用调用listen()函数。后面可以看到,创建socket的方式基本都是相同的,当创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect连接的客户套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应该接受指向该套接字的连接请求。

而UDP不是面向连接的,当然不用listen()了,创建出来的端口发送可接收。接收的话就要bind,仅发送就不用bind。bind的作用是,使得这个套接字的接收是从该端口接收的,发送是从该端口发送的(使得报文中的源端口是该端口)。所以一般客户端不用bind某个端口,交给系统从connect后选择,这样同样的代码可以避免bind同一个端口,否则每次都要改端口。而当需要收发端口统一时,请使用bind。

服务器端实现,初始化UDP监听端口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void init_udp_Socket(int& listenfd, const int port)
{
//定义socketfd,它要绑定监听的网卡地址和端口
listenfd = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);//UDP
if(listenfd < 0)
{
LOG_ERROR("create listen socket error, port-%d",port);
exit(1);
}
//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);//字节序转换
socketaddr.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY表示监听所有网卡地址,0.0.0.0;

//端口复用,在bind前设置,否则bind时出错就晚了
int optval = 1;
int ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void*)&optval, sizeof(int));
if(ret == -1)
{
LOG_ERROR("set socket setsockopt error !");
close(listenfd);
exit(1);
}

//绑定套接字和地址端口信息,sockaddr_in转成sockaddr
if(bind(listenfd,(struct sockaddr *)&socketaddr,sizeof(socketaddr))==-1)
{
LOG_ERROR("bind port-%d error !",port);
close(listenfd);
exit(1);
}
//完事了
}

原来的acceptfile命令的处理基本长这样,需要在sendstr前把ip和端口拿到。

1
2
3
4
5
6
7
8
9
10
11
12
13
void acceptfile(int conn1, string sid)
{
...
if(state == clientState::isWaiting)
{
string myip = usermap.fvalue_conn1_ip(conn1);
sendstr = "@#sendfile accept "+myip;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

...
}
...
}

添加一个函数,放回ip和port的string。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
string udp_hole_punching(int listenfd)
{
//定义sockaddr_in
struct sockaddr_in gateway;//表示网关
socklen_t addr_len = sizeof(gateway);
memset(&gateway, 0, sizeof(gateway));
char recvbuf[128];//对数据不感兴趣

//len要传地址,因为要保存写入结构体的长度
int res = recvfrom(listenfd, recvbuf, 128, 0, (struct sockaddr *)&gateway, &addr_len);
if(res < 0)
{
LOG_ERROR("udp hole punching receive error!");
return "";
}
else
{
string ip = string(inet_ntoa(gateway.sin_addr));
string port = to_string(ntohs(gateway.sin_port));
LOG_DEBUG("udp hole punching ip: %s, port: %s",ip.c_str(),port.c_str());
return ip+" "+port;
}
}

服务器差不多就完成了,下面是客户端的修改。客户端主要是在recvfile这个函数做修改,在函数开始前发送udp打洞信息即可,实现一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
bool udp_hole_punching(const char* server_ip, const int port)
{
//定义socketfd,它要绑定监听的网卡地址和端口
sockfd = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);//UDP
if (sockfd == INVALID_SOCKET)
{
printf("udp socket error!\n");
return false;
}
//定义sockaddr_in
struct sockaddr_in socketaddr;//告知要发送的目标ip及端口
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);//字节序转换
inet_pton(AF_INET, server_ip, &socketaddr.sin_addr);

//发送数据,最后的len不用传地址,因为是告知,不用修改
char sendbuf[10] = "udp";
int res = sendto(sockfd, sendbuf, strlen(sendbuf), 0, (struct sockaddr*)&socketaddr, sizeof(socketaddr));
if(res < 0)
{
printf("udp sendto error!\n");
return false;
}
return true;

}

linux-windows字符集问题

linux和windows编码不一样,中文乱码

一些奇妙的bug

客户端退出问题

  • 客户端接收线程recv阻塞退出问题

    • 一开始很奇怪,exit后服务器关闭套接字,然后接收线程就出问题了,recv是-1然后退出一直循环。

    • 这个问题是因为使用了size_t recvbytes = recv(connfd, recvbuf, sizeof(recvbuf), 0);其中size_t无法让返回值变成负数,因此判断-1失效,无法获取服务器已关闭的消息。

    • 然后发现了更怪的问题,就是服务器只close主线程的套接字,接收线程的并没有管,为什么接收线程会退出呢?

    • 查看socket的error字段,发现是10053,即主机主动关闭了连接。思考可能是主线程退出后关了些东西使得接收线程也失败了。检查发现主线程只关了自己的套接字,(一开始没有发现)再仔细看发现执行了WSACleanup();使得socket都退出了,这也难怪接收线程会直接退出。

    • 然而这并不是什么不好的事情,因为本来接收线程recv阻塞也不好退出,现在刚好根据主线程退出,二者同时close掉套接字,然后服务器分别响应并close。分别响应的原因是,当客户端自己崩了的话也是二者同时close掉套接字,此时服务器也应该是分别响应的。

  • 注意客户端调用exit后会close自己的套接字,所以服务器可以直接根据close这个信息来exit_,不需要根据exit命令来操作;这样可以把客户端正常退出和异常退出的情况合起来。


cout多线程安全问题

  • cout多线程安全问题,这个在客户端里涉及。因为cout本身是流对象重载了<<函数,所以<<endl和前面的不是同个函数调用(flush同理),因此会被其他线程的cout挤掉,就导致输出混乱(主要是换行endl被挤掉了不好看)。解决方法是:
    • 换行符直接写到字符串里,但cout刷新缓冲不支持,可能不能及时输出,因为\n在cout中不会刷新,刷新时机:
      • 程序正常退出会刷新cout的缓冲区
      • 一些输出操纵符可以帮助我们刷新,比如endl,flush,ends 代码实例: cout<<”hello”<<flush;由于重载函数,每个<<都可以被其他线程挤掉
      • 将输入于输出绑定在一起,则输入会导致刷新输出的缓冲区 代码:cin.tie(&cout)
      • 也可以通过unitbuf操纵符设置流的内部状态,从而清空缓冲区
    • 使用printf,在printf中\n会刷新缓冲区,刷新时机程序正常退出,输出字符带有‘/n’,调用函数fflush(stdout),发生标准输入,但注意printf能打印的格式是有限制的,cout可以打印重载了<<运算符的对象。
    • c++20中出现了std::format,太新了先不用
    • 也可以cout时加个互斥锁。。。
  • 解决方法就是在接收线程那cout尽量改为printf,主线程使用cout就不用改了(也比较多)。为什么说尽量,因为有的cout不用换行。

服务器命令解析问题-gdb调试

在chatting的一方exit后服务器崩溃,出现Segmentation fault (core dumped)。需要用gdb查看core文件,首先ulimit -c unlimited,然后在Makefile编译选项加个-g(就是-o2那里)。

不过我还是没产生core文件,直接gdb server,在gdb内运行程序(start),一直nnext跳转到start那行代码,然后复现bug,最终发现是命令解析出了问题:

image-20221028172207557

唯一的可能是:因为chatting中要退出,所以要使用@,这说明要进一步检查@这一部分。

跟踪客户端的代码,对于@命令的解析是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
vector<string> parse(string cmdstr)
{
//首先看第一个字符是不是@,是的话去掉就好了
if (cmdstr[0] == '@')
cmdstr = cmdstr.substr(1, cmdstr.size() - 1);
//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}
return res;//返回值是右值,外部vector会接收右值,调用移动构造
}

可以看到为了复用没有@的解析,把传进来的cmdstr的@去掉了,但是上层并没有去掉,直接发给服务器了,就出问题了。所以要么把发的去掉,要么收的时候去掉。这里使用引用的话会把换行也消了,所以打算修改服务器端的代码。

改完之后这个bug就解决了。


很小的失误都会导致崩溃

  • 用户1accept后服务器没有把用户1的名字发给用户2,只发了@#chat accept,导致用户2访vector越界。
  • 用户1accept后忘记切换状态了。

image-20221028232057720

这个bug是客户端sendfile后,对端因为服务器发过来的filename是空导致取filename时vec越界崩溃。

检查发现是服务器在处理sendfile命令时获取文件名使用的:find_last_of,打成了find_last_not_of,这样总是找到最后一个位置,然后把filename变成空。

1
2
3
4
5
6
7
8
9
10
11
size_t pos = filename.find_last_of("/\\");//把not去掉
if (pos != string::npos)
filename = filename.substr(pos + 1);

//并且加一个保护,以防发了个文件夹过来,比如a/
if(filename == "")
{
sendstr = "filename error, please break and check!";
send(myconn2, sendstr.c_str(), sendstr.size(), 0);
return;
}

相同ip会导致映射表冲刷。这是因为使用了交换机,发给服务器的ip是交换机的ip。

日志

  • 2022-10-12:有个idea,开始设计
  • 接下来两天:设计客户端状态机和初步完成代码,还有一堆烦人的课程作业
  • 接下来五天:根据状态机边设计边实现若干业务处理函数,还有一堆烦人的课程作业
  • 接下来大概三天:完善最后的逻辑补充,如一些特殊情况的思考,以及补充一些边写边想起来的命令(config那些),还有一堆烦人的课程作业
  • 接下来四天:实现服务端的设计,复杂度主要在用户映射和逻辑处理的那块,还有一堆烦人的课程作业
  • 2022-10-26:客户端和服务端都完成,由于代码在markdown中手写,有少量warning和error,迅速改完后已经能成功跑起来了
    接下来两三天:测bug,还是有一些问题,内存越界啊cout多线程安全,都是小事(一查查半天hh),调试后能正确运行,还有一堆烦人的课程作业
  • 2022-10-30:基于tcp的p2p文件传输无响应,猜测是内网无法连接,准备进行内网穿透的实现,从NAT的类型开始了解,进行了简单的udp打洞测试(基于c++)
  • 2022-10-31:udp打洞逐渐深入,实现了逆向连接(NAT和公网客户端),由于网络资源不允许(大多数人使用校园网是对称型,这种无法穿透),还无法进行双NAT下的udp打洞内网穿透;测试了tcp逆向连接的完成(基于conntrack连接跟踪原理,穿透了防火墙),这个过程中发现了TCP同时打开的现象(双主动connect实现tcp连接建立)
  • 2022-11-1:周二课多,没写代码。系统了解了TCP同时打开的原理,整理了一篇博客(技术讲解博客确实写得少)
  • 接下来三天:这几天没怎么干,有一堆烦人的课程作业,然后周五考试,考完下午散了下心
  • 2022-11-5:借助小薛的路由器(NAT是圆锥型,可以穿透)验证了udp打洞的内网穿透;但基于tcp的内网穿透一直无法实现。
  • 接下来两天:打算用quic实现udp可靠文件传输。试着配msquic环境,然而微软这个文档写的真逆天,作者测试也不完全,网上也没有相关的配置博客,折磨了两天放弃了(win10的TLS1.3打开了也test失败)
  • 接下来两天:事情多,课多以及写课程大作业…
  • 2022-11-9:不打算用quic了,看了其他RDT的UDP,有UDT和RUDP,GitHub上看开源项目看了好久,不太热门的东西文档太烂了,而且接口也不写明白;还看了下别人实现的简单的RDT的UDP,写的太烂了。浪费一中午和一下午时间和一个傍晚的时间,急,项目被卡着快两周了。还是要整理下心情
  • 2022-11-10:打算自己写一个RDT的UDP,并且不参考tcp而参考quic,当然只是简单实现。估计要写很久了,接下来事情好多。
  • 2022-11-20:十天过去了,下半学期开始后好忙…
  • 2022-11-27:终于完成了RDT的UDP实现,但还没融入到项目里,之后还要肝大作业,需要等寒假再完成了。
  • 2022-12-18:大作业队友迟迟不干活,把项目整理完,项目开发到此为止。

任务分发-epoll

  • 使用非阻塞的、ET模式的IO

每次添加事件、修改事件、删除事件都是差不多的流程,可以封装一下,并且epollfd的create和close可以变成RAII的管理模式。

因为上层调用时,事件的类型是不一样的(读转读、读转写、写转写等),为了接口易用与通用,让上层传入events描述事件类型。events是一个uinit32_t,也即32位无符号整数类型。

封装很简单,只需要支持添加、修改、删除、调用epoll_wait、设置非阻塞(这个也让上层决定,默认非阻塞)即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
//Epoller.h
#ifndef EPOLLER_H
#define EPOLLER_H

#include <sys/epoll.h> //epoll操作
#include <fcntl.h> // fcntl()
#include <unistd.h> // close()
#include <cassert>
#include <vector>
class Epoller
{
private:
int eventSize;
const bool nonBlock;
int epollFd;

std::vector<struct epoll_event> events;
public:
Epoller(const int eventsize = 1024, const bool ifNonBlock = true):
eventSize(eventsize),nonBlock(ifNonBlock),epollFd(epoll_create(5)),events(eventsize)
{
assert(epollFd>=0);
}
~Epoller()
{
close(epollFd);
}

int setFdNonblock(int fd)
{
return fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);//出错返回-1
}

bool addFd(int fd, uint32_t events)
{
if(fd < 0) return false;
//如果需要设置非阻塞,根据布尔运算就会调用SetFdNonblock函数,如果返回不是-1就成功,是-1就返回false
if(nonBlock && (setFdNonblock(fd)==-1))
return false;

epoll_event ev = {0};
ev.data.fd = fd;//关联fd
ev.events = events;//上层设置好类型
return 0 == epoll_ctl(epollFd, EPOLL_CTL_ADD, fd, &ev);//add,成功返回0
}

bool modFd(int fd, uint32_t events)
{
if(fd < 0) return false;
epoll_event ev = {0};
ev.data.fd = fd;
ev.events = events;
return 0 == epoll_ctl(epollFd, EPOLL_CTL_MOD, fd, &ev);//mod
}

bool delFd(int fd)
{
if(fd < 0) return false;
return 0 == epoll_ctl(epollFd, EPOLL_CTL_DEL, fd, 0);
}

//对于timeout:-1:永远等待;0:不等待直接返回,执行下面的代码;其他:在超时时间内没有事件发生,返回0,如果有事件发生立即返回
//默认不等待
int wait(int timeoutMs = 0) //成功返回多少事件就绪,超时返回0,出错返回-1
{
return epoll_wait(epollFd, &events[0], eventSize, timeoutMs);
}
int getEventFd(size_t i)
{
return events[i].data.fd;
}
uint32_t getEvents(size_t i)
{
return events[i].events;
}
};

#endif

调用epoller伪代码:

简单说明一下异常事件:

  • EPOLLRDHUP:对方调用close()正常断开连接,服务器会得知该信息;或者直接终止进程,操作系统会自动向服务器发FIN,也会得知。
  • EPOLLHUP:异常断开,当socket的一端认为对方发来了一个不存在的4元组请求的时候,会回复一个RST响应,在epoll上会响应为EPOLLHUP事件。
    • RST:表示重置连接、复位连接。一般有两种场景:
    • 对于客户端:当客户端向一个没有在listen的服务器端口发送的connect的时候,服务器会返回一个RST,因为服务器根本不知道这个4元组的存在。
    • 对于服务器:当客户端的系统突然崩溃(kill pid或者正常关机不行的,因为操作系统会发送FIN给对方),这时服务器从4元组发到客户端的数据就发回一个RST。
      • 使用shutdown、close关闭套接字,发送的是FIN,不是RST
      • 套接字关闭前,使用sleep。对运行的程序Ctrl+C,会发送FIN,不是RST
      • 套接字关闭前,执行return、exit(0)、exit(1),会发送FIN、不是RST
    • 以上后两个是操作系统干的。
  • EPOLLERR:上面两个异常事件都是服务器被动从客户端返回的(FIN或RST)信息得知的,EPOLLERR是服务器主动采取动作,如read和write时,发现对方出问题了,就出现这个异常。

关于两个监听端口:

  • 之前在客户端中简单描述为“两个监听线程”,本来想用两个线程、两个epoll池的。但实际上可以只在主线程里根据epoll响应两个端口上的连接请求
  • 因此实际上两个监听端口都在主线程accept,通过epoll_wait判断是listenfd1还是listenfd2即可。
  • 这是因为发送线程不会接收信息,它唯一的作用就是用于判断连接这个端口的是客户端的接收线程。那么两个监听端口就可以共用一个epoll池,因为epoll池响应到的connfd一定是listenfd1上的,就不需要两个epoll池分别响应connfd了。
  • 不过因为这样叫方便,还是叫做什么什么线程来区分这两个东西,但其实都在主线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include "epoller.h"
#include "threadpool.h"
const int MAX_EVENT = 20;
const int timeout = 0;//不阻塞
unique_ptr<Epoller> epoller(1024);
void start()//主进程
{
threadpool threadp(20);
while(true)
{
int eventCnt = epoller.wait(timeout);
for(int i=0; i<eventCnt; i++)
{
int fd = epoller.getEventFd(i);
uint32_t events = epoller.getEvents(i)
//处理epoll出错和对端关闭情况
if(fd == listenfd1)//注意有两个listen端口
{
listen_1();//把所有connect的都accept,ET模式要循环到底
}
else if(fd == listenfd2)
{
listen_2();
}
else if(events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))//异常事件
{//一般来讲,某个异常是客户端出问题,这样两个connfd都应该会异常,然后都关闭,
//所以不用根据一个把另一个同时关了,关这次的事件即可

/*
* 帮这个用户调exit_,删除表和让影响的对方退出,exit内会close套接字并删除事件
* 这样用户直接exit_后,因为exit_内close了,就不会进入这里再exit_重复调用了
* 而如果用户没有exit_直接关闭或崩溃,就帮忙exit_,总之就是只调用一次
*/
if(usermap.fvalue_conn1_ip(fd) != "")//如果是conn1
exit_(fd);//nologin的话break_直接不会做什么事情
else//conn2
{
epoller.delFd(fd);//从epoll内删除事件
close(fd);//conn2直接close
}

//其他处理
}
else if(events & EPOLLIN)//处理读事件
{
threadp.addTask(bind(task,fd));//添加任务,该任务处理读与写
}
}
//其他处理
}
}

recv处理

task中的接收,只有recv才被epoll响应,send是直接工作的,所以不受ET影响,也不用重新注册

非阻塞情况下,recv返回大于0是字节数,返回等于0是网络断开了或copy出错,这是严重错误,不会设置errno;小于0(**-1**)是其他错误,保存在errno

注意:EPOLLHUP、EPOLLERR这两个在add和mod时不需要手动注册这两个事件,内核会自动添加这两个事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <errno.h>
const uint32_t CONNEVENT = EPOLLONESHOT | EPOLLRDHUP | EPOLLET | EPOLLIN;
string recv_str(int conn1)
{
char recvbuf[256];
string recvstr;
while(true)
{
memset(recvbuf, 0, sizeof(recvbuf));//把接收缓冲清零
size_t len = recv(conn1, recvbuf, sizeof(recvbuf), 0);
if(len == 0)//copy出错或断开
{
string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
LOG_ERROR("%s [recv] error!", myuser.c_str());
uint32_t connEvent = CONNEVENT;//因为是oneshot,每次重新注册
epoller.modFd(conn1, connEvent);//重新设置,等下一次
return "";//这次就不做了
}
else if(len == -1)
{
if(errno == EAGAIN || errno == EWOULDBLOCK)//说明读完了,两个errno是一样的意思
{
uint32_t connEvent = CONNEVENT;//因为是oneshot,每次重新注册
epoller.modFd(conn1, connEvent);
break;//正常结束
}
else if(errno == EINTR)//被中断了,重新读
continue;
else //其他错误
{
string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
LOG_ERROR("%s [recv] error!", myuser.c_str());
uint32_t connEvent = CONNEVENT;//因为是oneshot,每次重新注册
epoller.modFd(conn1, connEvent);//重新设置,等下一次
return "";//这次就不做了
}
}
else//接收到了信息
{
recvstr += string(recvbuf);
}
}
return recvstr;
}

listen处理

简单一点的返回就accept返回-1就直接return了,但详细一点可以继续细分:

说明一下ECONNABORTED,这涉及TCP三次握手:

  • 首先客户端在connect时,发出第一次握手SYN
  • 此时epoll中listenfd收到响应,进入accept处理,返回SYN+ACK第二次握手,并等待第三次握手
  • 如果客户端返回ACK则accept成功,但如果客户端此时返回的是RST,就是这个异常事件了,但这个异常事件会被内核处理,上层直接跳过就好了。
  • 前面说到RST是很偶然的情况,所以一般不进行处理也行。包括被系统中断其实也不常见。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//listen不用oneshot,所以不用重新注册

void listen_1()//accept一次是一个客户连接,ET模式下要连续accept把连接建立完
{
while(true)
{
struct sockaddr_in client_addr;//获取客户的地址和端口号,连接后的不分配新端口
socklen_t len = sizeof(client_addr);//socklen_t 相当于 int,但使用int必须强制转型告知编译器
int conn1 = accept(listenfd1, (struct sockaddr*)&client_addr, &len);
//成功返回非负数
if(conn1 == -1)
{
if(errno == EAGAIN || errno == EWOULDBLOCK)
return;//读完了就返回
else if(errno == ECONNABORTED || errno == EINTR)
continue;//被中断了或客户端断开了就继续
else
{
LOG_DEBUG("server accept error");
continue;
}
}

//向内核注册conn1
uint32_t connEvent = CONNEVENT;
epoller.addFd(conn1, connEvent);

string ip = string(inet_ntoa(client_addr.sin_addr));
usermap.ins_conn1_ip(conn1,ip);//添加映射
LOG_DEBUG("server accept ip-%s",ip.c_str());

}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void listen_2()//accept一次是一个客户连接,ET模式下要连续accept把连接建立完
{
while(true)
{
struct sockaddr_in client_addr;//获取客户的地址和端口号,连接后的不分配新端口
socklen_t len = sizeof(client_addr);//socklen_t 相当于 int,但使用int必须强制转型告知编译器
int conn2 = accept(listenfd2, (struct sockaddr*)&client_addr, &len);
//成功返回非负数
if(conn2 == -1)
{
if(errno == EAGAIN || errno == EWOULDBLOCK)
return;//读完了就返回
else if(errno == ECONNABORTED || errno == EINTR)
continue;//被中断了或客户端断开了就继续
else
{
LOG_DEBUG("server accept error");
continue;
}
}

//向内核注册conn2,注册是因为可能会有异常事件要处理
uint32_t connEvent = CONNEVENT;
epoller.addFd(conn2, connEvent);

string ip = string(inet_ntoa(client_addr.sin_addr));
usermap.ins_ip_conn2(ip,conn2);//添加映射
LOG_DEBUG("server accept [2] ip-%s",ip.c_str());

}

}

初始化监听端口

命令主线程端口为9000,发送线程端口为8000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const int port1 = 9000;
const int port2 = 8000;
int listenfd1;
int listenfd2;
const uint32_t LISTENEVENT = EPOLLRDHUP | EPOLLET | EPOLLIN;
//因为accept是在主线程执行的,所以listenfd不需要oneshot,
//因为监听套接字在执行accept时主线程不会去调用wait,也就不会导致多线程竞争相同套接字

void init_all_Socket()
{
init_one_Socket(listenfd1,port1);
init_one_Socket(listenfd2,port2);//代码复用
}

void init_one_Socket(int& listenfd, const int port)
{
//定义socketfd,它要绑定监听的网卡地址和端口
listenfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);//第三个参数写0也可以,这里表示创建tcp套接字
if(listenfd < 0)
{
LOG_ERROR("create listen socket error, port-%d",port);
exit(1);
}
//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);//字节序转换
socketaddr.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY表示监听所有网卡地址,0.0.0.0;

//端口复用,在bind前设置,否则bind时出错就晚了
int optval = 1;
int ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void*)&optval, sizeof(int));
if(ret == -1)
{
LOG_ERROR("set socket setsockopt error !");
close(listenfd);
exit(1);
}

//绑定套接字和地址端口信息,sockaddr_in转成sockaddr
if(bind(listenfd,(struct sockaddr *)&socketaddr,sizeof(socketaddr))==-1)
{
LOG_ERROR("bind port-%d error !",port);
close(listenfd);
exit(1);
}
//开始监听,SOMAXCONN是系统给出的请求队列最大长度
if(listen(listenfd,SOMAXCONN) == -1)
{
LOG_ERROR("listen port-%d error!", port);
close(listenfd);
exit(1);
}
uint32_t listenEvent = LISTENEVENT;
if(!epoller.addFd(listenfd, listenEvent))
{
LOG_ERROR("add listen to epoll error!");
close(listenfd);
exit(1);
}
LOG_INFO("server listenning to port-%d", port);
}

说明一下SO_REUSEADDR

  • SO_REUSEADDR:

  • 端口复用

  • 一般来说,一个端口释放后会等待一会之后才能再被使用,因为主动关闭会time_wait

  • SO_REUSEADDR只有针对time-wait连接,确保server重启成功的这一个作用

  • linux系统time-wait连接持续时间为1min。

  • SOL_SOCKET表示在套接字级别上设置选项

  • optval=1:不等于0打开,等于0关闭

  • SO_REUSEADDR是让端口释放后立即就可以被再次使用。

  • SO_REUSEPORT是让多个进程可以绑定相同端口,并发性更好,可扩展性强

日志系统

之前的博客有完整的从头编写的步骤,可以完整地学习:WebServer模块单元测试 | JySama,在日志系统部分。

阻塞队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#ifndef BLOCKQUEUE_H
#define BLOCKQUEUE_H

#include <mutex>
#include <queue>
#include <condition_variable>
#include <cassert>
#include <chrono>
template<class T>
class blockqueue
{
private:
std::queue<T> que;
std::mutex mux;
std::condition_variable condprod;
std::condition_variable condcons;
size_t size;
bool isclose;
public:
blockqueue(int maxsize = 1024):size(maxsize),isclose(false)
{
assert(maxsize>0);//初始化检查
}

~blockqueue()//上层调用close即可
{
}

void close();
void clear();
bool empty();
bool full();

void push(const T &task);
bool pop(T& task);
bool pop(T& task, int timeout);


};


template<class T>
bool blockqueue<T>::empty()
{
//这是日志系统调用的函数,阻塞队列的pop不能调用,否则会死锁
std::lock_guard<std::mutex> locker(mux);
return que.empty();
}

template<class T>
bool blockqueue<T>::full()
{
//这是日志系统调用的函数,阻塞队列的push不能调用,否则会死锁
std::lock_guard<std::mutex> locker(mux);
return que.size()>=size;
}

template<class T>
void blockqueue<T>::push(const T &task)
{
//插入元素,首先抢占互斥锁,但即使抢占了互斥锁也可能不能插入,队列可能是满的,这时要释放锁让消费者线程获得锁
std::unique_lock<std::mutex> locker(mux);//要用条件变量,用unique锁
while(que.size()>=size)//避免虚假唤醒,notify_one一般不会导致虚假唤醒,但要随时最好准备。并且当要关闭时会notify_all
condprod.wait(locker);//等待唤醒
if(isclose)
return;//不能插入元素
que.push(task);//插入元素
condcons.notify_one();//唤醒消费者
}

template<class T>
bool blockqueue<T>::pop(T& task)
{
std::unique_lock<std::mutex> locker(mux);
while(que.empty() and !isclose)
condcons.wait(locker);

if(isclose)//关闭的信号
return false;

task = que.front();
que.pop();
condprod.notify_one();
return true;
}

template<class T>
bool blockqueue<T>::pop(T& task, int timeout)
{
std::unique_lock<std::mutex> locker(mux);
while(que.empty() and !isclose)//为空就等待,等待过程中如果超时就返回
if(condcons.wait_for(locker,std::chrono::seconds(timeout)) == std::cv_status::timeout)//超时
return false;

if(isclose)//关闭的信号
return false;
task = que.front();
que.pop();
condprod.notify_one();
return true;
}

template<class T>
void blockqueue<T>::close()
{
/*
* 当关闭时,我们的目标是要让所有的线程都退出,也就是不能被阻塞到push和pop里
* 调用此函数的时机是,上层日志系统已经把任务都做完,然后关闭队列
* 则调用这个函数后,所有想再次尝试push和pop的线程都不允许
*/
std::lock_guard<std::mutex> locker(mux);//要锁住,然后clear队列
clear();
isclose = true;//修改信号
//唤醒所有线程
condprod.notify_all();
condcons.notify_all();
}

template<class T>
void blockqueue<T>::clear()
{
//close已经锁住了,不用锁了
//高效的方式,swap一个空队列
std::queue<T> empty;
std::swap(empty, que);
}

#endif

上层日志系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
//头文件
#ifndef LOG_H
#define LOG_H

#include<mutex>
#include "time.h"
#include<stdarg.h>
#include<string>
#include<stdio.h>
#include<cassert>
#include<thread>
#include <dirent.h> //opendir
#include <sys/stat.h> //mkdir
#include "blockqueue.h"
using namespace std;
class Log
{
private:
Log();//单例模式构造函数私有,成员函数才能调用构造函数
public:
Log(Log const&) = delete;
Log& operator=(Log const&) = delete;
~Log();//关闭...//析构函数实际上和构造函数一样,可以private,因为本质上是成员函数调用
static Log* instance();//单例

//函数声明和定义,只能有一个使用默认参数,如果函数的声明和定义是分开的,那缺省函数不能在函数声明和定义中同时出现
//默认参数在函数声明中提供,当又有声明又有定义时,定义中不允许默认参数(定义中的默认参数是无用的,必须传入参数才能找到匹配的函数)
void init(int level=1, const char* fpath = "./log",int maxqueue_size=1024,int threadnum=1);//不能用构造函数传参,使用一个init传参初始化

void setlevel(int level){loglevel = level;}//修改level的接口,只允许主线程修改,因此不用互斥
int getlevel(){return loglevel;}
bool isopen(){return logisopen;}//看是否打开日志的接口

void createthread(int threadnum);
static void logthread();//异步线程的回调函数,需要是staic,没有this隐藏参数
void write(int level, const char *format,...);//同步写,解耦
void close();
private:
void asyncwrite();//互斥写,不用lambda表达式,因为要用到log类的变量,并修改它们
void changefile(struct tm *nowtime);//write函数的解耦
struct tm* gettime();
private:
static const int maxlines = 52000;
FILE *logfp;
int linecounts;
int filenum;
const char* path;
int logday;
bool isasync;
bool logisopen;
int loglevel;
unique_ptr<blockqueue<string>> blockque;//不用lambda表达式可以用uniqueptr,因为一个指针一起用。用指针是因为要根据队列长度动态构造
mutex mux;
};

//我们想用一个函数封装write函数,比如logoinfo调用level1的write,并且还要能判断loglevel支不支持
//但函数封装变参函数,为了传递可变参数,实际上还要修改write的实现,不如用宏来实现,使用##__VA_ARGS__传递可变参数,让编译器把宏替换为真实的函数
//##__VA_ARGS__的优点是,对于宏调用,如果format是一个字符串也即后面没有可变参数,## 操作将使预处理器(preprocessor)去除掉它前面的那个逗号。
//宏与类无关了,这里必须isopen了才能使用
#define LOG_BASE(level, format, ...) \
do {\
Log* log = Log::instance();\
if (log->isopen() && log->getlevel() <= level) {\
log->write(level, format, ##__VA_ARGS__); \
}\
} while(0);

#define LOG_DEBUG(format, ...) do {LOG_BASE(0, format, ##__VA_ARGS__)} while(0);
#define LOG_INFO(format, ...) do {LOG_BASE(1, format, ##__VA_ARGS__)} while(0);
#define LOG_WARN(format, ...) do {LOG_BASE(2, format, ##__VA_ARGS__)} while(0);
#define LOG_ERROR(format, ...) do {LOG_BASE(3, format, ##__VA_ARGS__)} while(0);

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#include "log.h"
using namespace std;

Log* Log::instance()
{
static Log instance;//调用构造函数
return &instance;
}
struct tm* Log::gettime()
{
struct tm *nowtime;
time_t t;
t = time(NULL);
nowtime = localtime(&t);
return nowtime;
}

void Log::changefile(struct tm *nowtime)//完成行数增加、判断文件切换
{
//接下来涉及行数的改写,以及文件的切换,要互斥。因为实际上一个线程切换文件即可,如果有线程在切换其他线程不可动
//unique_lock<mutex> locker(mux);互斥交给上层,因为fputs也要互斥
linecounts++;//先++,因为行数是从0开始的,++后刚好判断是不是满了,这个操作要互斥
if(logday != nowtime->tm_mday || linecounts == maxlines)//如果换了一天或行数满了
{
char newname[48];//用snprintf,不能用string了
if(logday != nowtime->tm_mday)//如果是换了一天
{
logday = nowtime->tm_mday;//修改天
linecounts = 0;//换文件了
filenum = 0;//文件份数从0开始
//为了格式化命名,要用format,这里用snprintf写入str
snprintf(newname, 48, "%s/%d-%02d-%02d_log%05d.txt",//补充一个前缀-文件夹
path, nowtime->tm_year+1900, nowtime->tm_mon+1, nowtime->tm_mday, filenum);
}
else//行数满了
{
linecounts = 0;
filenum++;
snprintf(newname, 48, "%s/%d-%02d-%02d_log%05d.txt",
path, nowtime->tm_year+1900, nowtime->tm_mon+1, nowtime->tm_mday, filenum);
}

fflush(logfp);//在关闭文件前要把文件缓存区的内容写完
fclose(logfp);
logfp = fopen(newname,"w");
assert(logfp != nullptr);//创建失败报错
}
//locker.unlock();
}

void Log::write(int level, const char *format,...)
{
//初始化时间
struct tm *nowtime = gettime();

//-----------------根据传入的信息整理成一行日志-------------------------
char infobuffer[128];//一般一行日志没那么长,128足够了
char timebuffer[36];//时间头
string allinfo;
//分级
switch(level)
{
case 0:
allinfo += "[debug]";
break;
case 1:
allinfo += "[info]";
break;
case 2:
allinfo += "[warning]";
break;
case 3:
allinfo += "[error]";
break;
default:
allinfo += "[info]";
break;
}
//添加时间信息
snprintf(timebuffer,36, "%d-%02d-%02d_%02d:%02d:%02d:",
nowtime->tm_year+1900, nowtime->tm_mon+1, nowtime->tm_mday,
nowtime->tm_hour,nowtime->tm_min,nowtime->tm_sec);//只精确到秒,更具体的信息交给内容体现

allinfo += string(timebuffer);

//写内容
va_list vaList;
va_start(vaList,format);
vsnprintf(infobuffer,128,format,vaList);
va_end(vaList);

allinfo += string(infobuffer)+"\n";//注意换个行
//----------------------------------------------------------------------------------------------

//分异步还是同步,要不要切换文件交给异步线程判断
if(isasync && !blockque->full())
blockque->push(allinfo);//异步直接插入
else
{
//在写之前看要不要创建新文件
//如果当前日期变了,一般来说判断day就可以了;或是行数已满,就换一个文件

lock_guard<mutex> locker(mux);//互斥
changefile(nowtime);//直接交给该函数完成
fputs(allinfo.c_str(),logfp);//操作文件缓冲区,也要互斥
fflush(logfp);
}
}

Log::Log()//初始化一部分变量
{
//初始行数为-1,因为是先++然后判断再写入,初始为0的话,第一份文件会少一行,
//比如最大行为2,初始为0;则++,写入;++就换文件了,只写了一行,所以初始要是-1。换文件后置为0
//因为换文件后没++了,写了一行,就是正确的
linecounts = -1;
filenum = 0;
isasync = false;
blockque = nullptr;
logday = 0;
logfp = nullptr;
logisopen = false;//init才算打开
}
void Log::close()
{

logisopen = false;
if(isasync)//异步的话要让线程退出
{
while(!blockque->empty());//等待工作完成

blockque->close();
}
if(logfp)//如果打开了文件要关闭
{
//由于其他线程可能正在使用,因此要等待互斥锁
lock_guard<mutex> locker(mux);
fflush(logfp);//刷新缓冲区
fclose(logfp);
logfp = nullptr;
}
}
Log::~Log()
{
close();
}

void Log::logthread()//异步线程回调函数
{
Log::instance()->asyncwrite();//调用类成员函数
}
//等级越低允许记录的权限越低,fpath是文件夹,文件名内部设置
void Log::init(int level, const char* fpath,int maxqueue_size,int threadnum)
{
if(logisopen == true)
return;//只允许init一次
logisopen = true;
loglevel = level;
if(maxqueue_size>0)//有阻塞队列则异步
{
isasync = true;
//创建阻塞队列
unique_ptr<blockqueue<string>> que(new blockqueue<string>(maxqueue_size));
blockque = move(que);//移动赋值
createthread(threadnum);
}
else
isasync = false;

//初始化时间
struct tm *nowtime = gettime();
logday = nowtime->tm_mday;
path = fpath;

char filename[48];//用snprintf,不能用string了
snprintf(filename, 48, "%s/%d-%02d-%02d_log%05d.txt",//补充一个前缀-文件夹
path, nowtime->tm_year+1900, nowtime->tm_mon+1, nowtime->tm_mday, filenum);

//初步打开文件,没有文件夹就创建文件夹
if(opendir(path) == NULL)//如果文件夹不存在
mkdir(path,0777);//0777是最大的访问权

logfp = fopen(filename,"w");
assert(logfp!=nullptr);
}

void Log::asyncwrite()
{
string str;
while(blockque->pop(str))
{
struct tm* nowtime = gettime();
lock_guard<mutex> locker(mux);//互斥
changefile(nowtime);//每写一行判断要不要换文件
fputs(str.c_str(),logfp);
fflush(logfp);
}
}

void Log::createthread(int threadnum)
{
for(int i=0;i<threadnum;i++)
{
thread(logthread).detach();//因为内部函数采用单例调用,logthread不用传入this指针
}
}

聊天记录存储

有了日志系统,要不要也设置一个聊天记录系统呢?实际上聊天记录要比日志系统简单很多,因为日志系统要把多个线程的日志记录写到一个文件,而聊天记录的存储实际上只是单线程记录的。因为设置了oneshot,在一次响应的过程中不会被其他线程响应,因此一个user的聊天记录存储工作就在这个线程里。

其次,聊天记录是同步的还是异步的,这也需要考量一番。我习惯把日志系统设置为异步的,是因为日志系统可以看成“汇聚所有应写的日志”,放入阻塞队列里用工作线程慢慢写入日志即可,这里面还有一个要点就是写入的文件在一段时间永远是同一个。

而聊天记录不同,一个user的聊天记录应该保存在一个文件里(目前先只考虑一个文件,而不考虑文件行数过多切换文件的情况),这样多个user在操作时就有多个文件的打开关闭。这样子用一个阻塞队列把这些记录汇集起来从逻辑上来说是有点奇怪的:

  • 一方面工作线程在获取阻塞队列的信息时把文件切换来切换去
  • 一方面这个系统(如果真设计出来)保存的行数信息、天数信息都没什么用。
  • 以及,因为聊天记录的字节大小不像一般的log记录那么精简,如果用阻塞队列很可能会有一段延时,导致记录的时间不对

因此,聊天记录同步存储即可,就在task内完成记录,因为task本身就是一个线程,这也该是线程完成的事情。并且每次只需要userid即可知道写哪个文件,所以实际上写一个函数即可。

每记录一次都打开、关闭文件,而不是当用户时登录时打开文件并记录映射,等用户退出时关闭文件,因为经验不足还无法预知用户会干什么事情以及退出代码写的对不对,所以先这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<stdio.h>
#include<string>
#include "time.h"
bool msg_log(string userid, string& msglog)
{
string filename = userid+".txt";
FILE *fp = fopen(filename.c_str(),"a");//不用互斥,同时间只有一个线程操作
if(fp == nullptr)
return false;
//初始化时间
struct tm *nowtime;
time_t t;
t = time(NULL);
nowtime = localtime(&t);
char timebuffer[36];//时间头
//添加时间信息
snprintf(timebuffer,36, "%d-%02d-%02d_%02d:%02d:%02d:",
nowtime->tm_year+1900, nowtime->tm_mon+1, nowtime->tm_mday,
nowtime->tm_hour,nowtime->tm_min,nowtime->tm_sec);//只精确到秒,更具体的信息交给内容体现
string allmsg = string(timebuffer)+msglog;
fputs(allmsg.c_str(),fp);
fclose(fp);
return true;
}

退出函数

写一个退出函数,在服务器开启时用一个线程接收退出信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//定义一个退出密码
const string exitpwd = "jysama";
void deal_close()
{
char buf[64];
const string exitstr = "exit -p"+exitpwd+"\n";
const string quitstr = "quit -p"+exitpwd+"\n";
while (fgets(buf, sizeof(buf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
if(strcmp(buf,exitstr.c_str())==0)
break;
else if(strcmp(buf,quitstr.c_str())==0)
break;
else
cout << "error" << endl;

bzero(buf,sizeof(buf));
}
//close all
}

顶层

所有工作已经完成,现在进行封装。

比较复杂,不放代码了。。。

数据库建立

1
2
3
4
5
6
7
8
9
// 建立yourdb库
create database yourdb;

// 创建user表
USE yourdb;
CREATE TABLE user(
userid char(50) NULL,
password char(50) NULL
)ENGINE=InnoDB;

makefile

g++ -o:

-O: 同时减少代码的长度和执行时间,其效果等价于 -O1

-O0: 表示不做优化

-O1: 表示默认优化

-O2: 告诉 g++ 产生尽可能小和尽可能快的代码。除了完成-O1 的优化之外,还进行一些额外的调整工作,如指令调整等

-O3: 包括循环展开和其他一些与处理性相关的优化工作,选项将使编译的速度比 -O 慢,但通常产生的代码执行速度会更快。


g++ -Wall:-Wall 打印警告信息


1
2
3
4
5
目标...: 依赖...
命令1
命令2
...
注意每条命令前必须有且仅有一个 tab 保持缩进,这是语法要求。
1
2
3
4
5
6
7
8
9
CXX = g++
CFLAGS = -std=c++14 -O2 -Wall
TARGET = server
OBJS = global_variables.cpp log.cpp usermap.cpp sqlconnpool.cpp udp_hole_punch.cpp server.cpp main.cpp

server: $(OBJS)
$(CXX) $(CFLAGS) $(OBJS) -o $(TARGET) -lpthread -lmysqlclient
clean:
rm -f $(TARGET)

编译

激动人心的时刻到了,因为一直是在markdown手写的,没有编写边测试,所以也不知道有多少错。


没什么大的bug,接下来还有一些问题要调。

顶层设计

  • 映射表(补:实际上可以用结构体减少映射,不过不够灵活)
  • 线程池、任务
  • 日志系统
  • 聊天日志
  • 同步非阻塞IO

为方便描述,我们做如下约定:

  • 客户端主线程是命令行线程,另一个线程叫做接收线程
  • 服务器主线程是接收命令线程,也叫命令线程,它只进行消息中转,命令解析与返回由线程池处理。该线程监听的端口由客户端主线程连接。
  • 服务器另一个监听线程叫发送线程,监听的端口由客户端接收线程连接(这样服务器就能知道客户端两个连接分别属于哪个线程),信息的发送由线程池处理。

映射表

考虑一下用户在服务器里的映射信息:

  • 在连接的时候,服务器会获得套接字描述符connfd和客户端对应的ip地址和端口号;当客户端后续发信息过来时,epoll会得知事件,同时获取的是用户的套接字信息,因此用connfd会比较多。然后,用户有两个线程会与服务器的两个监听线程连接,唯一能得知这两个套接字是一个用户的方法就是使用ip。由此我们大概能设计如下映射:
    • 0)套接字1->ip地址:这个映射是通过客户端主线程连接到服务器命令线程;
    • 1)ip地址->套接字2:这个映射是客户端接收线程连接到服务器发送线程,因为连接时间不可知,因此把映射保存下来;
    • 2)套接字1->套接字2:这个映射通过上面的映射获取,套接字1找到ip然后找到套接字2,这样就建立好了服务器与客户端之间的映射,即能快速找到客户端两个套接字。
      • 本质上因为不知道套接字1先建立还是套接字2先建立,所以不能获得套接字1的映射就立马去找套接字2,最稳健的时机是用户登录时建立两个套接字之间的映射,因为用户能登录说明两个连接都建立完成,并且此时是阻塞返回的,消息发到主线程,不需要立即使用套接字2。
  • 下面建立其他相关的映射:
    • 3)套接字1->sid:最初的sid是用户登录时的userid,因此这个映射在登录时建立,setsid可以更改。
    • 4)套接字1->sname:最初的sname是用户登录时的userid,因此这个映射在登录时建立,setsname可以更改。这两个映射是为了一些请求命令需要让对方得知自己的sid和sname,能快速获取。
    • 5)sid->套接字2:许多命令都是通过指定sid发送到对方的接收线程,这个映射在登录时建立。
    • 6)sid->sname:通过搜索sid获取sname。
    • 7)sid->客户状态:accept之类的命令通过sid检索对方状态。
    • 8)套接字1->客户状态:本机的命令有时也需要与客户状态交互。
  • chatting双方映射,在聊天的双方经常通过自己的套接字1发送到对方的套接字2,因此要建立该表。accept时服务器可以获取自己的套接字1和对方sid,此时可以建立:
    • 9)[本机套接字1]->[对方套接字2],这个通过对方sid->套接字2获取。
    • [对方套接字1]->[本机套接字2],这个通过本机套接字1->本机套接字2获取,对方的套接字1通过套接字2搜索映射表获取套接字1,这一步耗点时间,因为对方得知accept也需要时间,不会那么快发消息,所以可以搜索建立。
    • 注意上面两个表本质是一样的,用一个表存储。
  • 补充:
    • 10)conn2->user:获取user
    • 11)conn1->user:获取user
    • 12)conn1_req_peer2:conn1发起chat或send请求,就在这个表记录对方的端口,这样conn1break时才能知道要向谁break

映射表也许还有很多,现在也无法想到底。由于映射表挺多的,可以使用一个类封装,像客户端的请求表一样。但是这个类包含了许多的表,设计就有多种方案了。在讨论这些方案之前,我们可以想到,这些表的操作肯定需要一些互斥处理。对于同一个表,插入查找删除肯定是需要互斥的,但是两个不同的表之间需要互斥吗?不需要,并且如果这被需要的话可能会严重损耗性能,因此一个表就对应一个互斥锁。现在我们来讨论表类的设计:

  • 第一种,小封装,直接暴露这些表,插入查找删除都由外部执行。评价是这个方案肯定是不行的,接口太难用了。
  • 第二种,完全封装,但每个表配对一个函数,外部调用相应的函数执行。评价是代码冗余代码重复,并且无法全部做到函数重载,接口质量太差了。
  • 第三种,完全封装,使用模板编写函数,插入查找删除统一使用。但问题是无法得知对哪个表操作,或许可以使用一个顶层映射表,表id->表。但实际上这行不通,因为表的参数类型是不同的,并且这也无法通过模板来解决。
    • 可能使用函数模板通过传入表id操作不同的表吗?已尝试,这行不通,可以看我的博客函数模板问题 | JySama

讨论到现在,实际上发现没什么值得封装的了。。。因此直接不封装好了,设计大概是这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
//映射表
unordered_map<int, string> conn1_ip;//id0,套接字1->ip,以下都用这样的命名方式
unordered_map<string, int> ip_conn2;//id1
unordered_map<int, int> conn1_2;//id2
unordered_map<int, string> conn1_sid;//id3
unordered_map<int, string> conn1_sname;//id4
unordered_map<string, int> sid_conn2;//id5
unordered_map<string, string> sid_sname;//id6
unordered_map<string, clientState> sid_state;//id7
unordered_map<int, clientState> conn1_state;//id8
unordered_map<int, int> conn1_peer2;//id9
unordered_map<int, string> conn2_user;//id10
unordered_map<int, string> conn1_user;//id11

//每个表的互斥锁
mutex mux_conn1_ip;
mutex mux_ip_conn2;
mutex mux_conn1_2;
mutex mux_conn1_sid;
mutex mux_conn1_sname;
mutex mux_sid_conn2;
mutex mux_sid_sname;
mutex mux_sid_state;
mutex mux_conn1_state;
mutex mux_conn1_peer2;
mutex mux_conn2_user;
mutex mux_conn1_user;

//插入,包括直接修改
lock_guard<mutex> locker(mux);
map[key] = value

//删除
lock_guard<mutex> locker(mux);
typename unordered_map<T1, T2>::iterator iter = map.find(key);
if (iter == map.end())//不存在则直接不管
return;
else
map.erase(iter);

//通过key找value
lock_guard<mutex> locker(mux);
typename unordered_map<T1, T2>::iterator iter = map.find(key);

if (iter == map.end())//这里返回是因为之前想写函数模板,直接调用就进行错误处理等
return T2();//string的空构造是空字符串,int的空构造是0,clientState的空构造是noLogin
else
return iter->second;

//通过value找key
lock_guard<mutex> locker(mux);
typename unordered_map<T1, T2>::iterator iter = map.begin();
for (; iter != map.end(); iter++)
if (iter->second == value)
return iter->first;

//if (iter == map.end()),否则一定找不到
////这里返回是因为之前想写函数模板,直接调用就进行错误处理等
return T1();//string的空构造是空字符串,int的空构造是0,clientState的空构造是noLogin

写了后面一些内容,发现映射表使用太频繁了,因为没有封装,导致代码极速膨胀(因为每个操作都要互斥,一行语句会因为调用互斥加锁解锁展开成三四行),所以还是打算封装,每个表都配一系列函数好了,代码很多…不过后面调用就舒服很多。注:后面有些补充的表不会填上来。

state_c.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef STATE_C_H
#define STATE_C_H

//客户端状态机
enum class clientState
{
noLogin = 0,//未登录状态
cmdLine,//命令行状态
isChatting,//chat状态
isWaiting//等待状态
};


#endif

usermap.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#ifndef USERMAP_H
#define USERMAP_H

#include <string>
#include <unordered_map>
#include <mutex>
#include "state_c.h"
using namespace std;

class UserMap
{
private:
//映射表
unordered_map<int, string> conn1_ip;//id0,套接字1->ip,以下都用这样的命名方式
unordered_map<string, int> ip_conn2;//id1
unordered_map<int, int> conn1_2;//id2
unordered_map<int, string> conn1_sid;//id3
unordered_map<int, string> conn1_sname;//id4
unordered_map<string, int> sid_conn2;//id5
unordered_map<string, string> sid_sname;//id6
unordered_map<string, clientState> sid_state;//id7
unordered_map<int, clientState> conn1_state;//id8
unordered_map<int, int> conn1_peer2;//id9
unordered_map<int, string> conn2_user;//id10
unordered_map<int, string> conn1_user;//id11

//每个表的互斥锁
mutex mux_conn1_ip;
mutex mux_ip_conn2;
mutex mux_conn1_2;
mutex mux_conn1_sid;
mutex mux_conn1_sname;
mutex mux_sid_conn2;
mutex mux_sid_sname;
mutex mux_sid_state;
mutex mux_conn1_state;
mutex mux_conn1_peer2;
mutex mux_conn2_user;
mutex mux_conn1_user;

public:
//-----------------------------------------------------0
void ins_conn1_ip(int key, string value);
void del_conn1_ip(int key);
string fvalue_conn1_ip(int key);
//不会通过ip找conn1

//-----------------------------------------------------1
void ins_ip_conn2(string key, int value);
void del_ip_conn2(string key);
int fvalue_ip_conn2(string key);
//不会通过conn2找ip

//-----------------------------------------------------2
void ins_conn1_2(int key, int value);
void del_conn1_2(int key);
int fvalue_conn1_2(int key);
int fkey_conn1_2(int value);//通过2找1

//-----------------------------------------------------3
void ins_conn1_sid(int key, string value);
void del_conn1_sid(int key);
string fvalue_conn1_sid(int key);
int fkey_conn1_sid(string value);

//-----------------------------------------------------4
void ins_conn1_sname(int key, string value);
void del_conn1_sname(int key);
string fvalue_conn1_sname(int key);

//-----------------------------------------------------5
void ins_sid_conn2(string key, int value);
void del_sid_conn2(string key);
int fvalue_sid_conn2(string key);
string fkey_sid_conn2(int value);

//-----------------------------------------------------6
void ins_sid_sname(string key, string value);
void del_sid_sname(string key);
string fvalue_sid_sname(string key);

//-----------------------------------------------------7
void ins_sid_state(string key, clientState value);
void del_sid_state(string key);
clientState fvalue_sid_state(string key);
//不可能通过state找key

//-----------------------------------------------------8
void ins_conn1_state(int key, clientState value);
void del_conn1_state(int key);
clientState fvalue_conn1_state(int key);
//不可能通过state找key

//-----------------------------------------------------9
void ins_conn1_peer2(int key, int value);
void del_conn1_peer2(int key);
int fvalue_conn1_peer2(int key);

//-----------------------------------------------------10
void ins_conn2_user(int key, string value);
void del_conn2_user(int key);
string fvalue_conn2_user(int key);

//-----------------------------------------------------11
void ins_conn1_user(int key, string value);
void del_conn1_user(int key);
string fvalue_conn1_user(int key);
};
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
#include "usermap.h"
//---------------------------------------0
void UserMap::ins_conn1_ip(int key, string value)
{
lock_guard<mutex> locker(mux_conn1_ip);
conn1_ip[key] = value;//插入或修改
}
void UserMap::del_conn1_ip(int key)
{
lock_guard<mutex> locker(mux_conn1_ip);
typename unordered_map<int, string>::iterator iter = conn1_ip.find(key);
if (iter == conn1_ip.end())//不存在则直接不管
return;
else
conn1_ip.erase(iter);
}
string UserMap::fvalue_conn1_ip(int key)
{
lock_guard<mutex> locker(mux_conn1_ip);
typename unordered_map<int, string>::iterator iter = conn1_ip.find(key);

if (iter == conn1_ip.end())
return "";
else
return iter->second;
}
//---------------------------------------1
void UserMap::ins_ip_conn2(string key, int value)
{
lock_guard<mutex> locker(mux_ip_conn2);
ip_conn2[key] = value;//插入或修改
}
void UserMap::del_ip_conn2(string key)
{
lock_guard<mutex> locker(mux_ip_conn2);
typename unordered_map<string, int>::iterator iter = ip_conn2.find(key);
if (iter == ip_conn2.end())//不存在则直接不管
return;
else
ip_conn2.erase(iter);
}
int UserMap::fvalue_ip_conn2(string key)
{
lock_guard<mutex> locker(mux_ip_conn2);
typename unordered_map<string, int>::iterator iter = ip_conn2.find(key);

if (iter == ip_conn2.end())
return -1;
else
return iter->second;
}
//-----------------------------------------------------2
void UserMap::ins_conn1_2(int key, int value)
{
lock_guard<mutex> locker(mux_conn1_2);
conn1_2[key] = value;//插入或修改
}
void UserMap::del_conn1_2(int key)
{
lock_guard<mutex> locker(mux_conn1_2);
typename unordered_map<int, int>::iterator iter = conn1_2.find(key);
if (iter == conn1_2.end())//不存在则直接不管
return;
else
conn1_2.erase(iter);
}
int UserMap::fvalue_conn1_2(int key)
{
lock_guard<mutex> locker(mux_conn1_2);
typename unordered_map<int, int>::iterator iter = conn1_2.find(key);

if (iter == conn1_2.end())
return -1;
else
return iter->second;
}
int UserMap::fkey_conn1_2(int value)//通过2找1
{
lock_guard<mutex> locker(mux_conn1_2);
typename unordered_map<int, int>::iterator iter = conn1_2.begin();
for (; iter != conn1_2.end(); iter++)
if (iter->second == value)
return iter->first;

return -1;
}

//-----------------------------------------------------3
void UserMap::ins_conn1_sid(int key, string value)
{
lock_guard<mutex> locker(mux_conn1_sid);
conn1_sid[key] = value;//插入或修改
}
void UserMap::del_conn1_sid(int key)
{
lock_guard<mutex> locker(mux_conn1_sid);
typename unordered_map<int, string>::iterator iter = conn1_sid.find(key);
if (iter == conn1_sid.end())//不存在则直接不管
return;
else
conn1_sid.erase(iter);
}
string UserMap::fvalue_conn1_sid(int key)
{
lock_guard<mutex> locker(mux_conn1_sid);
typename unordered_map<int, string>::iterator iter = conn1_sid.find(key);

if (iter == conn1_sid.end())
return "";
else
return iter->second;
}
int UserMap::fkey_conn1_sid(string value)
{
lock_guard<mutex> locker(mux_conn1_sid);
typename unordered_map<int, string>::iterator iter = conn1_sid.begin();
for (; iter != conn1_sid.end(); iter++)
if (iter->second == value)
return iter->first;

return -1;
}

//-----------------------------------------------------4
void UserMap::ins_conn1_sname(int key, string value)
{
lock_guard<mutex> locker(mux_conn1_sname);
conn1_sname[key] = value;//插入或修改
}
void UserMap::del_conn1_sname(int key)
{
lock_guard<mutex> locker(mux_conn1_sname);
typename unordered_map<int, string>::iterator iter = conn1_sname.find(key);
if (iter == conn1_sname.end())//不存在则直接不管
return;
else
conn1_sname.erase(iter);
}
string UserMap::fvalue_conn1_sname(int key)
{
lock_guard<mutex> locker(mux_conn1_sname);
typename unordered_map<int, string>::iterator iter = conn1_sname.find(key);

if (iter == conn1_sname.end())
return "";
else
return iter->second;
}

//-----------------------------------------------------5
void UserMap::ins_sid_conn2(string key, int value)
{
lock_guard<mutex> locker(mux_sid_conn2);
sid_conn2[key] = value;//插入或修改
}
void UserMap::del_sid_conn2(string key)
{
lock_guard<mutex> locker(mux_sid_conn2);
typename unordered_map<string, int>::iterator iter = sid_conn2.find(key);
if (iter == sid_conn2.end())//不存在则直接不管
return;
else
sid_conn2.erase(iter);
}
int UserMap::fvalue_sid_conn2(string key)
{
lock_guard<mutex> locker(mux_sid_conn2);
typename unordered_map<string, int>::iterator iter = sid_conn2.find(key);

if (iter == sid_conn2.end())
return -1;
else
return iter->second;
}
string UserMap::fkey_sid_conn2(int value)
{
lock_guard<mutex> locker(mux_sid_conn2);
typename unordered_map<string, int>::iterator iter = sid_conn2.begin();
for (; iter != sid_conn2.end(); iter++)
if (iter->second == value)
return iter->first;

return "";
}

//-----------------------------------------------------6
void UserMap::ins_sid_sname(string key, string value)
{
lock_guard<mutex> locker(mux_sid_sname);
sid_sname[key] = value;//插入或修改
}
void UserMap::del_sid_sname(string key)
{
lock_guard<mutex> locker(mux_sid_sname);
typename unordered_map<string, string>::iterator iter = sid_sname.find(key);
if (iter == sid_sname.end())//不存在则直接不管
return;
else
sid_sname.erase(iter);
}
string UserMap::fvalue_sid_sname(string key)
{
lock_guard<mutex> locker(mux_sid_sname);
typename unordered_map<string, string>::iterator iter = sid_sname.find(key);

if (iter == sid_sname.end())
return "";
else
return iter->second;
}

//-----------------------------------------------------7
void UserMap::ins_sid_state(string key, clientState value)
{
lock_guard<mutex> locker(mux_sid_state);
sid_state[key] = value;//插入或修改
}
void UserMap::del_sid_state(string key)
{
lock_guard<mutex> locker(mux_sid_state);
typename unordered_map<string, clientState>::iterator iter = sid_state.find(key);
if (iter == sid_state.end())//不存在则直接不管
return;
else
sid_state.erase(iter);
}
clientState UserMap::fvalue_sid_state(string key)
{
lock_guard<mutex> locker(mux_sid_state);
typename unordered_map<string, clientState>::iterator iter = sid_state.find(key);

if (iter == sid_state.end())
return clientState::noLogin;
else
return iter->second;
}
//不可能通过state找key

//-----------------------------------------------------8
void UserMap::ins_conn1_state(int key, clientState value)
{
lock_guard<mutex> locker(mux_conn1_state);
conn1_state[key] = value;//插入或修改
}
void UserMap::del_conn1_state(int key)
{
lock_guard<mutex> locker(mux_conn1_state);
typename unordered_map<int, clientState>::iterator iter = conn1_state.find(key);
if (iter == conn1_state.end())//不存在则直接不管
return;
else
conn1_state.erase(iter);
}
clientState UserMap::fvalue_conn1_state(int key)
{
lock_guard<mutex> locker(mux_conn1_state);
typename unordered_map<int, clientState>::iterator iter = conn1_state.find(key);

if (iter == conn1_state.end())
return clientState::noLogin;
else
return iter->second;
}
//不可能通过state找key

//-----------------------------------------------------9
void UserMap::ins_conn1_peer2(int key, int value)
{
lock_guard<mutex> locker(mux_conn1_peer2);
conn1_peer2[key] = value;//插入或修改
}
void UserMap::del_conn1_peer2(int key)
{
lock_guard<mutex> locker(mux_conn1_peer2);
typename unordered_map<int, int>::iterator iter = conn1_peer2.find(key);
if (iter == conn1_peer2.end())//不存在则直接不管
return;
else
conn1_peer2.erase(iter);
}
int UserMap::fvalue_conn1_peer2(int key)
{
lock_guard<mutex> locker(mux_conn1_peer2);
typename unordered_map<int, int>::iterator iter = conn1_peer2.find(key);

if (iter == conn1_peer2.end())
return -1;
else
return iter->second;
}

//-----------------------------------------------------10
void UserMap::ins_conn2_user(int key, string value)
{
lock_guard<mutex> locker(mux_conn2_user);
conn2_user[key] = value;//插入或修改
}
void UserMap::del_conn2_user(int key)
{
lock_guard<mutex> locker(mux_conn2_user);
typename unordered_map<int, string>::iterator iter = conn2_user.find(key);
if (iter == conn2_user.end())//不存在则直接不管
return;
else
conn2_user.erase(iter);
}
string UserMap::fvalue_conn2_user(int key)
{
lock_guard<mutex> locker(mux_conn2_user);
typename unordered_map<int, string>::iterator iter = conn2_user.find(key);

if (iter == conn2_user.end())
return "";
else
return iter->second;
}

//-----------------------------------------------------11
void UserMap::ins_conn1_user(int key, string value)
{
lock_guard<mutex> locker(mux_conn1_user);
conn1_user[key] = value;//插入或修改
}
void UserMap::del_conn1_user(int key)
{
lock_guard<mutex> locker(mux_conn1_user);
typename unordered_map<int, string>::iterator iter = conn1_user.find(key);
if (iter == conn1_user.end())//不存在则直接不管
return;
else
conn1_user.erase(iter);
}
string UserMap::fvalue_conn1_user(int key)
{
lock_guard<mutex> locker(mux_conn1_user);
typename unordered_map<int, string>::iterator iter = conn1_user.find(key);

if (iter == conn1_user.end())
return "";
else
return iter->second;
}

调用测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include "usermap.h"
using namespace std;

int main()
{
UserMap usermap;
usermap.ins_conn1_2(1, 2);
int conn2 = usermap.fvalue_conn1_2(1);
cout << conn2 << endl;

string ip = usermap.fvalue_conn1_ip(1);
if (ip == "")
cout << "none" << endl;

usermap.ins_conn1_ip(1, "123");
ip = usermap.fvalue_conn1_ip(1);
cout << ip << endl;

clientState state = usermap.fvalue_conn1_state(1);
if (state == clientState::noLogin)
cout << "none" << endl;
usermap.ins_conn1_state(1,clientState::isChatting);
state = usermap.fvalue_conn1_state(1);
if (state == clientState::noLogin)
cout << "none" << endl;
return 0;
}

注册登录

这里假设搞定了日志系统和数据库连接池先,数据库连接池代码放在这后面,然后把注册登录的处理函数写了(大概在markdown写一下,有bug后面再改):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
void verify(int conn1, int isLogin, string userid, string password)
{
MYSQL* sql;
SqlRAII myconn(&sql,sqlpool,0);//获取一个sql连接句柄,0表示超时时间
string sendstr = "Server busy!";//默认

if(sql==nullptr)//如果没拿到,即超时了
{
send(conn1,sendstr.c_str(),int(sendstr.size()),0);
return;
}
else
{
MYSQL_FIELD *fields = nullptr;
MYSQL_RES *res = nullptr;
string order = "SELECT userid, password FROM user WHERE userid='"+userid+"'";//命令不用加分号

//要么没有,要么只能查出一个
if(mysql_query(sql, order.c_str())) //执行语句,成功返回0,错误返回非0
{
LOG_DEBUG( "Select error: %s",order.c_str());
mysql_free_result(res);//错误的话释放结果集(无论成功与否,查询后总是释放)
send(conn1,sendstr.c_str(),int(sendstr.size()),0);//发送server busy
return;
}
res = mysql_store_result(sql);//完整的结果集

while(MYSQL_ROW row = mysql_fetch_row(res)) //遍历行,没有对应的表项就进不来,进来就只有一行
{
string passwd(row[1]);
// 能select到说明又对应的username,看是登录还是注册
if(isLogin)//登录
{
if(password == passwd) //登录成功
{
sendstr = "success";
send(conn1,sendstr.c_str(),int(sendstr.size()),0);

string ip = usermap.fvalue_conn1_ip(conn1);
LOG_INFO("%s login successfully with password: %s ip: %s",userid.c_str(),password.c_str(),ip.c_str());
mysql_free_result(res);//获取行完毕,释放结果集使用的内存

//添加映射
login_addmap(conn1,userid);

return;
}
else //登录失败,这种情况是密码错误
{
sendstr = "password error!";
send(conn1,sendstr.c_str(),int(sendstr.size()),0);

string ip = usermap.fvalue_conn1_ip(conn1);
LOG_INFO("%s login unsuccessfully with password: %s ip: %s",userid.c_str(),password.c_str(),ip.c_str());
mysql_free_result(res);//获取行完毕,释放结果集使用的内存
return;
}
}

else//注册,说明userid被使用了
{
sendstr = "the user id ["+userid+"] is already in use";
send(conn1,sendstr.c_str(),int(sendstr.size()),0);

LOG_INFO("register unsuccessfully, [%s] is already in use",userid.c_str());
mysql_free_result(res);//获取行完毕,释放结果集使用的内存
return;
}
}

mysql_free_result(res);//释放结果集使用的内存

//现在处理没有查找到的情况
if(isLogin)//登录
{
sendstr = "user id ["+userid+"] not found";
send(conn1,sendstr.c_str(),int(sendstr.size()),0);

LOG_INFO("%s login but id not found",userid.c_str());
return;
}
else//注册,这种情况可以注册
{
string order_insert = "INSERT INTO user(userid, password) VALUES('"+userid+"','"+password+"')";
if(mysql_query(sql, order_insert.c_str()))
{
LOG_DEBUG( "Insert error: %s",order_insert.c_str());
send(conn1,sendstr.c_str(),int(sendstr.size()),0);//发送server busy
return;
}
else
{
sendstr = "success";
send(conn1,sendstr.c_str(),int(sendstr.size()),0);

string ip = usermap.fvalue_conn1_ip(conn1);
LOG_INFO("register successfully with id: [%s] password: [%s] ip:%s",userid.c_str(),password.c_str(),ip.c_str());
return;
}
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void login_addmap(int conn1,string userid)
{
string ip = usermap.fvalue_conn1_ip(conn1);
int conn2 = usermap.fvalue_ip_conn2(ip);

usermap.ins_conn1_2(conn1,conn2);
usermap.ins_conn1_sid(conn1,userid);
usermap.ins_conn1_sname(conn1,userid);

usermap.ins_sid_conn2(userid,conn2);
usermap.ins_sid_sname(userid,userid);
usermap.ins_sid_state(userid,clientState::cmdLine);

usermap.ins_conn1_state(conn1,clientState::cmdLine);
usermap.ins_conn2_user(conn2,userid);
usermap.ins_conn1_user(conn1,userid);
}

数据库连接池

在之前的博客已经介绍过了,这里再介绍以下吧:

头文件#include <mysql/mysql.h>

数据库的一个连接句柄的初始化有三步

  • 定义一个sql指针:MYSQL *sql = nullptr;
  • 用这个指针初始化一个sql结构体,返回一个指向这个结构体的指针:sql = mysql_init(sql);
  • init后就connect,连接数据库,返回一个可用连接sql = mysql_real_connect(sql, host,user, pwd,dbName, port, unix_socket, client_flag);
    • host是主机名或IP,如果“host”是NULL或字符串”localhost”,连接将被视为与本地主机的连接。
    • 如果unix_socket不是NULL,该字符串描述了应使用的套接字或命名管道。注意,“host”参数决定了连接的类型。
    • client_flag的值通常为0,其他标志可以实现特定的功能

然后这个sql句柄就可以用来执行语句了,最后在不使用的时候还需要调用mysql_close(sql);释放连接。并且,为了避免在使用库完成应用程序后发生内存泄漏(例如,在关闭与服务器的连接之后),可以显式调用mysql_library_end()。这样可以执行内存 Management 以清理和释放库使用的资源。

上面就是一个连接的建立,对于多个连接,我们可以把多个连接初始化后放入一个队列里,这样就构成了一个连接池。对于这样一个共享的连接池,就需要互斥操作。而这一个队列又和阻塞队列不同,队列是可能空的但不可能会因为满而阻塞——可用的连接是一定的,push回去不会多。在阻塞队列中使用了两个条件变量管理了空/满缓冲区的阻塞,这里只需要管理空,也即pop操作的阻塞。可以用一个条件变量,也可以用一个信号量。条件变量可以做超时处理,因此用条件变量(信号量就使用sem_post(&semId_)、sem_wait(&semId_)、sem_init(&semId_, 0, MAX_CONN_))。

当上层需要登录或注册时,会尝试获取一个连接,然后使用完后释放连接。这里的问题是,当没有连接可用时,是阻塞等待还是直接返回错误。或许使用折中会好一点,即用cond.wait_for()阻塞一段时间。当释放一个连接后就尝试唤醒一个阻塞的线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#ifndef SQLCONNPOOL_H
#define SQLCONNPOOL_H

#include <mysql/mysql.h>
#include <mutex>
#include <queue>
#include <condition_variable>
using namespace std;
class Sqlconnpool
{
private:
mutex mux;
condition_variable cond;
queue<MYSQL*> connque;
int maxconn;
int freecount;
Sqlconnpool();
~Sqlconnpool(){}//析构函数实际上和构造函数一样,可以private,因为本质上是成员函数调用
public:
Sqlconnpool(const Sqlconnpool&) = delete;
Sqlconnpool& operator=(const Sqlconnpool&) = delete;

void close();
static Sqlconnpool* instance();
MYSQL* getconn(int timeout);
void freeconn(MYSQL* conn);
//无法使用构造函数传参,用init,默认参数写声明中
void init(const char* host,int port,const char* user,const char* pwd,const char* dbname,int connsize=20);
int conncount();

};

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include "sqlconnpool.h"
#include <chrono>
#include <cassert>
#include "log.h"
using namespace std;
Sqlconnpool::Sqlconnpool()
{
maxconn = 0;
freecount = 0;
}

Sqlconnpool* Sqlconnpool::instance()
{
static Sqlconnpool instance;
return &instance;
}

MYSQL* Sqlconnpool::getconn(int timeout = 0)
{
assert(timeout>=0);//
unique_lock<mutex> locker(mux);
while(connque.empty())
if(cond.wait_for(locker, chrono::seconds(timeout)) == std::cv_status::timeout)//超时
{
LOG_WARN("Sqlconnpool busy")
return nullptr;
}

MYSQL* sql = connque.front();
connque.pop();
freecount--;
return sql;
}

void Sqlconnpool::freeconn(MYSQL* conn)
{
assert(conn);//防止放入nullptr
lock_guard<mutex> locker(mux);
connque.push(conn);
freecount++;
cond.notify_one();//唤醒一个get线程
}
int Sqlconnpool::conncount()
{
lock_guard<mutex> locker(mux);
return maxconn-freecount;
}

void Sqlconnpool::init(const char* host,int port,const char* user,const char* pwd,const char* dbname,int connsize)
{
assert(connsize>0);
maxconn = connsize;
freecount = connsize;
for(int i=0;i<maxconn;i++)
{
//三步初始化
MYSQL* sql = nullptr;
sql = mysql_init(sql);
if(!sql)
{
LOG_ERROR("sql number %d init error",i);
assert(sql);//终止报错
}
sql = mysql_real_connect(sql,host,user,pwd,dbname,port,nullptr,0);
if(!sql)
{
LOG_ERROR("sql number %d connect error",i);
assert(sql);//终止报错
}
connque.push(sql);//放入的一定不是nullptr
}
}

void Sqlconnpool::close()
{
unique_lock<mutex> locker(mux);
while(freecount != maxconn)//必须要等待所有连接都放回来,直接close再执行查询程序会崩溃
cond.wait(locker);//每放回一个连接唤醒一次,然后判断

while(!connque.empty())//逐个关闭连接
{
mysql_close(connque.front());
connque.pop();
}
mysql_library_end();//释放库的资源
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef SQLRAII_H
#define SQLRAII_H

#include "sqlconnpool.h"
#include <cassert>
using namespace std;
class SqlRAII
{
private:
MYSQL* conn;//保存连接好的sql
Sqlconnpool* connpool;//保存连接池
public:
SqlRAII(MYSQL** sql,Sqlconnpool* sqlpool,int timeout = 0)//传入sql指针的地址,即&sql,获取连接后传出去
{
assert(sqlpool);//必须先建好连接池

*sql = sqlpool->getconn(timeout);//可能会超时
//为了用户自行getconn和使用sqlraii的统一,这里统一让用户在上层处理sql为nullptr的情况
conn = *sql;
connpool = sqlpool;
}
~SqlRAII()
{
if(conn)//有连接就释放
connpool->freeconn(conn);
}
};

#endif

信息接收处理

  • 对于聊天信息,只需要判断第一个字符是不是”~”即可,是的话通过映射表发送给对方
  • 对于命令信息,客户端进行了很多处理了,发送过来的命令一定是正确的且不带@,且首尾没有空格,服务器需要做的就是再解析成向量形式
  • 从客户端发来的信息都带有一个换行符,这是为了服务器刚好能使用客户端的解析函数,我们接着约定,服务器发给客户端的信息都不带有换行符。这样比如发来的聊天信息就要记得去尾部换行符。

命令的映射如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const unordered_map<string, int> cmdmap =
{
{"register",0},
{"login",1},
{"search",2},
{"chat",3},
{"accept",4},
{"reject",5},
{"break",6},
{"send",7},
{"sendfile",8},
{"acceptfile",9},
{"rejectfile",10},
{"getfile",11},
{"acceptget",12},
{"rejectget",13},
{"setsid",14},
{"setsname",15},
{"choosefile",16},//服务器没有re命令,但是多了一个choosefile,通告选择的文件号码
{"exit",17},
{"hisir",18},
{"myresource",19},//这个也不用
{"oyasumi",20}
};

工作函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
void task(int conn1)//epoll响应conn1,指定工作函数从conn1处接收
{
//接收,ET模式下要一次接收完,该函数在下篇博客epoll中作出。
string recvstr = recv_str(conn1);
if(recvstr == "")//接收出错了
return;//不给用户发错误信息,可能对方断开了,发过去有危险
if(recvstr[0]=='~')//聊天内容
{
chatmsg(conn1,recvstr);
}
else//命令
{
vector<string> cmdvec = parse(recvstr);
int cmdvalue = cmdmap[cmdvec[0]];//客户端处理后命令一定存在
switch(cmdvalue)
{
case 0:
verify(conn1, 0, cmdvec[1], cmdvec[2]);
break;
case 1:
verify(conn1, 1, cmdvec[1], cmdvec[2]);
break;
case 2:
search(conn1, cmdvec[1]);
break;
case 3:
chat(conn1, cmdvec[1]);
break;
case 4:
accept(conn1, cmdvec[1]);
break;
case 5:
reject(conn1, cmdvec[1]);
break;
case 6:
break_(conn1);
break;
case 7:
send_(conn1, cmdvec[1], cmdvec[2]);
break;
case 8:
sendfile(conn1, cmdvec[1], cmdvec[2]);
break;
case 9:
acceptfile(conn1, cmdvec[1]);
break;
case 10:
rejectfile(conn1, cmdvec[1]);
break;
case 11:
getfile(conn1, cmdvec[1]);
break;
case 12:
acceptget(conn1, cmdvec[1], cmdvec[2]);
break;
case 13:
rejectget(conn1, cmdvec[1]);
break;
case 14:
setsid(conn1, cmdvec[1]);
break;
case 15:
setsname(conn1, cmdvec[1]);
break;
case 16:
choosefile(conn1, cmdvec[1]);
break;
case 17:
exit(conn1);
break;
case 18:
hisir(conn1, cmdvec[1]);
break;
case 20:
oyasumi(conn1);
break;
}
}
//这两句见下一篇博客
uint32_t connEvent = CONNEVENT;//因为是oneshot,每次重新注册
epoller.modFd(conn1, connEvent);
}

解析命令函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector<string> parse(string cmdstr)
{
//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}
return res;//返回值是右值,外部vector会接收右值,调用移动构造

}

命令工作函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void chatmsg(int conn1, string& recvstr)
{
int conn2 = usermap.fvalue_conn1_peer2(conn1);//获取对方接收套接字
string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
if(conn2 == -1)
{
LOG_ERROR("%s chatmsg error, can't find peer!", myuser.c_str());
return;
}
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid

string mysname = usermap.fvalue_conn1_sname(conn1);//获取发送方名字

LOG_INFO("%s send [msg] to %s",myuser.c_str(),peeruser.c_str());
//还要记录myuser的聊天日志
recvstr = recvstr.substr(1,recvstr.size()-2);//去~和去\n
recvstr = mysname+": "+recvstr;
send(conn2,recvstr.c_str(),recvstr.size(),0);

//记录日志,在下一篇博客中已完成该函数
//前面的recvstr已经记录了发送方名字和发送内容,然后文件名包含了发送者id,因此再补上接收者id即可
recvstr = recvstr + " ["+peeruser+"]";
if(!msg_log(myuser,recvstr))
LOG_ERROR("%s open log falied!", myuser.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void search(int conn1, string sid)
{
string sendstr;
string sname = usermap.fvalue_sid_sname(sid);
string myuser = usermap.fvalue_conn1_user(conn1);//获取userid
LOG_INFO("%s [search] sid-%s",myuser.c_str(),sid.c_str());
if(sname == "")
{
sendstr = "The peer does not exist!";
send(conn1,sendstr.c_str(),sendstr.size(),0);
return;
}
clientState state = usermap.fvalue_sid_state(sid);
if(state == clientState::isChatting)
sendstr = "sid-["+sid+"]sname-["+sname+"]state-is chatting";
else
sendstr = "sid-["+sid+"]sname-["+sname+"]state-isn't chatting";
send(conn1,sendstr.c_str(),sendstr.size(),0);


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void chat(int conn1, string sid)
{
string sendstr;
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字
int myconn2 = usermap.fvalue_conn1_2(conn1);//获取自己的接收套接字
//获取自己信息
string mysid = usermap.fvalue_conn1_sid(conn1);
string mysname = usermap.fvalue_conn1_sname(conn1);
usermap.ins_conn1_state(conn1,clientState::isWaiting);//修改自己状态为waiting
usermap.ins_sid_state(mysid,clientState::isWaiting);//改两次

if(conn2 == -1)
{
sendstr = "The peer does not exist, please break!";
send(myconn2,sendstr.c_str(),sendstr.size(),0);
return;
}
clientState state = usermap.fvalue_sid_state(sid);//获取对方状态
if(state == clientState::isChatting)
{
sendstr = "The peer is chatting, you may wait for a long time...";
send(myconn2,sendstr.c_str(),sendstr.size(),0);
}

sendstr = "@#chatfrom "+mysid+" "+mysname;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方
usermap.ins_conn1_req_peer2(conn1,conn2);//添加一个请求映射

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s send a [chat] requestion to %s",myuser.c_str(),peeruser.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void accept_(int conn1, string sid)
{
string sendstr;
clientState state = usermap.fvalue_sid_state(sid);//获取对方状态
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字
int myconn2 = usermap.fvalue_conn1_2(conn1);
if(state == clientState::isWaiting)
{
sendstr = "@#chat accept";
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

string mysid = usermap.fvalue_conn1_sid(conn1);//获取自己的sid,为了修改状态
usermap.ins_conn1_state(conn1,clientState::isChatting);//修改自己状态为chatting
usermap.ins_sid_state(mysid,clientState::isChatting);//改两次

int peerconn1 = usermap.fkey_conn1_2(conn2);//获取对方套接字1,为了修改状态
usermap.ins_conn1_state(peerconn1,clientState::isChatting);//修改对方状态为chatting
usermap.ins_sid_state(sid,clientState::isChatting);//改两次

usermap.ins_conn1_peer2(conn1,conn2);
usermap.ins_conn1_peer2(peerconn1,myconn2);

//accept不删,为了在break到达前accept后还能找到对方,reject可以删,因为不用找对方
//usermap.del_conn1_req_peer2(peerconn1);//把服务器保存的对方的请求删掉

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [accept] the chat requestion for %s",myuser.c_str(),peeruser.c_str());
}
else//对方已经退出了,或在accept到服务器前break了,或其他情况,让accept方退出
{
sendstr = "@#break now";
send(myconn2,sendstr.c_str(),sendstr.size(),0);//发给自己
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void reject(int conn1, string sid)
{
string sendstr;
clientState state = usermap.fvalue_sid_state(sid);//获取对方状态
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字

if(state == clientState::isWaiting)
{
sendstr = "@#chat reject";
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

int peerconn1 = usermap.fkey_conn1_2(conn2);//获取对方套接字1,为了修改状态
usermap.ins_conn1_state(peerconn1,clientState::cmdLine);//修改对方状态为cmdLine
usermap.ins_sid_state(sid,clientState::cmdLine);//改两次

usermap.del_conn1_req_peer2(peerconn1);//把服务器保存的对方的请求删掉

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [reject] the chat requestion for %s",myuser.c_str(),peeruser.c_str());
}
//其他情况下对于reject不用管


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void break_(int conn1)//加下划线为了避免和break关键字重合,这里不close,在epoll里close
{
string sendstr;
clientState state = usermap.fvalue_conn1_state(conn1);//获取自己状态
string mysid = usermap.fvalue_conn1_sid(conn1);//获取自己的sid,为了修改状态

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
int conn2 = usermap.fvalue_conn1_req_peer2(conn1);
if(state == clientState::isWaiting)
{
usermap.ins_conn1_state(conn1,clientState::cmdLine);//修改自己状态为cmdLine
usermap.ins_sid_state(mysid,clientState::cmdLine);//改两次

if(conn2 == -1)//没有请求就不用管,否则要删除对方的请求表
return;
usermap.del_conn1_req_peer2(conn1);//删请求
sendstr = "@#break "+mysid;
send(conn2,sendstr.c_str(),sendstr.size(),0);
LOG_INFO("%s [break] from waiting",myuser.c_str());
}
else if(state == clientState::cmdLine)//在break到达前acceptfile了,让对方退出
{
if(conn2 == -1)//没有请求就不用管,reject会把请求删掉,这与accept区分开来
return;
usermap.del_conn1_req_peer2(conn1);//删请求
sendstr = "@#break now";
send(conn2,sendstr.c_str(),sendstr.size(),0);
LOG_INFO("%s [break] from cmdLine",myuser.c_str());
}
else if(state == clientState::isChatting)//正在通信,或在break到达前accept了
{
conn2 = usermap.fvalue_conn1_peer2(conn1);//找到正在通信的对方

string mysid = usermap.fvalue_conn1_sid(conn1);//获取自己的sid,为了修改状态
usermap.ins_conn1_state(conn1,clientState::cmdLine);//修改自己状态为cmdLine
usermap.ins_sid_state(mysid,clientState::cmdLine);//改两次
LOG_INFO("%s [break] from chatting",myuser.c_str());

if(conn2 == -1)//一般不会,可能有没考虑到的情况吧
return;
//让对方也退出
int peerconn1 = usermap.fkey_conn1_2(conn2);//获取对方套接字1,为了修改状态
string sid = usermap.fvalue_conn1_sid(peerconn1);//获取对方sid,修改状态
usermap.ins_conn1_state(peerconn1,clientState::cmdLine);//修改对方状态为cmdLine
usermap.ins_sid_state(sid,clientState::cmdLine);//改两次

//删除通信的映射
usermap.del_conn1_peer2(conn1);
usermap.del_conn1_peer2(peerconn1);

sendstr = "@#break now";
send(conn2,sendstr.c_str(),sendstr.size(),0);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void send_(int conn1, string sid, string& msg)
{
string sendstr;
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字
if(conn2 == -1)
return;
//获取自己信息
string mysid = usermap.fvalue_conn1_sid(conn1);
string mysname = usermap.fvalue_conn1_sname(conn1);

sendstr = "sid-["+mysid+"] sname-["+mysname+"] send: "+msg;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [send] message to %s: %s",myuser.c_str(),peeruser.c_str(),msg.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void sendfile(int conn1, string sid, string filename)
{
size_t pos = filename.find_last_not_of("/");//发过来可能是完整路径,要把路径删掉
if(pos != string::npos)
filename = filename.substr(pos+1);
string sendstr;
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字
int myconn2 = usermap.fvalue_conn1_2(conn1);//获取自己的接收套接字
//获取自己信息
string mysid = usermap.fvalue_conn1_sid(conn1);
string mysname = usermap.fvalue_conn1_sname(conn1);
usermap.ins_conn1_state(conn1,clientState::isWaiting);//修改自己状态为waiting
usermap.ins_sid_state(mysid,clientState::isWaiting);//改两次

if(conn2 == -1)
{
sendstr = "The peer does not exist, please break!";
send(myconn2,sendstr.c_str(),sendstr.size(),0);
return;
}
sendstr = "@#sendfilefrom "+mysid+" "+mysname+" "+filename;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方
usermap.ins_conn1_req_peer2(conn1,conn2);//添加一个请求映射

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s send a [sendfile] requestion to %s",myuser.c_str(),peeruser.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void acceptfile(int conn1, string sid)
{
string sendstr;
clientState state = usermap.fvalue_sid_state(sid);//获取对方状态
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字
int myconn2 = usermap.fvalue_conn1_2(conn1);
if(state == clientState::isWaiting)
{
string myip = usermap.fvalue_conn1_ip(conn1);
sendstr = "@#sendfile accept "+myip;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方


int peerconn1 = usermap.fkey_conn1_2(conn2);//获取对方套接字1,为了修改状态
usermap.ins_conn1_state(peerconn1,clientState::cmdLine);//修改对方状态为cmdLine
usermap.ins_sid_state(sid,clientState::cmdLine);//改两次


//accept不删,为了在break到达前accept后还能找到对方,reject可以删,因为不用找对方
//usermap.del_conn1_req_peer2(peerconn1);//把服务器保存的对方的请求删掉

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [acceptfile] from %s",myuser.c_str(),peeruser.c_str());
}
else//对方已经退出了,或在accept到服务器前break了,或其他情况,让accept方退出
{
sendstr = "@#break now";
send(myconn2,sendstr.c_str(),sendstr.size(),0);//发给自己
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void rejectfile(int conn1, string sid)
{
string sendstr;
clientState state = usermap.fvalue_sid_state(sid);//获取对方状态
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字

if(state == clientState::isWaiting)
{
sendstr = "@#sendfile reject";
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

int peerconn1 = usermap.fkey_conn1_2(conn2);//获取对方套接字1,为了修改状态
usermap.ins_conn1_state(peerconn1,clientState::cmdLine);//修改对方状态为cmdLine
usermap.ins_sid_state(sid,clientState::cmdLine);//改两次

usermap.del_conn1_req_peer2(peerconn1);//把服务器保存的对方的请求删掉

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [rejectfile] from %s",myuser.c_str(),peeruser.c_str());
}
//其他情况下对于reject不用管


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void getfile(int conn1, string sid)
{
string sendstr;
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字
int myconn2 = usermap.fvalue_conn1_2(conn1);//获取自己的接收套接字
//获取自己信息
string mysid = usermap.fvalue_conn1_sid(conn1);
string mysname = usermap.fvalue_conn1_sname(conn1);
usermap.ins_conn1_state(conn1,clientState::isWaiting);//修改自己状态为waiting
usermap.ins_sid_state(mysid,clientState::isWaiting);//改两次

if(conn2 == -1)
{
sendstr = "The peer does not exist, please break!";
send(myconn2,sendstr.c_str(),sendstr.size(),0);
return;
}
sendstr = "@#getfilefrom "+mysid+" "+mysname;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方
usermap.ins_conn1_req_peer2(conn1,conn2);//添加一个请求映射

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s send a [getfile] requestion to %s",myuser.c_str(),peeruser.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void acceptget(int conn1, string sid, string& src)
{
string sendstr;
clientState state = usermap.fvalue_sid_state(sid);//获取对方状态
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字

if(state == clientState::isWaiting)
{
sendstr = "@#getfile accept "+src;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方


int peerconn1 = usermap.fkey_conn1_2(conn2);//获取对方套接字1,为了修改状态
usermap.ins_conn1_state(peerconn1,clientState::cmdLine);//修改对方状态为cmdLine
usermap.ins_sid_state(sid,clientState::cmdLine);//改两次


//accept不删,为了在break到达前accept后还能找到对方,reject可以删,因为不用找对方
//usermap.del_conn1_req_peer2(peerconn1);//把服务器保存的对方的请求删掉

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [acceptget] from %s",myuser.c_str(),peeruser.c_str());
}
//其他情况下对于getfile不用管
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void rejectget(int conn1, string sid)
{
string sendstr;
clientState state = usermap.fvalue_sid_state(sid);//获取对方状态
int conn2 = usermap.fvalue_sid_conn2(sid);//获取对方接收套接字

if(state == clientState::isWaiting)
{
sendstr = "@#getfile reject";
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

int peerconn1 = usermap.fkey_conn1_2(conn2);//获取对方套接字1,为了修改状态
usermap.ins_conn1_state(peerconn1,clientState::cmdLine);//修改对方状态为cmdLine
usermap.ins_sid_state(sid,clientState::cmdLine);//改两次

usermap.del_conn1_req_peer2(peerconn1);//把服务器保存的对方的请求删掉

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [rejectget] from %s",myuser.c_str(),peeruser.c_str());
}
//其他情况下对于reject不用管


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void choosefile(int conn1, string number)//服务器没有re命令,但是多了一个choosefile,通告选择的文件号码
{
string sendstr;
int conn2 = usermap.fvalue_conn1_req_peer2(conn1);//从getfile请求里获取对方的套接字
if(conn2 == -1)//意外情况...
{
LOG_ERROR("[choosefile] can't find the file-sender")
return;
}

string myip = usermap.fvalue_conn1_ip(conn1);//告知对方(发送方)本地ip地址
sendstr = "@#choosefile "+number+" "+myip;
send(conn2,sendstr.c_str(),sendstr.size(),0);//发给对方

string myuser = usermap.fvalue_conn1_user(conn1);//获取发送方userid
string peeruser = usermap.fvalue_conn2_user(conn2);//获取对方userid
LOG_INFO("%s [choosefile] from %s",myuser.c_str(),peeruser.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void setsid(int conn1, string& newsid)
{
string oldsid = usermap.fvalue_conn1_sid(conn1);//先获取旧的sid
string sendstr;
if(oldsid == newsid)
{
sendstr = "Your old sid and new sid are the same";
send(conn1,sendstr.c_str(),sendstr.size(),0);//发给自己
return;
}

int check = usermap.fvalue_sid_conn2(newsid);//查看是否已有该sid
string sendstr;
if(check != -1)//该sid已注册
{
sendstr = "The newsid ["+newsid+"] has been used, setsid failed";
send(conn1,sendstr.c_str(),sendstr.size(),0);//发给自己
return;
}

//有些表sid是当key的,这里的修改原则是删除->重新插入
string sname = usermap.fvalue_conn1_sname(conn1);//获取sname,为了改sid_sname表
clientState state = usermap.fvalue_sid_state(oldsid);//获取state,为了改sid_state表
int conn2 = usermap.fvalue_sid_conn2(oldsid);//获取conn2,为了改sid_conn2表

usermap.ins_conn1_sid(conn1,newsid);//修改

usermap.del_sid_sname(oldsid);//删
usermap.ins_sid_sname(newsid,sname);//插入
usermap.del_sid_state(oldsid);//删
usermap.ins_sid_state(newsid,state);//插入
usermap.del_sid_conn2(oldsid);//删
usermap.ins_sid_conn2(newsid,conn2);//插入

sendstr = "setsid successfullly, new sid is ["+newsid+"]";
send(conn1,sendstr.c_str(),sendstr.size(),0);//发给自己

string myuser = usermap.fvalue_conn1_user(conn1);//获取userid
LOG_INFO("%s [setsid] from %s to [%s]",myuser.c_str(),oldsid.c_str(),newsid.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void setsname(int conn1, string& newsname)
{
string oldsname = usermap.fvalue_conn1_sname(conn1);//先获取旧的sname
string sendstr;
if(oldsname == newsname)
{
sendstr = "Your old sname and new sname are the same";
send(conn1,sendstr.c_str(),sendstr.size(),0);//发给自己
return;
}
string sid = usermap.fvalue_conn1_sid(conn1);

//修改
usermap.ins_conn1_sname(conn1,newsname);
usermap.ins_sid_sname(sid,newsname);

sendstr = "setsname successfullly, new sname is ["+newsname+"]";
send(conn1,sendstr.c_str(),sendstr.size(),0);//发给自己

string myuser = usermap.fvalue_conn1_user(conn1);//获取userid
LOG_INFO("%s [setsname] from %s to [%s]",myuser.c_str(),oldsname.c_str(),newsname.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void exit_(int conn1)//退出相当于break+clean map+close
{
break_(conn1);//调用break,让会影响到的对方退出
//break是没有关系的,因为如果普通情况下exit,请求表映射是空,那么break不会做事情

string myuser = usermap.fvalue_conn1_user(conn1);//获取userid
LOG_INFO("%s [exit]",myuser.c_str());//删之前记录一下

cleanmap(conn1);//删表
epoller.delFd(conn1);//从epoll内删除事件
close(conn1);//关套接字
}
void cleanmap(int conn1)//删表,oyasumi和exit都可以使用
{
string myip = usermap.fvalue_conn1_ip(conn1);
string mysid = usermap.fvalue_conn1_sid(conn1);
int conn2 = usermap.fvalue_conn1_2(conn1);

//13个表都删了
usermap.del_conn1_ip(conn1);
usermap.del_ip_conn2(myip);
usermap.del_conn1_2(conn1);
usermap.del_conn1_sid(conn1);
usermap.del_conn1_sname(conn1);
usermap.del_sid_conn2(mysid);
usermap.del_sid_sname(mysid);
usermap.del_sid_state(mysid);
usermap.del_conn1_state(conn1);
usermap.del_conn1_peer2(conn1);
usermap.del_conn2_user(conn2);
usermap.del_conn1_user(conn1);
usermap.del_conn1_req_peer2(conn1);

}

随机数库random

在命令hisiroyasumi中,服务器会返回一句话,这句话实际上是随机的。理论上,设置一个全局变量每次调用命令就加一,这样在多个用户调用的情况下就能做到对单个用户来说比较随机了。

但是还是用点相对来说高级的东西,c语言的rand和srand就不用了,我们来学一下c++11新实现的库<random>。使用方法很简单:

  • 实例化一个随机数引擎
  • 实例化一个统计分布(可选)
  • 分布对象调用引擎对象即可返回随机数

伪随机数引擎

random库提供了三种常用的随机数生成引擎,都以模版类的方式定义。分别是:

  • linear_congruential_engine:线性同余生成引擎,是最常用也是速度最快的,但随机效果一般
  • mersenne_twister_engine:梅森旋转算法,随机效果最好。比较慢,占用存储空间较大,但是在参数设置合理的情况下,可生成最长的不重复序列,且具有良好的频谱特征。
  • subtract_with_carry_engine:滞后Fibonacci算法。速度最快,占用存储空间较大,频谱特性有时不佳。
  • default_random_engine:就是线性同余引擎,参考https://cplusplus.com/reference/random/default_random_engine/

所有生成器引擎,或者经过adapter修饰后的类实例,都提供如下接口供使用

  • min:返回最小值,静态函数
  • max:返回最大值,静态函数
  • seed:设置随机数生成的种子
  • operator():产生随机数
  • void discard (unsigned long long z):调用z次operator()函数

另外,都定义了输入输出操作符和关系运算的非成员函数。随机数引擎接收一个整数作为种子,不提供就会使用默认值

使用大概像这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int min,max;
//定义上下边界

default_random_engine e;
//创建引擎

uniform_int_distribution<unsigned> u(min,max);
//创建取值范围

int randNum=u(e);
//获取伪随机数

//也可以直接用引擎
int randNum=e();

预定义算法

定义了算法的最佳实践,避免了参数的选择,可以直接选择引擎,即不用使用上面的引擎然后设置一些参数,可以直接用算法。

算法包括minstd_rand0、minstd_rand、mt19937(32位)、mt19937_64(产生64位随机数)、ranlux24_base、ranlux48_base等。 mt19937是目前 C++ 标准中最实用的引擎

分布

分布用于进一步加载引擎,使随机数具有分布性质,提供的分布有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//均匀分布:
uniform_int_distribution //整数均匀分布
uniform_real_distribution //浮点数均匀分布

//伯努利类型分布
bernoulli_distribution //伯努利分布
binomial_distribution //二项分布
geometry_distribution //几何分布
negative_biomial_distribution //负二项分布

// Rate-based distributions:
poisson_distribution //泊松分布
exponential_distribution //指数分布
gamma_distribution //伽马分布
weibull_distribution //威布尔分布
extreme_value_distribution //极值分布

//正态分布相关:
normal_distribution //正态分布
chi_squared_distribution //卡方分布
cauchy_distribution //柯西分布
fisher_f_distribution //费歇尔F分布
student_t_distribution // t分布

//分段分布相关:
discrete_distribution //离散分布
piecewise_constant_distribution //分段常数分布
piecewise_linear_distribution //分段线性分布

random_device

区别与其他生成算法的伪随机数,通过硬件生成真正的不确定随机数(如果硬件不支持,标准也允许使用伪随机生成方法实现此函数),返回一个unsigned int,通常作为前述引擎的seeds。

前面使用分布加载引擎后,如果不设置种子,每次运行的随机数序列都是一样的,因为:引擎使用默认值->引擎序列相同->分布产生的序列相同。

因此如果要每次运行都产生不一样的序列,就要用种子,以往的做法可以用time(NULL),但这实际上是个糟糕的做法。这就可以用到random_device。

random_device一般只用来作为其他伪随机数算法的种子,参考:C++11随机数的正确打开方式 - 别再闹了 - 博客园 (cnblogs.com)。大概就是说:

  • random_device最大最小值不可以改
  • linux下是用熵池获取随机数的,当熵池用尽后,许多random_device的具体实现的性能会急速下降
  • 多次调用random_device要花费比其他伪随机数算法更多的时间。在Linux中,每次调用random_device都需要读urandom这个文件再关闭,而在WIndom中我们需要调用操作系统的API,再销毁实例化对象,这个时间花费显然比设置好种子就能一直产生的其他伪随机数算法要慢得多。

而作为种子:只调用一次,用于给引擎一个随机的初始值就完成了。

1
2
3
4
5
6
7
8
9
10
11
#include <random>

using namespace std;

int main(){
int min = 0,max = 100;
random_device seed;//硬件生成随机数种子
ranlux48 engine(seed());//利用种子生成随机数引擎
uniform_int_distribution<int> distrib(min, max);//设置随机数范围,并为均匀分布
int random = distrib(engine);//随机数
}

随机数考量

前面大概熟悉了一下随机数的生成,但还没想好用户怎么用随机数,是所有用户共享一个随机数分布呢?还是用户来了就创建分布对象然后只生成一个数字呢?还是一个用户对应一个分布对象呢?

  • 简单点就可以用第二种,因为这两个命令用的频率应该很少,缺点是多次调用hisir的话,随机得不够均匀;并且这种方法更耗时、耗内存,为了生成单个数字而完整地实例化了一个分布对象。
  • 第一种的话也简单点,从统计的角度上看,这对于全体用户是均匀分布,但用户体验好肯定是对于单个用户来说均匀分布一些。
  • 第三种貌似比较折中,但是就需要多一个映射表,挺麻烦的,映射表已经不想改了,否决否决。

基本上是决定用第一种共享的方式,加一个互斥锁访问。因为如果从概率角度上看,每个用户访问时,每个随机数的概率都是相同的(均匀分布),这对用户来说就也都是均匀的了。当然,我也没研究底层的实现是基于统计的还是基于概率的。不过已经足够了。

严格来说,全局的方式都不用随机数种子,因为一直运行的话,也就不会有相同的序列了。因为有互斥锁,封装一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <random>
#include <mutex>
using namespace std;

class Randint
{
private:
mutex mux;//因为引擎有序的关系,要互斥
random_device seed;//硬件生成随机数种子
ranlux48 engine;//利用种子生成随机数引擎
uniform_int_distribution<int> distrib;//设置随机数范围,并为均匀分布
public:
//hisir语录和oyasumi语录大小可能不一样,所以对应不一样的随即器
Randint(int min, int max) :engine(seed()), distrib(min, max){}//初始列表构造

int operator()()
{
lock_guard<mutex> locker(mux);
return distrib(engine);
}
};

int main()//测试
{
Randint rand_of_hisir(0, 10);
for (int i = 0; i < 100; i++)
cout << rand_of_hisir() << " ";

return 0;

可以使用多个对象变成一个vector形式的随机化对象池,不过从分布来看没什么意义,因此就分别对两个命令各使用一个对象即可。

语录对象

语录对象使用const的vector<string>,通过下标访问,不用互斥。形式如下,具体就不在博客里放了。

1
2
const vector<string> hisir_sentence = [...];
const vector<string> oyasumi_sentence = [...];

1
2
3
4
5
6
7
8
void hisir(int conn1, string& msg)
{
string sendstr = hisir_sentence[rand_of_hisir()];
send(conn1,sendstr.c_str(),sendstr.size(),0);

string myuser = usermap.fvalue_conn1_user(conn1);//获取userid
LOG_INFO("%s [hisir]: %s",myuser.c_str(),msg.c_str());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
void oyasumi(int conn1)
{
string sendstr = oyasumi_sentence[rand_of_oyasumi()];
send(conn1,sendstr.c_str(),sendstr.size(),0);

break_(conn1);//调用break,让会影响到的对方退出

string myuser = usermap.fvalue_conn1_user(conn1);//获取userid
LOG_INFO("%s [oyasumi]~",myuser.c_str());//删之前记录一下

cleanmap(conn1);//删表
close(conn1);//关套接字
}

线程池

线程池就是传入task工作,可以参考之前的博客:WebServer模块单元测试 | JySama

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
//threadpool.h
#ifndef THREADPOOL_H
#define THREADPOOL_H

#include<mutex>
#include<thread>
#include<condition_variable>
#include<functional>
#include<queue>
#include <cassert>//使用assert函数
class threadpool
{
private:
struct pool//封装三个资源
{
std::mutex mtx;//互斥锁
std::queue<std::function<void()>> taskQueue;//任务队列,无参数的function,调用时不用传参
std::condition_variable cond;//条件变量
bool isclose = false;//默认值是false
};
std::shared_ptr<pool> pool_;//共享指针,pool_是一个指针指向pool结构体,这个指针用于线程池操作资源

public:
threadpool(int threadnum = 8):pool_(std::make_shared<pool>())//以make_shared的方式new一个对象给pool_指针
{
assert(threadnum > 0);//没有线程就报错
for(int i=0;i<threadnum;i++)//创建线程池
std::thread([pool_t = pool_]{//现在要按值捕获,相当于拷贝构造共享指针,计数+1,且指向相同内容
std::unique_lock<std::mutex> locker(pool_t->mtx);//定义一个locker对象,现在已经锁住了
while(true)
{
if(!pool_t->taskQueue.empty())//如果有任务
{
auto task = pool_t->taskQueue.front();
pool_t->taskQueue.pop();
locker.unlock();
//解锁后再执行
task();
//执行完了,进入下一轮循环,注意要锁住
locker.lock();//抢占锁
}
else if(pool_t->isclose)
break;
else//如果没有任务
pool_t->cond.wait(locker);//解锁并等待,唤醒后会抢占互斥锁
}
}).detach();//把thread分离,不用手动join,结束自动回收
}

void addTask(std::function<void()> task)
{
std::lock_guard<std::mutex> locker(pool_->mtx);//定义一个locker对象
pool_->taskQueue.emplace(task);//这种方式,使用emplace和push没啥区别,task本身就是临时对象
//如果要真正使用到emplace调用构造函数,还要配合std::forward完美转发,此时无论构造函数是不是explicit(不能隐式转换),都可以正常工作
pool_->cond.notify_one();//插入一个元素唤醒一个线程
}
void close()
{
pool_->isclose = true;
pool_->cond.notify_all();
}
~threadpool()//析构函数
{
}
};
#endif

工作函数补充——内网穿透

于2022/12补充

当进行文件传输时,公网服务器在工作线程里顺便实现内网穿透(基于udp)服务即可,该部分的工作在这篇博客:UDP hole punching | JySama。为了封装到项目中,使用命名空间。

为了简单,不考虑高并发的情况,默认连续的两个向服务器请求的内网主机就是要进行内网穿透的主机。

udp_hole_punch.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef UDP_HOLE_PUNCHING_H
#define UDP_HOLE_PUNCHING_H
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>//sockaddr_in
#include <arpa/inet.h>//in_addr
#include <cstring>
#include <unistd.h>//close
#include <vector>
#include "log.h"
using namespace std;
namespace UDP_HP
{
void init_udp_Socket(int& listenfd, const int port);
string udp_hole_punching(int listenfd);
vector<string> parse(string str);
void work(int& listenudp);
}
#endif

udp_hole_punch.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#include "udp_hole_punch.h"
namespace UDP_HP
{
void init_udp_Socket(int& listenfd, const int port)
{
listenfd = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);//UDP
if(listenfd < 0)
{
LOG_ERROR("create listen socket error, port-%d\n",port);
exit(1);
}
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);
socketaddr.sin_addr.s_addr = htonl(INADDR_ANY);

//Port reused
int optval = 1;
int ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void*)&optval, sizeof(int));
if(ret == -1)
{
close(listenfd);
exit(1);
}

//bind
if(bind(listenfd,(struct sockaddr *)&socketaddr,sizeof(socketaddr))==-1)
{
close(listenfd);
exit(1);
}
}

string udp_hole_punching(int listenfd)
{
struct sockaddr_in gateway;
socklen_t addr_len = sizeof(gateway);
memset(&gateway, 0, sizeof(gateway));
char recvbuf[128];
memset(&recvbuf, 0, sizeof(recvbuf));

int res = recvfrom(listenfd, recvbuf, 128, 0, (struct sockaddr *)&gateway, &addr_len);
if(res < 0)
{
LOG_WARN("udp hole punching receive error!\n");
return "";
}
else
{
string ip = string(inet_ntoa(gateway.sin_addr));
string port = to_string(ntohs(gateway.sin_port));

string host = string(recvbuf);
LOG_INFO("udp hole punching NAT ip: %s, port: %s; host:%s\n",ip.c_str(),port.c_str(),host.c_str());

return ip+" "+port+" "+host;
}
return "";
}

vector<string> parse(string str)
{
str = str + " ";//add a space
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = str.find(' ', pos)) != string::npos)
{
res.push_back(str.substr(pos, pos1 - pos));
while (str[pos1] == ' ')
pos1++;
pos = pos1;
}
return res;//move rvalue
}

void work(int& listenudp)
{
string ip_port1 = udp_hole_punching(listenudp);
string ip_port2 = udp_hole_punching(listenudp);
if(ip_port1 == "" || ip_port2 == "")
return;
vector<string> host1 = parse(ip_port1);
vector<string> host2 = parse(ip_port2);

//The server sends back using a NAT ip
string ip1 = host1[0];
string port1 = host1[1];
string ip2 = host2[0];
string port2 = host2[1];

struct sockaddr_in socketaddr1;
socketaddr1.sin_family = AF_INET;//ipv4
socketaddr1.sin_port = htons(stoi(port1));
inet_pton(AF_INET, ip1.c_str(), &socketaddr1.sin_addr);

struct sockaddr_in socketaddr2;
socketaddr2.sin_family = AF_INET;//ipv4
socketaddr2.sin_port = htons(stoi(port2));
inet_pton(AF_INET, ip2.c_str(), &socketaddr2.sin_addr);

if(ip1 != ip2) //Different intranets
{
ip_port1 = ip1 + " " + port1;
ip_port2 = ip2 + " " + port2;
}
else //In the same NAT
{
ip_port1 = host1[2] + " " + host1[3];
ip_port2 = host2[2] + " " + host2[3];
}

int res = sendto(listenudp, ip_port1.c_str(),
ip_port1.size(), 0, (struct sockaddr*)&socketaddr2, sizeof(socketaddr2));
if(res < 0)
{
LOG_WARN("udp sendto error!\n");
//exit(1);
}

res = sendto(listenudp, ip_port2.c_str(),
ip_port2.size(), 0, (struct sockaddr*)&socketaddr1, sizeof(socketaddr1));
if(res < 0)
{
LOG_WARN("udp sendto error!\n");
//exit(1);
}

}
}

下一阶段

现在可以进入下一阶段了,见下一篇博客(这篇字数多太卡了)

前言

这应该是我自己从头开始自己思考、自己设计、自己写代码的第一个项目了。

想做个小聊天软件,以命令行为主,我喜欢这种风格。不过因为是命令行交互,为了支持更多功能,所以整个客户端程序就变成了一个巨大的状态机,设计起来有些困难。

难点在于要单缓冲区多线程,一个输出输入缓冲(黑框框)要键入命令、接收随时可能到来的输出;并且用户可能输入一些不对应状态的命令,这也需要处理;最后还必须处理网络时延的问题。

项目代码链接:Chen-Jin-yuan/orange (github.com)

设计分析

  • 单reactor多线程模式
  • 主要有三种功能
    • 命令:客户端向服务器发送命令进行交互,命令行状态下直接输入,在其他状态下使用@字符:@cmd
    • 聊天:客户之间能进行文字聊天通信,数据通信路径为客户->服务器->客户,这样服务器可以记录聊天数据
      • 需要注册登录,有一个唯一的登录id,链接到数据库;
      • 登录后可以设置**标识字符串sid(search id)**,以及标识用户名sname。sid用于其他客户寻找本用户,即建立一个临时的会话(好玩的地方),sid在同一时间不能相同;标识用户名可任意,通信时显示。
    • 文件传输:进行p2p的文件传输
      • 用户A可以通过命令获取用户B的文件资源表(resource文件夹下放置可传输的文件),经用户B同意后可以传输
      • 用户A也可以主动发送文件给用户B,用户B同意后可接收
  • 调用主要以命令为主,本质是一台状态机,难点在于用户输入只有一个缓冲,状态不能交叉影响

命令系统

这里设计命令系统,用户在普通情况下是一个命令行输入,即cmd> ,这里为了清楚标识,命令关键字以**@**标识。

命令 作用
@register [userID] [password] [password again] 注册一个账号,用户名和密码不能含空格,用户名不能重
@login [userID] [password] 登录账号
@search [sid] 搜索某个sid,若存在则返回对方sname和是否在chat的信息;对方不会知道有用户搜索ta
@chat [sid] 准备进入chat状态,描述较长见表格后
@accept [sid] 接受某个sid,收到chat请求后会维护更新本地客户端的一个请求表,需要存在该sid
@reject [sid] 拒绝某个sid,收到chat请求后会维护更新本地客户端的一个请求表,需要存在该sid
@break 退出状态,进入命令行状态,描述较长见表格后
@send [sid] [msg] 直接向某个sid(若存在)发送一条信息,对方会得知sid和sname
@sendfile [sid] [path] 直接向某个sid(若存在)发送一条文件发送信息,对方会得知sid和sname和文件名,进入等待
@acceptfile [sid] 接收某sid发送的文件,返回接收信息
@rejectfile [sid] 拒绝某sid发送的文件,返回拒绝信息
@getfile [sid] 请求获取对方的文件资源表,对方会得知sid和sname,进入等待
@acceptget [sid] 同意对方获取资源,返回接受信息,等待对方选择文件
@rejectget [sid] 拒绝对方获取资源,返回拒绝信息
@setsid [newsid] 设置sid,不设置默认用登录的userID
@setsname [newsname] 设置sname,不设置默认用登录的userID
@re 重复上一条命令
@exit 退出
@hisir [msg] 向服务器说点事情,会得到服务器返回的一句话
@oyasumi 退出,向服务器说晚安
  • @chat:直接与某个sid用户通信,如果没有该sid会返回错误信息1;否则对方会接收到请求,获得你的sid和sname,并选择是否接受。若拒绝则返回拒绝信息;若接受则进入chat状态。用户在调用chat命令后,会进入等待状态(等待对方接受)
  • @break:
    • 退出等待状态,这会告知对方已经不请求chat了,同时删除对方的请求表中的sid,返回命令行状态
    • 退出chat状态,这会告知对方已经断开chat了,返回命令行状态
    • 退出发送文件等待状态,和chat一样
    • 退出获取文件资源表的等待状态
  • @sendfile:调用break可以退出等待;若对方接收,会开辟一个发送文件线程;若对方拒绝,直接退出等待
  • @getfile:若对方同意,则可以开始选择文件

客户端

我们先随意写一些功能函数,最后封装起来,一些全局变量最后会变成类成员变量,请不用担心。

命令解析

首先解析命令,从命令行读入字符串然后分析。命令解析在本地完成,不发送给服务器,这样能降低出错的可能并降低服务器的复杂度。

下面是一个解析命令的demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
#include<iostream>
#include<cstring>//string.h
#include<string>
#include<unordered_map>
using namespace std;

const unordered_map<string, int> cmdmap =
{
{"register",0},
{"login",1},
{"search",2},
{"chat",3},
{"accept",4},
{"reject",5},
{"break",6},
{"send",7},
{"sendfile",8},
{"acceptfile",9},
{"rejectfile",10},
{"getfile",11},
{"acceptget",12},
{"rejectget",13},
{"setsid",14},
{"setsname",15},
{"re",16},
{"exit",17},
{"hisir",18},
{"oyasumi",20}
};

//去掉string首尾空格
void trim(string& s)
{
if (!s.empty())
{
s.erase(0, s.find_first_not_of(" \t"));//首次出现不匹配空格的位置
s.erase(s.find_last_not_of(" \t") + 1);
}
}

//解析命令,cmdstr首位空格都去掉了
vector<string> parse(string cmdstr)
{
//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

return res;

}

void run()
{

char cmdbuf[128];
char cmdtmp[128] = "no cmd";
bool reflag = false;
cout << "cmd> ";
//根据bool或运算顺序,如果用上一次的结果(reflag==true),就不接收字符
while (reflag || fgets(cmdbuf, sizeof(cmdbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
string cmdstr(cmdbuf);
trim(cmdstr);

if (cmdstr == "\n")//不管怎么样都有个换行,仅有一个换行就不管
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << "cmd> ";
continue;
}
vector<string> cmdvec = parse(cmdstr);

//查找命令
auto iter = cmdmap.find(cmdvec[0]);
if (iter == cmdmap.end())//命令错误
{
cout << "Wrong command, your command parsed is: " << cmdvec[0] << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << "cmd> ";
continue;
}

int cmdvalue = iter->second;
switch (cmdvalue)
{
case 0:
if (cmdvec.size() != 4)
{
cout << "error> The number of parameters for [register] is wrong!" << endl;
break;
}
else if (cmdvec[2] != cmdvec[3])
{
cout << "error> The passwords entered twice are not equal!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 1:
if (cmdvec.size() != 3)
{
cout << "error> The number of parameters for [login] is wrong!" << endl;
break;
}

else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 2:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [search] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 3:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [chat] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 4:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [accept] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 5:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [reject] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 6:
if (cmdvec.size() != 1)
{
cout << "error> The number of parameters for [break] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << endl;
break;
}

case 7:
if (cmdvec.size() != 3)
{
cout << "error> The number of parameters for [send] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 8:
if (cmdvec.size() != 3)
{
cout << "error> The number of parameters for [sendfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 9:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [acceptfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 10:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [rejectfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 11:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [getfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 12:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [acceptget] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 13:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [rejectget] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 14:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [setsid] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 15:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [setsname] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 16:
if (cmdvec.size() != 1)
{
cout << "error> The number of parameters for [re] is wrong!" << endl;
reflag = false;//虽然是re,但是命令有问题
break;
}
else//即使是错误命令也存,用户可能头铁...就想再试一次
{
//做处理
if (!strcmp(cmdtmp, "no cmd"))//如果还没有命令
{
cout << "error> No command yet!" << endl;
}
else
{
cout << "reEX> " << cmdtmp;//不用换行,cmdtmp自然有个'\n'
//strcpy已不可用,不指定长度不安全。使用strlen要+1,因为长度不包含结束符,要补上去
strcpy_s(cmdbuf,strlen(cmdtmp)+1,cmdtmp);
reflag = true;//表明不必把cmdbuf置零
}
break;
}

case 17:
if (cmdvec.size() != 1)
{
cout << "error> The number of parameters for [exit] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << endl;
break;
}

case 18:
if (cmdvec.size() != 2)
{
cout << "error> The number of parameters for [hisir] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 20:
if (cmdvec.size() != 1)
{
cout << "error> The number of parameters for [oyasumi] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

}
if (cmdvalue == 16 && reflag)//如果是re且成功的话就不用cmd,不用cmdbuf置零
continue;
else if (cmdvalue == 16)//虽然是re但失败了,不能保存re
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << "cmd> ";
}
else
{
strcpy_s(cmdtmp,strlen(cmdbuf)+1,cmdbuf);//暂存上一次命令,方便re调用,如果上一次是re的话,不更新
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << "cmd> ";
reflag = false;//上一次不是re自然置false
}

}
}
int main()
{
run();
return 0;
}

连接服务器

在客户端打开初始化时自动连接服务器,然后返回一个连接套接字描述符,允许收发信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include<iostream>
#include<cstring>
#include<WinSock2.h>//除了inet_pton
#include <WS2tcpip.h>//inet_pton

#pragma comment(lib,"ws2_32.lib")//链接dll

//#define MYPORT 8000
//const char* SERVER_IP = "192.168.248.131";
//ip和port以参数形式传入
//INVALID_SOCKET(~0)和SOCKET_ERROR(-1)都是-1
SOCKET connect_S(const char* SERVER_IP, int MYPORT)//返回连接句柄
{
//初始化WSA
WORD sockVersion = MAKEWORD(2, 2);
WSADATA wsaData;//WSADATA结构体变量的地址值

//int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
//成功时会返回0,失败时返回非零的错误代码值
if (WSAStartup(sockVersion, &wsaData) != 0)
{
std::cerr << "WSAStartup() error!" << std::endl;
return INVALID_SOCKET;
}

//创建套接字
SOCKET connfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (connfd == INVALID_SOCKET)
{
std::cerr << "socket error !" << std::endl;
return INVALID_SOCKET;
}

//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(MYPORT);//服务器端口,自己连接后的端口是os分配的,由进程选择一个端口去连服务器
//socketaddr.sin_addr.s_addr = inet_addr(SERVER_IP); ///服务器ip
//inet_addr最好换成inet_aton(),不会冤枉0.0.0.0和255.255.255.255
struct in_addr inaddr;
inet_pton(AF_INET, SERVER_IP, &inaddr);//windows下相当于inet_aton的函数,多了第一个参数表明是ipv4还是ipv6
socketaddr.sin_addr = inaddr;
std::cout << "connecting to ip-" << SERVER_IP << " port-" << MYPORT << std::endl;

///连接服务器,成功返回0,错误返回-1。返回的描述符connfd,该socket包含了服务器ip、port,自己ip、port,可用于发送和接收数据
if (connect(connfd, (struct sockaddr*)&socketaddr, sizeof(socketaddr)) == SOCKET_ERROR)
{
std::cerr << "connect fail !" << std::endl;
return SOCKET_ERROR;
}
std::cout << "connect to server successfully!" << std::endl;

return connfd;
}
/*
注意该函数还没有
closesocket(connfd);和WSACleanup();
我们写一个关闭函数
*/
//调用多少次connect_S,就要调用多少次close_S
void close_S(SOCKET connfd)
{
closesocket(connfd);
WSACleanup();
}

设计状态机

为了简化设计,有一些状态是显式状态,有一些状态是隐式状态。什么是显式和隐式呢?显式状态比如说在命令行和在chat时输入的响应是不一样的,这是全局的;隐式状态比如说一些等待状态,这些等待是阻塞的,局部的,就不需要显式切换状态。简单来说,

  • 显式状态:
    • 未登录状态:可注册、登录,其他命令都是ban的;登录后进入命令行状态;
    • 命令行状态:不可注册登录,其他可使用;chat后进入等待状态阻塞,对方接受后进入chat状态,否则返回命令行状态;
    • chat状态:此状态下的输入都发送给对端,除非使用@标识命令,比如使用@break退出chat状态;
    • 等待状态:所有等待状态下,可以输入@break返回命令行状态。
  • 隐式状态:
    • 请求获取等待状态:等待对方响应,响应后进入命令行状态(拒绝)或选择文件状态(接受);
    • 选择文件状态:等待用户输入文件序号;
    • chat等待状态:等待对方响应;
    • 发送文件等待状态:等待对方响应,响应后进入命令行状态;
    • 等待文件被获取状态:允许对方获取资源,等待对方选择文件。

状态使用枚举类,c++编程风格惯用法提出使用enum class代替enum和namespace,可以参考c++编程风格惯用法 | JySama

1
2
3
4
5
6
7
8
9
10
11
12
enum class clientState
{
noLogin = 0,//未登录状态
cmdLine,//命令行状态
isChatting,//chat状态
isWaiting//等待状态
};

//调用例子
clientState state;//定义一个枚举类型变量
if(state == clientState::noLogin and loginsuccess)
state = clientState::cmdLine;

状态机实现–重写run()

对于不同状态,我们希望输出的提示符不同,比如前面都是cmd> ,现在希望根据状态进行改变。一种方法是打印提示符时判断状态,太麻烦了;一种方法是把提示符看做变量,更改状态时就更改提示符。

1
2
3
4
5
6
7
8
9
10
11
12
命令行状态:"cmd> "
等待状态:"waiting> "
chat状态:"chatting with [sname]> "
未登录状态:"need login> "

unordered_map<clientState,string> promptMap =
{
{clientState::noLogin,"need login> "},
{clientState::cmdLine,"cmd> "},
{clientState::isChatting,"chatting with "},//进入chat状态要根据对方的sname来拼接
{clientState::isWaiting,"waiting> "}
};

如果没有登录而调用ban掉的命令,我们就打印一句错误信息

1
2
3
4
5
if(state == clientState::noLogin)
{
cerr << "error> Not signed in yet!" <<endl;
break;
}

如果已经登录还调用注册和登录命令,就打印一句错误信息

1
2
3
4
5
if(state != clientState::noLogin)
{
cerr << "error> Signed in yet!" <<endl;
break;
}

这些错误信息,我们可以在case语句里面加,但太臃肿了,我们进行一层封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
bool checkCmd(int cmdvalue)
{
//最开始只考虑是否登录
if(cmdvalue==0||cmdvalue==1)
{
if(state != clientState::noLogin)
{
cerr << "error> Have signed in yet!" <<endl;
return false;
}
}
else
{
if(state == clientState::noLogin)
{
cerr << "error> Not signed in yet!" <<endl;
return false;
}
}

//如果正在等待,只允许break
if(state == clientState::isWaiting && cmdvalue!= 6)
{
cerr << "error> Only break or @break can input because you are waiting for something!" <<endl;
return false;
}

return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
//test
#include<iostream>
#include<cstring>//string.h
#include<string>
#include<unordered_map>
using namespace std;

const unordered_map<string, int> cmdmap =
{
{"register",0},
{"login",1},
{"search",2},
{"chat",3},
{"accept",4},
{"reject",5},
{"break",6},
{"send",7},
{"sendfile",8},
{"acceptfile",9},
{"rejectfile",10},
{"getfile",11},
{"acceptget",12},
{"rejectget",13},
{"setsid",14},
{"setsname",15},
{"re",16},
{"exit",17},
{"hisir",18},
{"oyasumi",20}
};


enum class clientState
{
noLogin = 0,//未登录状态
cmdLine,//命令行状态
isChatting,//chat状态
isWaiting//等待状态
};

unordered_map<clientState, string> promptMap =
{
{clientState::noLogin,"need login> "},
{clientState::cmdLine,"cmd> "},
{clientState::isChatting,"chatting with "},//进入chat状态要根据对方的sname来拼接
{clientState::isWaiting,"waiting> "}
};


//去掉string首尾空格
void trim(string& s)
{
if (!s.empty())
{
s.erase(0, s.find_first_not_of(" \t"));//首次出现不匹配空格的位置
s.erase(s.find_last_not_of(" \t") + 1);
}
}

//解析命令,cmdstr首位空格都去掉了
vector<string> parse(string cmdstr)
{
//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

return res;

}
clientState state = clientState::noLogin;
string prompt = promptMap[state];
bool checkCmd(int cmdvalue)
{
//最开始只考虑是否登录
if (cmdvalue == 0 || cmdvalue == 1)
{
if (state != clientState::noLogin)
{
cerr << "error> Have signed in yet!" << endl;
return false;
}
}
else
{
if (state == clientState::noLogin)
{
cerr << "error> Not signed in yet!" << endl;
return false;
}
}

//如果正在等待,只允许break
if (state == clientState::isWaiting && cmdvalue != 6)
{
cerr << "error> Only break or @break can input because you are waiting for something!" << endl;
return false;
}

return true;
}



void run()
{

char cmdbuf[128];
char cmdtmp[128] = "no cmd";
bool reflag = false;
cout << prompt;
//根据bool或运算顺序,如果用上一次的结果(reflag==true),就不接收字符
while (reflag || fgets(cmdbuf, sizeof(cmdbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
string cmdstr(cmdbuf);
trim(cmdstr);

if (cmdstr == "\n")//不管怎么样都有个换行,仅有一个换行就不管
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
continue;
}
vector<string> cmdvec = parse(cmdstr);

//查找命令
auto iter = cmdmap.find(cmdvec[0]);
if (iter == cmdmap.end())//命令错误
{
cerr << "Wrong command, your command parsed is: " << cmdvec[0] << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
reflag = false;//因为可能是调用了re(连续两次login)会导致错误,这里错误要让用户重新输入,否则会崩溃(cmdbuf是空)
cout << prompt;
continue;
}

int cmdvalue = iter->second;
if (!checkCmd(cmdvalue))//如果命令与状态不匹配
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
continue;
}
switch (cmdvalue)
{
case 0:
if (cmdvec.size() != 4)
{
cerr << "error> The number of parameters for [register] is wrong!" << endl;
break;
}
else if (cmdvec[2] != cmdvec[3])
{
cerr << "error> The passwords entered twice are not equal!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 1:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [login] is wrong!" << endl;
break;
}

else
{
//做处理
state = clientState::cmdLine;
prompt = promptMap[state];
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 2:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [search] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 3:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [chat] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input break or @break to back to command line..." << endl;
break;
}

case 4:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [accept] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 5:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [reject] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 6:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [break] is wrong!" << endl;
break;
}
else
{
//做处理
state = clientState::cmdLine;
prompt = promptMap[state];
break;
}

case 7:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [send] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 8:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [sendfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 9:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 10:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 11:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [getfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 12:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptget] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 13:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectget] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 14:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsid] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 15:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsname] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 16:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [re] is wrong!" << endl;
reflag = false;//虽然是re,但是命令有问题
break;
}
else//即使是错误命令也存,用户可能头铁...就想再试一次
{
//做处理
if (!strcmp(cmdtmp, "no cmd"))//如果还没有命令
{
cerr << "error> No command yet!" << endl;
}
else
{
cout << "reEX> " << cmdtmp;//不用换行,cmdtmp自然有个'\n'
//strcpy已不可用,不指定长度不安全。使用strlen要+1,因为长度不包含结束符,要补上去
strcpy_s(cmdbuf, strlen(cmdtmp) + 1, cmdtmp);
reflag = true;//表明不必把cmdbuf置零
}
break;
}

case 17:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [exit] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << endl;
break;
}

case 18:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [hisir] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 20:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [oyasumi] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

}
if (cmdvalue == 16 && reflag)//如果是re且成功的话就不用cmd,不用cmdbuf置零
continue;
else if (cmdvalue == 16)//虽然是re但失败了,不能保存re
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
}
else
{
strcpy_s(cmdtmp, strlen(cmdbuf) + 1, cmdbuf);//暂存上一次命令,方便re调用,如果上一次是re的话,不更新
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
reflag = false;//上一次不是re自然置false
}

}
}

int main()
{
run();
return 0;
}

处理@

我们对命令的解析还没有包含@形式,实际上@只有在chatting状态下才被必须,其他情况下只需要简单的把@去掉即可,即检测到第一个字符的@的话就把@去掉。

而在chatting状态下,我们依旧从fgets里接收输入,如果第一个字符是@,我们判定为输入命令,这样才有一系列的操作(解析命令、进行switch等等):

1
2
3
4
5
6
7
8
9
10
if(state == clientState::isChatting && cmdbuf[0]!='@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
cout << prompt;
}

else//否则一定是命令
{
//解析命令
}

这样,对于解析命令函数,就可以这样改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector<string> parse(string cmdstr)
{
//首先看第一个字符是不是@,是的话去掉就好了
if(cmdstr[0] == '@')
cmdstr = cmdstr.substr(1,cmdstr.size()-1);

//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

return res;

}

我们还没有处理chatting的状态转换,因为这涉及服务器发回确认,现在我们在chat命令那使用Sleep()函数阻塞一段时间,表示等待同意。demo可以这样改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//test
#include<iostream>
#include<cstring>//string.h
#include<string>
#include<unordered_map>
#include <windows.h>//Sleep()
using namespace std;

string chatSname = "";

const unordered_map<string, int> cmdmap =
{
{"register",0},
{"login",1},
{"search",2},
{"chat",3},
{"accept",4},
{"reject",5},
{"break",6},
{"send",7},
{"sendfile",8},
{"acceptfile",9},
{"rejectfile",10},
{"getfile",11},
{"acceptget",12},
{"rejectget",13},
{"setsid",14},
{"setsname",15},
{"re",16},
{"exit",17},
{"hisir",18},
{"oyasumi",20}
};


enum class clientState
{
noLogin = 0,//未登录状态
cmdLine,//命令行状态
isChatting,//chat状态
isWaiting//等待状态
};

unordered_map<clientState, string> promptMap =
{
{clientState::noLogin,"need login> "},
{clientState::cmdLine,"cmd> "},
{clientState::isChatting,"chatting with "},//进入chat状态要根据对方的sname来拼接
{clientState::isWaiting,"waiting> "}
};


//去掉string首尾空格
void trim(string& s)
{
if (!s.empty())
{
s.erase(0, s.find_first_not_of(" \t"));//首次出现不匹配空格的位置
s.erase(s.find_last_not_of(" \t") + 1);
}
}

//解析命令,cmdstr首位空格都去掉了
vector<string> parse(string cmdstr)
{
//首先看第一个字符是不是@,是的话去掉就好了
if (cmdstr[0] == '@')
cmdstr = cmdstr.substr(1, cmdstr.size() - 1);

//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

return res;

}
clientState state = clientState::noLogin;
string prompt = promptMap[state];
bool checkCmd(int cmdvalue)
{
//最开始只考虑是否登录
if (cmdvalue == 0 || cmdvalue == 1)
{
if (state != clientState::noLogin)
{
cerr << "error> Have signed in yet!" << endl;
return false;
}
}
else
{
if (state == clientState::noLogin)
{
cerr << "error> Not signed in yet!" << endl;
return false;
}
}
//如果正在chatting,不允许再chat
if (state == clientState::isChatting && cmdvalue == 3)
{
cerr << "error> You are chatting with "<<chatSname<<"! You can break to chat with other." << endl;
return false;
}

//如果正在等待,只允许break
if (state == clientState::isWaiting && cmdvalue != 6)
{
cerr << "error> Only break or @break can input because you are waiting for something!" << endl;
return false;
}

return true;
}



void run()
{

char cmdbuf[128];
char cmdtmp[128] = "no cmd";
bool reflag = false;
cout << prompt;
//根据bool或运算顺序,如果用上一次的结果(reflag==true),就不接收字符
while (reflag || fgets(cmdbuf, sizeof(cmdbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
cout << "send something..." << endl;//test
cout << prompt;
}
else//剩下的内容都是else的
{
string cmdstr(cmdbuf);
trim(cmdstr);

if (cmdstr == "\n")//不管怎么样都有个换行,仅有一个换行就不管
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
continue;
}
vector<string> cmdvec = parse(cmdstr);

//查找命令
auto iter = cmdmap.find(cmdvec[0]);
if (iter == cmdmap.end())//命令错误
{
cerr << "Wrong command, your command parsed is: " << cmdvec[0] << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
continue;
}

int cmdvalue = iter->second;
if (!checkCmd(cmdvalue))//如果命令与状态不匹配
{
memset(cmdbuf, 0, sizeof(cmdbuf));
reflag = false;//因为可能是调用了re(连续两次login)会导致错误,这里错误要让用户重新输入,否则会崩溃(cmdbuf是空)
cout << prompt;
continue;
}
switch (cmdvalue)
{
case 0:
if (cmdvec.size() != 4)
{
cerr << "error> The number of parameters for [register] is wrong!" << endl;
break;
}
else if (cmdvec[2] != cmdvec[3])
{
cerr << "error> The passwords entered twice are not equal!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 1:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [login] is wrong!" << endl;
break;
}

else
{
//做处理
state = clientState::cmdLine;
prompt = promptMap[state];
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 2:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [search] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 3:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [chat] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input break or @break to back to command line..." << endl;

Sleep(3000);//阻塞3秒,假装等待连接
state = clientState::isChatting;
//chatSname = ...
prompt = promptMap[state] + chatSname+"> ";
break;
}

case 4:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [accept] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 5:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [reject] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 6:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [break] is wrong!" << endl;
break;
}
else
{
//做处理
state = clientState::cmdLine;
prompt = promptMap[state];
break;
}

case 7:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [send] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 8:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [sendfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 9:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 10:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 11:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [getfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 12:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptget] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 13:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectget] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 14:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsid] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 15:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsname] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 16:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [re] is wrong!" << endl;
reflag = false;//虽然是re,但是命令有问题
break;
}
else//即使是错误命令也存,用户可能头铁...就想再试一次
{
//做处理
if (!strcmp(cmdtmp, "no cmd"))//如果还没有命令
{
cerr << "error> No command yet!" << endl;
}
else
{
cout << "reEX> " << cmdtmp;//不用换行,cmdtmp自然有个'\n'
//strcpy已不可用,不指定长度不安全。使用strlen要+1,因为长度不包含结束符,要补上去
strcpy_s(cmdbuf, strlen(cmdtmp) + 1, cmdtmp);
reflag = true;//表明不必把cmdbuf置零
}
break;
}

case 17:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [exit] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << endl;
break;
}

case 18:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [hisir] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 20:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [oyasumi] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

}
if (cmdvalue == 16 && reflag)//如果是re且成功的话就不用cmd,不用cmdbuf置零
continue;
else if (cmdvalue == 16)//虽然是re但失败了,不能保存re
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
}
else
{
strcpy_s(cmdtmp, strlen(cmdbuf) + 1, cmdbuf);//暂存上一次命令,方便re调用,如果上一次是re的话,不更新
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt;
reflag = false;//上一次不是re自然置false
}
}


}
}

int main()
{
run();
return 0;
}

接收处理

在基础设计时,已经降低了交互的复杂度,比如我们使用accept命令来回复,而不是直接根据请求键入其他东西,在接下来的考虑中,你会得到更深刻的理解。尽管我们仍然有许多命令没处理完,但在处理它们之前,要讨论怎么从服务器接收返回的数据,这是个很值得深思的问题。

对于发送命令来说,我们在本地解析了命令就可以直接发送给服务器,服务器根据前面设计的命令解析(只有少许不同,让服务器区分发送的是命令还是聊天内容)就可以解析完成,因此对于客户端来说,重点是处理接收,困难就在于:用户只有一个输出缓冲区和一个输入缓冲区!

  • 一种方式是:让用户在调用需要服务器交互的命令后阻塞,等待接收,然后对接收内容进行处理。比如当login、register后,等待服务器返回成功和失败信息即可,理论上所有的命令都可以用这种方式来实现;
  • 上面的方式没有考虑一个问题,就是当别的客户发来一条信息时,实际上本地客户端并不一定去recv。比如本地用户还在干别的事情就有别的用户发一个send或get请求过来,这个请求经由服务器再发送到本地客户端,直到下次本地客户调用阻塞命令才会进行接收。另外在chat的时候实际上也不知道什么时候该recv。一方面时延很久,一方面无法区分返回的命令。这是两个很严重的问题。
  • 额外接收线程:我们还是对问题简化
    • 直接与服务器交互的命令,就直接阻塞等待接收(因为必须等待处理);
    • 与其他用户的交互,这会导致信息不知道什么时候发送过来,就另外用一个线程去接收,这个线程使用另外一个套接字(线程的port也会不一样),发送给哪个套接字交给服务器判断就好了。

  • 看起来问题已经解决了,但如果服务器只有一个监听端口,会使得复杂度过大。服务器是可以监听多个端口的,那么可以让服务器用两个监听线程。这两个监听线程刚好可以把两种套接字区分开来。
  • 由于有两个监听线程,导致我们不得不再考虑:什么信息该发送到哪个端口?这个问题产生的原因是,两个监听线程得到的连接套接字可以要交互。比如chat,当用户1发送一条消息时,实际上是在我们的主线程(接收stdin解析命令线程)发送的,如果走的是监听1(接收命令的线程),那么要想把这条消息发送给用户2,就要从监听2那里获取用户2的接收信息套接字,再发送。这使得问题复杂化,可能需要考虑共享变量互斥的问题。因此我们不得不考虑发送什么到哪里的问题。
  • 好吧,其实这个问题不用考虑:“接收线程允许发送命令吗?”这是不太允许的,因为它要一直recv,你可能会说用非阻塞的recv,但这又使得问题走向另一个复杂的地方。我们可以断言:所有的命令,都要发送给服务器的接收命令的线程!至于命令或是回复的转交,不得不通过另一个连接来执行。

现在来完成接收线程要做的事情,先封装到一个函数里,这里首先要连接服务器,还记得前面写的connect_S函数吗:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include<iostream>
#include<cstring>
#include<WinSock2.h>//除了inet_pton
#include <WS2tcpip.h>//inet_pton

#pragma comment(lib,"ws2_32.lib")//链接dll

//#define MYPORT 8000
//const char* SERVER_IP = "192.168.248.131";
//ip和port以参数形式传入
//INVALID_SOCKET(~0)和SOCKET_ERROR(-1)都是-1
SOCKET connect_S(const char* SERVER_IP, int MYPORT)//返回连接句柄
{
//初始化WSA
WORD sockVersion = MAKEWORD(2, 2);
WSADATA wsaData;//WSADATA结构体变量的地址值

//int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
//成功时会返回0,失败时返回非零的错误代码值
if (WSAStartup(sockVersion, &wsaData) != 0)
{
std::cerr << "WSAStartup() error!" << std::endl;
return INVALID_SOCKET;
}

//创建套接字
SOCKET connfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (connfd == INVALID_SOCKET)
{
std::cerr << "socket error !" << std::endl;
return INVALID_SOCKET;
}

//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(MYPORT);//服务器端口,自己连接后的端口是os分配的,由进程选择一个端口去连服务器
//socketaddr.sin_addr.s_addr = inet_addr(SERVER_IP); ///服务器ip
//inet_addr最好换成inet_aton(),不会冤枉0.0.0.0和255.255.255.255
struct in_addr inaddr;
inet_pton(AF_INET, SERVER_IP, &inaddr);//windows下相当于inet_aton的函数,多了第一个参数表明是ipv4还是ipv6
socketaddr.sin_addr = inaddr;
std::cout << "connecting to ip-" << SERVER_IP << " port-" << MYPORT << std::endl;

///连接服务器,成功返回0,错误返回-1。返回的描述符connfd,该socket包含了服务器ip、port,自己ip、port,可用于发送和接收数据
if (connect(connfd, (struct sockaddr*)&socketaddr, sizeof(socketaddr)) == SOCKET_ERROR)
{
std::cerr << "connect fail !" << std::endl;
return SOCKET_ERROR;
}
std::cout << "connect to server successfully!" << std::endl;

return connfd;
}
/*
注意该函数还没有
closesocket(connfd);和WSACleanup();
我们写一个关闭函数
*/
//调用多少次connect_S,就要调用多少次close_S
void close_S(SOCKET connfd)
{
closesocket(connfd);
WSACleanup();
}

这样我们的接收线程的工作函数的工作逻辑就更清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void recvThread(const char* SERVER_IP, int MYPORT)
{
//连接服务器
SOCKET connfd = connect_S(SERVER_IP,MYPORT);//获取套接字
if(connfd==INVALID_SOCKET || connfd==SOCKET_ERROR)
exit(1);//连接失败退出,错误由connect_S函数输出

//--------------------------工作-----------------------------
char recvbuf[1024];
while(1)
{
memset(recvbuf,0,sizeof(recvbuf));//每次都将buffer清空,防止被上次写入的结果影响
size_t recvbytes = recv(connfd, recvbuf, sizeof(recvbuf),0);//同步接收,是阻塞的
//异常结束时,退出
if(recvbytes<=0)
{
cout<<"The receiving thread exits!"<<endl;
break;
}

cout<<recvbuf<<endl;//endl可以刷新缓冲
}
//退出
close_S(connfd);
}

接收线程的工作大概就是这些,只是简单的打印,因为我们把复杂度交给服务器了。不过,我们考虑下主线程和接收线程之间的关系——它们共享输出缓冲。因此会发生一种情况,在主线程打印出来的提示符会被顶掉,尽管用户仍然可以在主线程输入,但会让用户感觉“坏掉了”,大概像这样:

1
2
cmd> something....recv> .....................
here enter other

因此,除了打印前需要换行,还需要在打印后把提示符再打印出来:

1
cout<<endl<<recvbuf<<endl<<prompt;

然后加入线程,需要头文件<thread><functional>

1
thread(bind(recvThread, SERVER_IP, MYPORT)).detach();

阶段性测试的demo如下,我们把接收线程相关的函数都放在main函数前面,因为已经相当臃肿了,等处理完一并封装。注意这里面有<windows.h><WinSock2.h>,后者一定要在前者前include。

1
2
3
“winsock2.h”定义了_WINSOCKAPI_,原意是不要编译''winsock.h"。
如果"windows.h"在“winsock2.h”前面,那么会先include “winsock.h”编译,后再“winsock2.h”时遇到了重定义。
所以先include “winsock2.h”再include "windows.h"就可以避开这些重定义。

同时,我们进行一点优化,以往打印提示符时没有刷新缓冲区,现在我们把刷新缓冲加上,否则可能会堵住显得卡,像这样cout << prompt << flush;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
//test
#include<iostream>
#include<cstring>//string.h
#include<string>
#include<unordered_map>

#include <thread>
#include<functional>
#include<WinSock2.h>//除了inet_pton
#include <WS2tcpip.h>//inet_pton
#include <windows.h>//Sleep()

#pragma comment(lib,"ws2_32.lib")//链接dll
using namespace std;

string chatSname = "";

const unordered_map<string, int> cmdmap =
{
{"register",0},
{"login",1},
{"search",2},
{"chat",3},
{"accept",4},
{"reject",5},
{"break",6},
{"send",7},
{"sendfile",8},
{"acceptfile",9},
{"rejectfile",10},
{"getfile",11},
{"acceptget",12},
{"rejectget",13},
{"setsid",14},
{"setsname",15},
{"re",16},
{"exit",17},
{"hisir",18},
{"oyasumi",20}
};


enum class clientState
{
noLogin = 0,//未登录状态
cmdLine,//命令行状态
isChatting,//chat状态
isWaiting//等待状态
};

unordered_map<clientState, string> promptMap =
{
{clientState::noLogin,"need login> "},
{clientState::cmdLine,"cmd> "},
{clientState::isChatting,"chatting with "},//进入chat状态要根据对方的sname来拼接
{clientState::isWaiting,"waiting> "}
};


//去掉string首尾空格
void trim(string& s)
{
if (!s.empty())
{
s.erase(0, s.find_first_not_of(" \t"));//首次出现不匹配空格的位置
s.erase(s.find_last_not_of(" \t") + 1);
}
}

//解析命令,cmdstr首位空格都去掉了
vector<string> parse(string cmdstr)
{
//首先看第一个字符是不是@,是的话去掉就好了
if (cmdstr[0] == '@')
cmdstr = cmdstr.substr(1, cmdstr.size() - 1);

//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

return res;

}
clientState state = clientState::noLogin;
string prompt = promptMap[state];
bool checkCmd(int cmdvalue)
{
//最开始只考虑是否登录
if (cmdvalue == 0 || cmdvalue == 1)
{
if (state != clientState::noLogin)
{
cerr << "error> Have signed in yet!" << endl;
return false;
}
}
else
{
if (state == clientState::noLogin)
{
cerr << "error> Not signed in yet!" << endl;
return false;
}
}
//如果正在chatting,不允许再chat
if (state == clientState::isChatting && cmdvalue == 3)
{
cerr << "error> You are chatting with " << chatSname << "! You can break to chat with other." << endl;
return false;
}

//如果正在等待,只允许break
if (state == clientState::isWaiting && cmdvalue != 6)
{
cerr << "error> Only break or @break can input because you are waiting for something!" << endl;
return false;
}

return true;
}



void run()
{

char cmdbuf[128];
char cmdtmp[128] = "no cmd";
bool reflag = false;
cout << prompt << flush;
//根据bool或运算顺序,如果用上一次的结果(reflag==true),就不接收字符
while (reflag || fgets(cmdbuf, sizeof(cmdbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
cout << "send something..." << endl;//test
cout << prompt << flush;
}
else//剩下的内容都是else的
{
string cmdstr(cmdbuf);
trim(cmdstr);

if (cmdstr == "\n")//不管怎么样都有个换行,仅有一个换行就不管
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
vector<string> cmdvec = parse(cmdstr);

//查找命令
auto iter = cmdmap.find(cmdvec[0]);
if (iter == cmdmap.end())//命令错误
{
cerr << "Wrong command, your command parsed is: " << cmdvec[0] << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}

int cmdvalue = iter->second;
if (!checkCmd(cmdvalue))//如果命令与状态不匹配
{
memset(cmdbuf, 0, sizeof(cmdbuf));
reflag = false;//因为可能是调用了re(连续两次login)会导致错误,这里错误要让用户重新输入,否则会崩溃(cmdbuf是空)
cout << prompt << flush;
continue;
}
switch (cmdvalue)
{
case 0:
if (cmdvec.size() != 4)
{
cerr << "error> The number of parameters for [register] is wrong!" << endl;
break;
}
else if (cmdvec[2] != cmdvec[3])
{
cerr << "error> The passwords entered twice are not equal!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 1:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [login] is wrong!" << endl;
break;
}

else
{
//做处理
state = clientState::cmdLine;
prompt = promptMap[state];
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 2:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [search] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 3:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [chat] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input break or @break to back to command line..." << endl;

Sleep(3000);//阻塞3秒,假装等待连接
state = clientState::isChatting;
//chatSname = ...
prompt = promptMap[state] + chatSname + "> ";
break;
}

case 4:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [accept] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 5:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [reject] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 6:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [break] is wrong!" << endl;
break;
}
else
{
//做处理
state = clientState::cmdLine;
prompt = promptMap[state];
break;
}

case 7:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [send] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << " " << cmdvec[2] << endl;
break;
}

case 8:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [sendfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 9:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 10:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectfile] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 11:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [getfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 12:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptget] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 13:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectget] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 14:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsid] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 15:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsname] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 16:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [re] is wrong!" << endl;
reflag = false;//虽然是re,但是命令有问题
break;
}
else//即使是错误命令也存,用户可能头铁...就想再试一次
{
//做处理
if (!strcmp(cmdtmp, "no cmd"))//如果还没有命令
{
cerr << "error> No command yet!" << endl;
}
else
{
cout << "reEX> " << cmdtmp;//不用换行,cmdtmp自然有个'\n'
//strcpy已不可用,不指定长度不安全。使用strlen要+1,因为长度不包含结束符,要补上去
strcpy_s(cmdbuf, strlen(cmdtmp) + 1, cmdtmp);
reflag = true;//表明不必把cmdbuf置零
}
break;
}

case 17:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [exit] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << endl;
break;
}

case 18:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [hisir] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

case 20:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [oyasumi] is wrong!" << endl;
break;
}
else
{
//做处理
cout << "test> " << cmdvec[0] << " " << cmdvec[1] << endl;
break;
}

}
if (cmdvalue == 16 && reflag)//如果是re且成功的话就不用cmd,不用cmdbuf置零
continue;
else if (cmdvalue == 16)//虽然是re但失败了,不能保存re
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}
else
{
strcpy_s(cmdtmp, strlen(cmdbuf) + 1, cmdbuf);//暂存上一次命令,方便re调用,如果上一次是re的话,不更新
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
reflag = false;//上一次不是re自然置false
}
}


}
}
SOCKET connect_S(const char* SERVER_IP, int MYPORT)//返回连接句柄
{
//初始化WSA
WORD sockVersion = MAKEWORD(2, 2);
WSADATA wsaData;//WSADATA结构体变量的地址值

//int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
//成功时会返回0,失败时返回非零的错误代码值
if (WSAStartup(sockVersion, &wsaData) != 0)
{
std::cerr << "WSAStartup() error!" << std::endl;
return INVALID_SOCKET;
}

//创建套接字
SOCKET connfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (connfd == INVALID_SOCKET)
{
std::cerr << "socket error !" << std::endl;
return INVALID_SOCKET;
}

//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(MYPORT);//服务器端口,自己连接后的端口是os分配的,由进程选择一个端口去连服务器
//socketaddr.sin_addr.s_addr = inet_addr(SERVER_IP); ///服务器ip
//inet_addr最好换成inet_aton(),不会冤枉0.0.0.0和255.255.255.255
struct in_addr inaddr;
inet_pton(AF_INET, SERVER_IP, &inaddr);//windows下相当于inet_aton的函数,多了第一个参数表明是ipv4还是ipv6
socketaddr.sin_addr = inaddr;

///连接服务器,成功返回0,错误返回-1。返回的描述符connfd,该socket包含了服务器ip、port,自己ip、port,可用于发送和接收数据
if (connect(connfd, (struct sockaddr*)&socketaddr, sizeof(socketaddr)) == SOCKET_ERROR)
{
std::cerr << "connect fail !" << std::endl;
return SOCKET_ERROR;
}
return connfd;
}
/*
注意该函数还没有
closesocket(connfd);和WSACleanup();
我们写一个关闭函数
*/
//调用多少次connect_S,就要调用多少次close_S
void close_S(SOCKET connfd)
{
closesocket(connfd);
WSACleanup();
}
void recvThread(const char* SERVER_IP, int MYPORT)
{
//连接服务器
SOCKET connfd = connect_S(SERVER_IP, MYPORT);//获取套接字
if (connfd == INVALID_SOCKET || connfd == SOCKET_ERROR)
exit(1);//连接失败退出,错误由connect_S函数输出

//--------------------------工作-----------------------------
char recvbuf[1024];
while (1)//后面会设置exitflag
{
memset(recvbuf, 0, sizeof(recvbuf));//每次都将buffer清空,防止被上次写入的结果影响
size_t recvbytes = recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
//异常结束时,退出
if (recvbytes <= 0)
{
cout << endl << "The receiving thread exits!" << endl << prompt << flush;
break;
}

cout << endl << recvbuf << endl << prompt << flush;
}
//退出
close_S(connfd);
}
const char* SERVER_IP = "192.168.248.131";
int MYPORT = 8000;

int main()
{
thread(bind(recvThread, SERVER_IP, MYPORT)).detach();
run();
return 0;
}

简单写一个linux服务器发送数据测试,有了这台服务器,我们之后就可以模拟响应往下做了,尽管我们现在是手动在服务器上发送数据。

服务器发送exit就可以退出,接收线程也会因此退出,我们现在可以先放着,不去管它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//server
#include <sys/socket.h>
#include <netinet/in.h>//sockaddr_in
#include <arpa/inet.h>//in_addr
#include <string.h>
#include <iostream>//cerr
#include <unistd.h>//close
#define myport 8000


int main()
{
//定义socketfd,它要绑定监听的网卡地址和端口
int listenfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);//第三个参数写0也可以,这里表示创建tcp套接字

//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(myport);//字节序转换
socketaddr.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY表示监听所有网卡地址,0.0.0.0;
//因为路由的关系,从客户端来的IP包只可能到达其中一个网卡。指定了网卡地址的话,必须从相应的地址进入才能连接到port
//#define INADDR_ANY ((in_addr_t) 0x00000000)

//绑定套接字和地址端口信息,sockaddr_in转成sockaddr
if(bind(listenfd,(struct sockaddr *)&socketaddr,sizeof(socketaddr))==-1)
{
std::cerr<<"bind"<<std::endl;
exit(1);
//cerr不经过缓冲而直接输出,一般用于迅速输出出错信息,是标准错误,默认情况下被关联到标准输出流,但它不被缓冲.
//也就说错误消息可以直接发送到显示器,而无需等到缓冲区或者新的换行符时,才被显示。
}
std::cout<<"listen socket port: "<<myport<<std::endl;

//开始监听
if(listen(listenfd,SOMAXCONN) == -1)
{
std::cerr<<"listen"<<std::endl;
exit(1);
}

///客户端套接字
char buffer[1024];
struct sockaddr_in client_addr;//获取客户的地址和端口号,连接后的不分配新端口
socklen_t len = sizeof(client_addr);//socklen_t 相当于 int,但使用int必须强制转型告知编译器
std::cout<<"wating for conn..."<<std::endl;

int conn = accept(listenfd, (struct sockaddr*)&client_addr, &len);//阻塞,等待连接,成功则创建连接套接字conn描述这个用户
if(conn==-1)
{
std::cerr<<"connect"<<std::endl;
exit(1);
}
/*
如果队列中没有等待的连接,套接字也没有被标记为Non-blocking,accept()会阻塞调用函数直到连接出现;
如果套接字被标记为Non-blocking,队列中也没有等待的连接,accept()返回错误EAGAIN或EWOULDBLOCK。
*/
std::cout<<"conn successfully: port-"<<ntohs(client_addr.sin_port)<<" ip-"<<inet_ntoa(client_addr.sin_addr)<<std::endl<<std::endl;
char sendbuf[1024];
//gets:从标准输入 stdin 读取一行,并把它存储在 str 所指向的字符串中。当读取到换行符时停止
//如果成功,该函数返回 str。如果发生错误或者到达文件末尾时还未读取任何字符,则返回 NULL。
//等价于fgets(sendbuf, sizeof(sendbuf), stdin)

std::cout<<"send> ";
while (fgets(sendbuf, sizeof(sendbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
std::cout<<"send to client: "<<sendbuf;//不用换行fgets包含了\n
send(conn, sendbuf, strlen(sendbuf)-1,0); ///\n is ignored
if(strcmp(sendbuf,"exit\n")==0)
break;

bzero(sendbuf,sizeof(sendbuf));

std::cout<<"send> ";
}

close(conn);
close(listenfd);
return 0;
}

命令发送与接收

现在我们来处理主线程的命令发送,基本上就是处理run方法,不过注意,在main调用run前,我们先进行连接,然后把套接字传给run(),大概像这样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
int main()
{
//连接服务器
SOCKET connfd = connect_S(SERVER_IP, MYPORT);//获取套接字
if (connfd == INVALID_SOCKET || connfd == SOCKET_ERROR)
exit(1);//连接失败退出,错误由connect_S函数输出

run(connfd);

//退出
close_S(connfd);
return 0;
}

现在我们对run函数作修改,注意一个前提,进入switch内的else的部分,都是可以进行执行的正确的命令,这些命令可以发送给服务器。我们对这些部分进行send和recv处理。关于recv,在主线程中是阻塞我们的输入的,但是当我们在waiting时,还是能够输入break来退出等待(这是必要的),这就不能阻塞了,因此对于waiting的这部分,接收放到接收线程处理。

为什么说break是必要的呢?我们实际上可以认为服务器是不会让用户等(或者说等太久)的,但对等方是可能让本地用户等很久的(可能由于意外、可能由于心理道德),这就需要允许用户自主退出,尽管这会增加许多复杂度。


对于那些要接收回复的命令,我们在此探讨一下它们要接收什么。

  • register
    • 注册正确还是错误:success | failure
    • 如果是success,在本地处理并返回注册正确的信息;
    • 如果是failure,在本地处理并返回注册失败的信息,由于我们判断了密码相不相同了,这必然是用户ID已被使用了;
  • login
    • 如果是success,在本地处理并返回登录成功的信息,并进入命令行;
    • 如果是failure,可能是密码错了或者根本没有用户ID,这时服务器不返回failure,而是返回相关的错误信息,直接打印即可;
  • search
    • 如果是failure,本地处理并返回不存在该sid
    • 否则成功,服务器直接发回sname和是否在chat的信息,直接打印
  • chat
    • 服务器返回对方是否接受的信息,为了区别格式(命令与命令之间,命令与聊天信息之间),服务器会发送这样的信息:”@#chat accept”、”@#chat reject”
    • 然后会进行状态的更新
  • accept
    • 暂不考虑交互,但可能因为时延影响,后续也许涉及交互的认证
  • reject
    • 与accept一样
  • break
    • 与accept一样
  • send
    • 这里降低复杂度,如果对方sid不存在也不返回消息,所以需要用户先search
  • sendfile
    • 服务器返回对方是否接受的信息,为了区别格式,服务器会发送这样的信息:”@#sendfile accept”、”@#sendfile reject”
  • acceptfile
    • 与accept一样
  • rejectfile
    • 与accept一样
  • getfile
    • 当对方acceptget时,会给本地用户一个资源表,因此服务器发回来的就是这样一个资源表。
    • 那么服务器返回的消息像这样:”@#getfile reject”、”@#getfile accept resource_list”
  • acceptget
    • 不阻塞直接可以做别的事,即一定允许发送,接收线程处理对方选择,服务器返回这样一条信息:”@#choosefile [number]”
  • rejectget
    • 与accept一样
  • setsid
    • 返回成功和失败信息,直接打印
  • setsname
    • 返回成功和失败信息,直接打印
  • re
    • 不返回
  • exit
    • 不返回
  • hisir
    • 返回一句话,直接打印
  • oyasumi
    • 会返回一句晚安消息(随机选择),然后打印

有些返回是直接打印的,而有些返回需要进行解析,比如login;而那些accept之类的,因为主线程是在waiting状态,那就让接收线程解析。

当用户在选择文件时,要键入输入,为了不和主线程的输入冲突,我们就在主线程输入。为了不增加状态(比如目前在选择文件状态),我们只需要增加一个选择文件flag就好了,如果是true的话表明这个输入用于选择文件。发送的命令是:choosefile [number],下面的run先不对此做修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
bool chooseflag = false;
bool isNumber(const char* buf)//判断字符传是否为数字
{
for(int i=0;buf[i]!='\n';i++)//以\n结尾
if(buf[i]<'0'||buf[i]>'9')//每个字符都要是数字
return false;
return true;
}
string chattarg = "";
void run(SOCKET connfd)
{
//一个汉字两字节,可能需要更大的buf
char cmdbuf[256];
char cmdtmp[256] = "no cmd";
char recvbuf[256];
bool reflag = false;
bool exitflag = false;//函数退出信号
cout << prompt << flush;
//根据bool或运算顺序,如果用上一次的结果(reflag==true),就不接收字符
while (reflag || fgets(cmdbuf, sizeof(cmdbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
if(chooseflag)//如果在选择文件。接收线程会把这个flag改成true,并且打印提示符enter file number>
{
if(isNumber(cmdbuf))
{
//要进行数字的范围判断,用stoi
int num = stoi(cmdbuf);
if(num>fileNumber || num<1)
{
cerr << "Please enter a number in the range!" << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt<< flush;
continue;
}
send(connfd, cmdbuf, sizeof(cmdbuf),0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
//...还需要用一个线程来接收
cout << "Downloading..." << endl;
chooseflag = false;
state = clientState::cmdLine;
prompt = promptMap[state];
cout << prompt << flush;
continue;
}

else
{
cerr << "Please enter a number!" << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt<< flush;
continue;
}
}
if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
send(connfd, cmdbuf, sizeof(cmdbuf),0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}
else//剩下的内容都是else的
{
memset(recvbuf, 0, sizeof(recvbuf));//把接收缓冲清零
string cmdstr(cmdbuf);
trim(cmdstr);

if (cmdstr == "\n")//不管怎么样都有个换行,仅有一个换行就不管
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
vector<string> cmdvec = parse(cmdstr);

//查找命令
auto iter = cmdmap.find(cmdvec[0]);
if (iter == cmdmap.end())//命令错误
{
cerr << "Wrong command, your command parsed is: " << cmdvec[0] << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}

int cmdvalue = iter->second;
if (!checkCmd(cmdvalue))//如果命令与状态不匹配
{
memset(cmdbuf, 0, sizeof(cmdbuf));
reflag = false;//因为可能是调用了re(连续两次login)会导致错误,这里错误要让用户重新输入,否则会崩溃(cmdbuf是空)
cout << prompt << flush;
continue;
}
switch (cmdvalue)
{
case 0:
if (cmdvec.size() != 4)
{
cerr << "error> The number of parameters for [register] is wrong!" << endl;
break;
}
else if (cmdvec[2] != cmdvec[3])
{
cerr << "error> The passwords entered twice are not equal!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
if(strcmp(recvbuf, "success")==0)
cout << "The registration is successful and you can log in!" << endl;
else
cout << "Registration failed, please try a different ID!" << endl;

break;
}

case 1:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [login] is wrong!" << endl;
break;
}

else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的

if(strcmp(recvbuf, "success")==0)
{
cout << "Login successfully!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cout << recvbuf <<endl;
}
break;
}

case 2:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [search] is wrong!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
if(strcmp(recvbuf, "faliure")==0)
{
cout << "sid does not exist!" << endl;
}
else
{
cout << recvbuf <<endl;
}
break;
}

case 3:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [chat] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送

state = clientState::isWaiting;
prompt = promptMap[state];
chattarg = cmdvec[1];
cout << "waiting> Waiting for a response, you can input break or @break to back to command line..." << endl;

//响应在接收线程处理!
/*
Sleep(3000);//阻塞3秒,假装等待连接
state = clientState::isChatting;
//chatSname = ...
prompt = promptMap[state] + chatSname + "> ";
*/
break;
}

case 4:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [accept] is wrong!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
break;
}

case 5:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [reject] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
break;
}

case 6:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [break] is wrong!" << endl;
break;
}
else
{
//做处理,break也需要发送,这样服务器才能知道用户从某请求的等待中退出了
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
state = clientState::cmdLine;
prompt = promptMap[state];
break;
}

case 7:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [send] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
break;
}

case 8:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [sendfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 9:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptfile] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
//...还需要用一个线程来接收,文件名通过请求表维护
break;
}

case 10:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectfile] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
break;
}

case 11:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [getfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 12:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptget] is wrong!" << endl;
break;
}
else
{
//做处理
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
break;
}

case 13:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectget] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
break;
}

case 14:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsid] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
//需要接收响应,因为sid是全局唯一的,可能设置失败!
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf <<endl;
break;
}

case 15:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsname] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
//这里接收响应是为了增加用户体验,服务器返回一个成功信息
//因为设置相对频率较少,这不是什么很大的负担
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf <<endl;
break;
}

case 16:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [re] is wrong!" << endl;
reflag = false;//虽然是re,但是命令有问题
break;
}
else//即使是错误命令也存,用户可能头铁...就想再试一次
{
//做处理
if (!strcmp(cmdtmp, "no cmd"))//如果还没有命令
{
cerr << "error> No command yet!" << endl;
}
else
{
cout << "reEX> " << cmdtmp;//不用换行,cmdtmp自然有个'\n'
//strcpy已不可用,不指定长度不安全。使用strlen要+1,因为长度不包含结束符,要补上去
strcpy_s(cmdbuf, strlen(cmdtmp) + 1, cmdtmp);
reflag = true;//表明不必把cmdbuf置零
}
break;
}

case 17:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [exit] is wrong!" << endl;
break;
}
else
{
//做处理,或许需要告知服务器退出,先这样
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
exitflag = true;
cout << "The client now exits!" <<endl;
break;
}

case 18:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [hisir] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf <<endl;
break;
}

case 20:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [oyasumi] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()),0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf <<endl;
exitflag = true;
break;
}

}

//是否退出
if(exitflag)
break;

if (cmdvalue == 16 && reflag)//如果是re且成功的话就不用cmd,不用cmdbuf置零
continue;
else if (cmdvalue == 16)//虽然是re但失败了,不能保存re
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}
else
{
strcpy_s(cmdtmp, strlen(cmdbuf) + 1, cmdbuf);//暂存上一次命令,方便re调用,如果上一次是re的话,不更新
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
reflag = false;//上一次不是re自然置false
}
}


}
}

解析接收命令

进一步丰富接收线程的接收函数,可以借助前面的解析函数parse

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
int fileNumber = 0;//全局变量,用于对方acceptget后选择文件的最大数量
unordered_map<int,string> getResourceMap;
//解析命令,返回解析成不成功,失败则false
bool parseRecv(string cmdrecv)
{
//最后加一个空格,这样能解析成功
cmdrecv += " ";
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdrecv.find(' ', pos)) != string::npos)
{
res.push_back(cmdrecv.substr(pos, pos1 - pos));
while (cmdrecv[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

if(res.size()<2)
{
cerr << endl <<"An error command was received in the receThread!" <<endl;
return false;
}

if(res[0] == "chat" && state == clientState::isWaiting)//必须还在等待
{
if(res[1] == "accept")
{
cout << endl <<"The peer agrees to chat!" <<endl;
state = clientState::isChatting;
prompt = promptMap[state]+ chattarg+"> ";
}
else if(res[1] == "reject")
{
cout << endl <<"The peer disagrees to chat!" <<endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl <<"An error command was received in the receThread!" <<endl;
return false;
}
}
else if(res[0] == "sendfile" && state == clientState::isWaiting)
{
if(res[1] == "accept")
{
cout << endl <<"The peer agrees to receive file!" <<endl;
state = clientState::cmdLine;
prompt = promptMap[state];
//开线程
}
else if(res[1] == "reject")
{
cout << endl <<"The peer disagrees to receive file!" <<endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl <<"An error command was received in the receThread!" <<endl;
return false;
}
}
else if(res[0] == "getfile" && state == clientState::isWaiting)
{
if(res[1] == "accept" && res[2] == "none")//如果没有资源但同意了,第三个参数就是none
{
cerr << endl <<"The peer has no resources!" <<endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else if(res[1] == "accept")
{
chooseflag = true;
prompt = "enter the number of file> ";
cout << endl <<"The peer agrees to getfile!" <<endl;
getResourceMap.clear();//清空
fileNumber = 0;//重置
string resolist;//打印信息
//假设对方发送的文件资源表是[1] some.txt [2] some2.txt,即空格分隔的成对的
for(int i=2;i<res.size();i+=2)
{
fileNumber++;
getResourceMap[fileNumber] = res[i+1];
resolist += res[i]+"\t"+res[i+1]+"\n";
}
cout << resolist <<flush;


}
else if(res[1] == "reject")
{
cout << endl <<"The peer doesn't allow you to get the resource!" <<endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl <<"An error command was received in the receThread!" <<endl;
return false;
}
}
else if(res[0] == "choosefile")
{
int num = stoi(res[1]);
cout << endl <<"The peer requested the ["+res[1]+"] file!" <<endl;
//开一个线程发送
}
else
return false;
return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void recvThread(const char* SERVER_IP, int MYPORT)
{
//连接服务器
SOCKET connfd = connect_S(SERVER_IP, MYPORT);//获取套接字
if (connfd == INVALID_SOCKET || connfd == SOCKET_ERROR)
exit(1);//连接失败退出,错误由connect_S函数输出

//--------------------------工作-----------------------------
char recvbuf[1024];
while (1)//后面会设置exitflag
{
memset(recvbuf, 0, sizeof(recvbuf));//每次都将buffer清空,防止被上次写入的结果影响
size_t recvbytes = recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
//异常结束时,退出
if (recvbytes <= 0)
{
cout << endl << "The receiving thread exits!" << endl << prompt << flush;
break;
}

if(recvbytes >= 3 && recvbuf[0]=='@' && recvbuf[1]=='#')//这种情况下要解析命令
{
string cmdrecv = recvbuf;
if(parseRecv(cmdrecv.substr(2,cmdrecv.size()-2)))
cout << prompt << flush;//失败就不打印刷新
}
else//其他信息直接输出
{
cout << endl << recvbuf << endl;
cout << prompt << flush;
}

}
//退出
close_S(connfd);
}

测试

需要两个服务器来模拟输入输出,代码不贴了(感觉没人能follow到这…写着写着发现太抽象了)。

不过测试发现挺好的,测了好久关了忘记截图了,不想再测了,因为手敲服务器的响应有点麻烦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
//test
#include<iostream>
#include<cstring>//string.h
#include<string>
#include<unordered_map>

#include <thread>
#include<functional>
#include<WinSock2.h>//除了inet_pton
#include <WS2tcpip.h>//inet_pton
#include <windows.h>//Sleep()

#pragma comment(lib,"ws2_32.lib")//链接dll
using namespace std;

string chatSname = "";
int fileNumber = 0;//全局变量,用于对方acceptget后选择文件的最大数量
unordered_map<int, string> getResourceMap;
const unordered_map<string, int> cmdmap =
{
{"register",0},
{"login",1},
{"search",2},
{"chat",3},
{"accept",4},
{"reject",5},
{"break",6},
{"send",7},
{"sendfile",8},
{"acceptfile",9},
{"rejectfile",10},
{"getfile",11},
{"acceptget",12},
{"rejectget",13},
{"setsid",14},
{"setsname",15},
{"re",16},
{"exit",17},
{"hisir",18},
{"oyasumi",20}
};


enum class clientState
{
noLogin = 0,//未登录状态
cmdLine,//命令行状态
isChatting,//chat状态
isWaiting//等待状态
};

unordered_map<clientState, string> promptMap =
{
{clientState::noLogin,"need login> "},
{clientState::cmdLine,"cmd> "},
{clientState::isChatting,"chatting with "},//进入chat状态要根据对方的sname来拼接
{clientState::isWaiting,"waiting> "}
};


//去掉string首尾空格
void trim(string& s)
{
if (!s.empty())
{
s.erase(0, s.find_first_not_of(" \t"));//首次出现不匹配空格的位置
s.erase(s.find_last_not_of(" \t") + 1);
}
}

//解析命令,cmdstr首位空格都去掉了
vector<string> parse(string cmdstr)
{
//首先看第一个字符是不是@,是的话去掉就好了
if (cmdstr[0] == '@')
cmdstr = cmdstr.substr(1, cmdstr.size() - 1);

//对于一个关键字的命令,无法用空格分割,考虑到最后一定有个\n是没用的,因此把\n改为空格,一举两得
cmdstr[cmdstr.size() - 1] = ' ';
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdstr.find(' ', pos)) != string::npos)
{
res.push_back(cmdstr.substr(pos, pos1 - pos));
while (cmdstr[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

return res;

}
clientState state = clientState::noLogin;
string prompt = promptMap[state];
string chattarg = "";
bool chooseflag = false;
bool checkCmd(int cmdvalue)
{
//最开始只考虑是否登录
if (cmdvalue == 0 || cmdvalue == 1)
{
if (state != clientState::noLogin)
{
cerr << "error> Have signed in yet!" << endl;
return false;
}
}
else
{
if (state == clientState::noLogin)
{
cerr << "error> Not signed in yet!" << endl;
return false;
}
}
//如果正在chatting,不允许再chat
if (state == clientState::isChatting && cmdvalue == 3)
{
cerr << "error> You are chatting with " << chatSname << "! You can break to chat with other." << endl;
return false;
}

//如果正在等待,只允许break
if (state == clientState::isWaiting && cmdvalue != 6)
{
cerr << "error> Only break or @break can input because you are waiting for something!" << endl;
return false;
}

return true;
}



bool isNumber(const char* buf)//判断字符传是否为数字
{
for (int i = 0; buf[i] != '\n'; i++)//以\n结尾
if (buf[i] < '0' || buf[i]>'9')//每个字符都要是数字
return false;
return true;
}

void run(SOCKET connfd)
{
//一个汉字两字节,可能需要更大的buf
char cmdbuf[256];
char cmdtmp[256] = "no cmd";
char recvbuf[256];
bool reflag = false;
bool exitflag = false;//函数退出信号
cout << prompt << flush;
//根据bool或运算顺序,如果用上一次的结果(reflag==true),就不接收字符
while (reflag || fgets(cmdbuf, sizeof(cmdbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
if (chooseflag)//如果在选择文件。接收线程会把这个flag改成true,并且打印提示符enter file number>
{
if (isNumber(cmdbuf))
{
//要进行数字的范围判断,用stoi
int num = stoi(cmdbuf);
if (num > fileNumber || num < 1)
{
cerr << "Please enter a number in the range!" << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
send(connfd, cmdbuf, sizeof(cmdbuf), 0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
//...还需要用一个线程来接收

chooseflag = false;
state = clientState::cmdLine;
prompt = promptMap[state];
cout << prompt << flush;
continue;
}

else
{
cerr << "Please enter a number!" << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
}
if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
send(connfd, cmdbuf, sizeof(cmdbuf), 0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}
else//剩下的内容都是else的
{
memset(recvbuf, 0, sizeof(recvbuf));//把接收缓冲清零
string cmdstr(cmdbuf);
trim(cmdstr);

if (cmdstr == "\n")//不管怎么样都有个换行,仅有一个换行就不管
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
vector<string> cmdvec = parse(cmdstr);

//查找命令
auto iter = cmdmap.find(cmdvec[0]);
if (iter == cmdmap.end())//命令错误
{
cerr << "Wrong command, your command parsed is: " << cmdvec[0] << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}

int cmdvalue = iter->second;
if (!checkCmd(cmdvalue))//如果命令与状态不匹配
{
memset(cmdbuf, 0, sizeof(cmdbuf));
reflag = false;//因为可能是调用了re(连续两次login)会导致错误,这里错误要让用户重新输入,否则会崩溃(cmdbuf是空)
cout << prompt << flush;
continue;
}
switch (cmdvalue)
{
case 0:
if (cmdvec.size() != 4)
{
cerr << "error> The number of parameters for [register] is wrong!" << endl;
break;
}
else if (cmdvec[2] != cmdvec[3])
{
cerr << "error> The passwords entered twice are not equal!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
if (strcmp(recvbuf, "success") == 0)
cout << "The registration is successful and you can log in!" << endl;
else
cout << "Registration failed, please try a different ID!" << endl;

break;
}

case 1:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [login] is wrong!" << endl;
break;
}

else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的

if (strcmp(recvbuf, "success") == 0)
{
cout << "Login successfully!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cout << recvbuf << endl;
}
break;
}

case 2:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [search] is wrong!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
if (strcmp(recvbuf, "faliure") == 0)
{
cout << "sid does not exist!" << endl;
}
else
{
cout << recvbuf << endl;
}
break;
}

case 3:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [chat] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送

state = clientState::isWaiting;
prompt = promptMap[state];
chattarg = cmdvec[1];
cout << "waiting> Waiting for a response, you can input break or @break to back to command line..." << endl;

//响应在接收线程处理!
/*
Sleep(3000);//阻塞3秒,假装等待连接
state = clientState::isChatting;
//chatSname = ...
prompt = promptMap[state] + chatSname + "> ";
*/
break;
}

case 4:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [accept] is wrong!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
break;
}

case 5:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [reject] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
break;
}

case 6:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [break] is wrong!" << endl;
break;
}
else
{
//做处理,break也需要发送,这样服务器才能知道用户从某请求的等待中退出了
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
state = clientState::cmdLine;
prompt = promptMap[state];
break;
}

case 7:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [send] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
break;
}

case 8:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [sendfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 9:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptfile] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
//...还需要用一个线程来接收,文件名通过请求表维护
break;
}

case 10:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectfile] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
break;
}

case 11:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [getfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 12:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptget] is wrong!" << endl;
break;
}
else
{
//做处理
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
break;
}

case 13:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectget] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
break;
}

case 14:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsid] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
//需要接收响应,因为sid是全局唯一的,可能设置失败!
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
break;
}

case 15:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsname] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
//这里接收响应是为了增加用户体验,服务器返回一个成功信息
//因为设置相对频率较少,这不是什么很大的负担
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
break;
}

case 16:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [re] is wrong!" << endl;
reflag = false;//虽然是re,但是命令有问题
break;
}
else//即使是错误命令也存,用户可能头铁...就想再试一次
{
//做处理
if (!strcmp(cmdtmp, "no cmd"))//如果还没有命令
{
cerr << "error> No command yet!" << endl;
}
else
{
cout << "reEX> " << cmdtmp;//不用换行,cmdtmp自然有个'\n'
//strcpy已不可用,不指定长度不安全。使用strlen要+1,因为长度不包含结束符,要补上去
strcpy_s(cmdbuf, strlen(cmdtmp) + 1, cmdtmp);
reflag = true;//表明不必把cmdbuf置零
}
break;
}

case 17:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [exit] is wrong!" << endl;
break;
}
else
{
//做处理,或许需要告知服务器退出,先这样
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
exitflag = true;
cout << "The client now exits!" << endl;
break;
}

case 18:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [hisir] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
break;
}

case 20:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [oyasumi] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
exitflag = true;
break;
}

}

//是否退出
if (exitflag)
break;

if (cmdvalue == 16 && reflag)//如果是re且成功的话就不用cmd,不用cmdbuf置零
continue;
else if (cmdvalue == 16)//虽然是re但失败了,不能保存re
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}
else
{
strcpy_s(cmdtmp, strlen(cmdbuf) + 1, cmdbuf);//暂存上一次命令,方便re调用,如果上一次是re的话,不更新
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
reflag = false;//上一次不是re自然置false
}
}


}
}

SOCKET connect_S(const char* SERVER_IP, int MYPORT)//返回连接句柄
{
//初始化WSA
WORD sockVersion = MAKEWORD(2, 2);
WSADATA wsaData;//WSADATA结构体变量的地址值

//int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
//成功时会返回0,失败时返回非零的错误代码值
if (WSAStartup(sockVersion, &wsaData) != 0)
{
std::cerr << "WSAStartup() error!" << std::endl;
return INVALID_SOCKET;
}

//创建套接字
SOCKET connfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (connfd == INVALID_SOCKET)
{
std::cerr << "socket error !" << std::endl;
return INVALID_SOCKET;
}

//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(MYPORT);//服务器端口,自己连接后的端口是os分配的,由进程选择一个端口去连服务器
//socketaddr.sin_addr.s_addr = inet_addr(SERVER_IP); ///服务器ip
//inet_addr最好换成inet_aton(),不会冤枉0.0.0.0和255.255.255.255
struct in_addr inaddr;
inet_pton(AF_INET, SERVER_IP, &inaddr);//windows下相当于inet_aton的函数,多了第一个参数表明是ipv4还是ipv6
socketaddr.sin_addr = inaddr;

///连接服务器,成功返回0,错误返回-1。返回的描述符connfd,该socket包含了服务器ip、port,自己ip、port,可用于发送和接收数据
if (connect(connfd, (struct sockaddr*)&socketaddr, sizeof(socketaddr)) == SOCKET_ERROR)
{
std::cerr << "connect fail !" << std::endl;
return SOCKET_ERROR;
}
return connfd;
}
/*
注意该函数还没有
closesocket(connfd);和WSACleanup();
我们写一个关闭函数
*/
//调用多少次connect_S,就要调用多少次close_S
void close_S(SOCKET connfd)
{
closesocket(connfd);
WSACleanup();
}

//解析命令,返回解析成不成功,失败则false
bool parseRecv(string cmdrecv)
{
//最后加一个空格,这样能解析成功
cmdrecv += " ";
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdrecv.find(' ', pos)) != string::npos)
{
res.push_back(cmdrecv.substr(pos, pos1 - pos));
while (cmdrecv[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

if (res.size() < 2)
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}

if (res[0] == "chat" && state == clientState::isWaiting)//必须还在等待
{
if (res[1] == "accept")
{
cout << endl << "The peer agrees to chat!" << endl;
state = clientState::isChatting;
prompt = promptMap[state] + chattarg + "> ";
}
else if (res[1] == "reject")
{
cout << endl << "The peer disagrees to chat!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}
}
else if (res[0] == "sendfile" && state == clientState::isWaiting)
{
if (res[1] == "accept")
{
cout << endl << "The peer agrees to receive file!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
//开线程
}
else if (res[1] == "reject")
{
cout << endl << "The peer disagrees to receive file!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}
}
else if (res[0] == "getfile" && state == clientState::isWaiting)
{
if (res[1] == "accept" && res[2] == "none")//如果没有资源但同意了,第三个参数就是none
{
cerr << endl << "The peer has no resources!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else if (res[1] == "accept")
{
chooseflag = true;
prompt = "enter the number of file> ";
cout << endl << "The peer agrees to getfile!" << endl;
getResourceMap.clear();//清空
fileNumber = 0;//重置
string resolist;//打印信息
//假设对方发送的文件资源表是[1] some.txt [2] some2.txt,即空格分隔的成对的
for (int i = 2; i < res.size(); i += 2)
{
fileNumber++;
getResourceMap[fileNumber] = res[i + 1];
resolist += res[i] + "\t" + res[i + 1] + "\n";
}
cout << resolist << flush;


}
else if (res[1] == "reject")
{
cout << endl << "The peer doesn't allow you to get the resource!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}
}
else if (res[0] == "choosefile")
{
int num = stoi(res[1]);
cout << endl << "The peer requested the [" + res[1] + "] file!" << endl;
//开一个线程发送
}
else
return false;
return true;
}

void recvThread(const char* SERVER_IP, int MYPORT)
{
//连接服务器
SOCKET connfd = connect_S(SERVER_IP, MYPORT);//获取套接字
if (connfd == INVALID_SOCKET || connfd == SOCKET_ERROR)
exit(1);//连接失败退出,错误由connect_S函数输出

//--------------------------工作-----------------------------
char recvbuf[1024];
while (1)//后面会设置exitflag
{
memset(recvbuf, 0, sizeof(recvbuf));//每次都将buffer清空,防止被上次写入的结果影响
size_t recvbytes = recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
//异常结束时,退出
if (recvbytes <= 0)
{
cout << endl << "The receiving thread exits!" << endl << prompt << flush;
break;
}

if (recvbytes >= 3 && recvbuf[0] == '@' && recvbuf[1] == '#')//这种情况下要解析命令
{
string cmdrecv = recvbuf;
if (parseRecv(cmdrecv.substr(2, cmdrecv.size() - 2)))
cout << prompt << flush;//失败就不打印刷新
}
else//其他信息直接输出
{
cout << endl << recvbuf << endl;
cout << prompt << flush;
}



}
//退出
close_S(connfd);
}


const char* SERVER_IP = "192.168.248.131";
int MYPORT = 8000;
int MYPORT2 = 9000;
int main()
{
thread(bind(recvThread, SERVER_IP, MYPORT)).detach();
//连接服务器
SOCKET connfd = connect_S(SERVER_IP, MYPORT2);//获取套接字
run(connfd);
//退出
close_S(connfd);
return 0;
}

请求表设计

前面提到,我们总要考虑用户“无故”输入“无效”命令的情况(往最坏的情况上考虑),当用户没有被请求时,用户可能输入accept和reject这类命令,此时是无效的。为了快速响应以及降低服务器的复杂度,我们将这类无效命令在本地判断,因此需要一个请求表维护到来的请求(chat、sendfile、getfile)。

  • 当接收线程接收到一个请求时,继续解析以获得sid。比如对方发送一个chat sid时,服务器的命令接收端会得到请求方的sid等信息,然后把命令发给该sid的接收线程,格式像这样:@#chatfrom [sid] [sname]。只需要把该sid获取然后添加到请求表内即可。对于getfile:@#getfilefrom [sid] [sname];对于sendfile:@#sendfilefrom [sid] [sname] [filename]
  • 当用户accept时,查表,只有存在才允许把该命令发送到服务器,然后服务器会发一个@#chat accept给对方,就完成了。reject同理。并且accept和reject后,都要把请求表内对应的表项删除。
  • 当对方请求到来时,本地会更新请求表添加表项;然而对方用户可能在等待状态break,这时要通知本地对方取消请求并删除请求表项。但是在前面我们对用户的break命令仅仅只是发送了一个break命令,我们还需要做更多的处理。
    • 我们选择在服务器端处理,服务器是可以得知用户在什么状态的。在noLogin状态不允许break;cmdLine状态下,break是无效的。服务器真正要区分的是用户在waiting还是在chatting。因此,当用户在cmdLine调用break时,不做处理。
    • 当用户发送chat、sendfile、getfile后,服务器在转发信息时标记用户进入waiting状态。注意我们的设计,在waiting时只能break或等待,因此用户在同一时间最多只允许有一个请求。一旦用户在waiting时发送一个break,服务器在接收后会把用户标记回cmdLine状态,并且服务器还需要知道这个break发回给谁,因此服务器在标记用户进入wating时还必须添加用户之间的映射,通过这个映射把@#break [sid]发过去,并随后把这个映射删除。一旦对方接收到这个回复,就从请求表中把该sid对应的表项删除。
    • 如果accept了一个chatting请求,服务器会标记双方进入chatting状态。此时如果某一方break,服务器通过映射找到另一方,发一个@#break now,对方接收后从chatting状态退出即可。
    • 我们再考虑两种情况:
      • 当用户在waiting状态下break,服务器标记用户回到cmdLine,而对方还没有接收到这个消息就accept了,由于用户已经回到cmdLine状态了,这个accept是失效的,因此服务器通过cmdLine状态得知这一事件,向accept一方发回一个@#break now,让其退回命令行状态(如果是chatting的话)并打印”the peers break before you accept”。如果是reject就不管
      • 当用户在waiting状态下break,服务器还没标记用户回到cmdLine,对方就发了accept,此时由于服务器发现用户还在waiting,就把accept转过去了,但用户已经回到命令行了。在接收线程里,如果用户不在等待状态则accept是无效的,因此只用考虑向accept发送处理。服务器把accept转过去后,标记的用户的状态要么返回cmdLine(sendfile这些被accept就标记为cmdLine),要么进入chatting状态。然后服务器又收到break信息,前面说到在chatting状态收到break,服务器发一个@#break now。则如果accept方目前在chatting,也可以退出;否则用户应该在cmdLine状态,这样收到@#break now,给accept方打印一个信息”the peers break before you accept”。
  • 因为用户最多一个请求,则**三个请求命令可以共用一个请求表,映射如下:[sid] [cmd],如 123->”sendfile”**。

至此,我们知道了请求表的设计和接收线程该如何修改。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
//请求表封装
#include <iostream>
#include <string>
#include <unordered_map>
#include <mutex>

using namespace std;


//对于chat和getfile命令,只存储sid和命令关键字,对于sendfile,还需要存储文件的名称,
//如sendfilesome.txt,因为sendfile是8个字节,所以很容易解析文件名

class ReqTable//主线程和接收线程都需要互斥操作
{
private:
unordered_map<string, string> reqTable;
using iterator = unordered_map<string, string>::iterator;
string filename;
mutex reqTableMux;

public:
ReqTable(){}
~ReqTable(){}
void insertReq(string sid,string cmd);//插入表项
void deleteReq(string sid);//删除表项
string checkReq(string sid);//查找并删除,如果是sendfile,让用户调用getName函数获取
string getName() { return filename; }

void print()//打印表项,后续或许可以加一个打印请求表的命令,现在用作测试
{
for (auto ite = reqTable.begin(); ite != reqTable.end(); ite++)
cout << ite->first << "\t" << ite->second << endl;
}

};

void ReqTable::insertReq(string sid, string cmd)
{
lock_guard<mutex> locker(reqTableMux);
reqTable[sid] = cmd;
}

void ReqTable::deleteReq(string sid)
{
lock_guard<mutex> locker(reqTableMux);
iterator iter = reqTable.find(sid);

if (iter == reqTable.end())//不存在则直接不管
return;
else
reqTable.erase(iter);

}

string ReqTable::checkReq(string sid)
{
lock_guard<mutex> locker(reqTableMux);
iterator iter = reqTable.find(sid);

if (iter == reqTable.end())//不存在则返回空,交由上层处理
return "";
else
{
string cmd = iter->second;
if (cmd.size() >= 8)//这种情况是sendfile,设置filename
{
filename = cmd.substr(8);
cmd = "sendfile";
}
//查找完不能删除,还不能删除,因为可能accept不对应,要保留
//reqTable.erase(iter);
return cmd;
}
}
//测试
int main()
{
ReqTable reqTable;
reqTable.insertReq("123", "chat");
reqTable.insertReq("456", "sendfilea.txt");
reqTable.insertReq("789", "getfile");
reqTable.print();

cout << reqTable.checkReq("789") << endl;

//判断后获取文件
if (reqTable.checkReq("456") == "sendfile")
cout << reqTable.getName() << endl;

reqTable.deleteReq("123");
cout << reqTable.checkReq("123") << endl;
reqTable.deleteReq("123");
return 0;
}

再修改一下接收线程的解析命令函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
ReqTable reqTable;

//解析命令,返回解析成不成功,失败则false
bool parseRecv(string cmdrecv)
{
//最后加一个空格,这样能解析成功
cmdrecv += " ";
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = cmdrecv.find(' ', pos)) != string::npos)
{
res.push_back(cmdrecv.substr(pos, pos1 - pos));
while (cmdrecv[pos1] == ' ')//过滤空格
pos1++;
pos = pos1;
}

if (res.size() < 2)
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}

if (res[0] == "chat" && state == clientState::isWaiting)//必须还在等待
{
if (res[1] == "accept")
{
cout << endl << "The peer agrees to chat!" << endl;
state = clientState::isChatting;
prompt = promptMap[state] + chattarg + "> ";
}
else if (res[1] == "reject")
{
cout << endl << "The peer disagrees to chat!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}
}
else if (res[0] == "sendfile" && state == clientState::isWaiting)
{
if (res[1] == "accept")
{
cout << endl << "The peer agrees to receive file!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
//开线程发送
}
else if (res[1] == "reject")
{
cout << endl << "The peer disagrees to receive file!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}
}
else if (res[0] == "getfile" && state == clientState::isWaiting)
{
if (res[1] == "accept" && res[2] == "none")//如果没有资源但同意了,第三个参数就是none
{
cerr << endl << "The peer has no resources!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else if (res[1] == "accept")
{
chooseflag = true;
prompt = "enter the number of file> ";
cout << endl << "The peer agrees to getfile!" << endl;
getResourceMap.clear();//清空
fileNumber = 0;//重置
string resolist;//打印信息
//假设对方发送的文件资源表是[1] some.txt [2] some2.txt,即空格分隔的成对的
for (int i = 2; i < res.size(); i += 2)
{
fileNumber++;
getResourceMap[fileNumber] = res[i + 1];
resolist += res[i] + "\t" + res[i + 1] + "\n";
}
cout << resolist << flush;


}
else if (res[1] == "reject")
{
cout << endl << "The peer doesn't allow you to get the resource!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}

else
{
cerr << endl << "An error command was received in the receThread!" << endl;
return false;
}
}
else if (res[0] == "choosefile")
{
int num = stoi(res[1]);
cout << endl << "The peer requested the [" + res[1] + "] file!" << endl;
//开一个线程发送
}
//---------------------------------------------------------------------------------
//-----------------------------请求表相关命令----------------------------------------
else if (res[0] == "chatfrom")
{
reqTable.insertReq(res[1], "chat");
cout << endl << "Receive a chat request from " << res[2] <<" -sid:" << res[1] << endl;
}
else if (res[0] == "sendfilefrom")
{
string req = "sendfile"+res[3];
reqTable.insertReq(res[1],req);
cout << endl << "Receive a sendfile request from " << res[2] <<" -sid: " << res[1] <<" -file name: " << res[3] << endl;
}
else if (res[0] == "getfilefrom")
{
reqTable.insertReq(res[1], "getfile");
cout << endl << "Receive a getfile request from " << res[2] <<" -sid:" << res[1] << endl;
}
//----break-----
else if (res[0] == "break")
{
if(res[1] == "now")
{
if(state == clientState::isChatting)//进入虚假chatting,让其退出
{
cout << endl << "The peers break before you accept!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else//进入接收或发送
{
cout << endl << "The peers break before you accept!" << endl;
//要让接收或发送线程退出
}
}

else//这种情况是break [sid],用户sid撤回请求
reqTable.deleteReq(res[1]);
}
else
return false;
return true;
}

现在对accept那些命令进行更改,即修改run函数,这里把整个run都弄下来,分开弄到时候合在一起也麻烦,这里还改一下choosefile的发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
void run(SOCKET connfd)
{
//一个汉字两字节,可能需要更大的buf
char cmdbuf[256];
char cmdtmp[256] = "no cmd";
char recvbuf[256];
bool reflag = false;
bool exitflag = false;//函数退出信号
cout << prompt << flush;
//根据bool或运算顺序,如果用上一次的结果(reflag==true),就不接收字符
while (reflag || fgets(cmdbuf, sizeof(cmdbuf), stdin) != NULL)//gets已不被编译器支持,不太安全
{
if (chooseflag)//如果在选择文件。接收线程会把这个flag改成true,并且打印提示符enter file number>
{
if (isNumber(cmdbuf))
{
//要进行数字的范围判断,用stoi
int num = stoi(cmdbuf);
if (num > fileNumber || num < 1)
{
cerr << "Please enter a number in the range!" << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
string choosecmd = "choosefile " + string(cmdbuf);//拼接命令
send(connfd, choosecmd.c_str(), choosecmd.size(), 0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
//...还需要用一个线程来接收

chooseflag = false;
state = clientState::cmdLine;
prompt = promptMap[state];
cout << prompt << flush;
continue;
}

else
{
cerr << "Please enter a number!" << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
}
if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
send(connfd, cmdbuf, sizeof(cmdbuf), 0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}
else//剩下的内容都是else的
{
memset(recvbuf, 0, sizeof(recvbuf));//把接收缓冲清零
string cmdstr(cmdbuf);
trim(cmdstr);

if (cmdstr == "\n")//不管怎么样都有个换行,仅有一个换行就不管
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}
vector<string> cmdvec = parse(cmdstr);

//查找命令
auto iter = cmdmap.find(cmdvec[0]);
if (iter == cmdmap.end())//命令错误
{
cerr << "Wrong command, your command parsed is: " << cmdvec[0] << endl;
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
continue;
}

int cmdvalue = iter->second;
if (!checkCmd(cmdvalue))//如果命令与状态不匹配
{
memset(cmdbuf, 0, sizeof(cmdbuf));
reflag = false;//因为可能是调用了re(连续两次login)会导致错误,这里错误要让用户重新输入,否则会崩溃(cmdbuf是空)
cout << prompt << flush;
continue;
}
switch (cmdvalue)
{
case 0:
if (cmdvec.size() != 4)
{
cerr << "error> The number of parameters for [register] is wrong!" << endl;
break;
}
else if (cmdvec[2] != cmdvec[3])
{
cerr << "error> The passwords entered twice are not equal!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
if (strcmp(recvbuf, "success") == 0)
cout << "The registration is successful and you can log in!" << endl;
else
cout << "Registration failed, please try a different ID!" << endl;

break;
}

case 1:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [login] is wrong!" << endl;
break;
}

else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的

if (strcmp(recvbuf, "success") == 0)
{
cout << "Login successfully!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cout << recvbuf << endl;
}
break;
}

case 2:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [search] is wrong!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
if (strcmp(recvbuf, "faliure") == 0)
{
cout << "sid does not exist!" << endl;
}
else
{
cout << recvbuf << endl;
}
break;
}

case 3:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [chat] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//发送cmdstr就不用做头尾空格去除
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送

state = clientState::isWaiting;
prompt = promptMap[state];
chattarg = cmdvec[1];
cout << "waiting> Waiting for a response, you can input break or @break to back to command line..." << endl;

//响应在接收线程处理!
/*
Sleep(3000);//阻塞3秒,假装等待连接
state = clientState::isChatting;
//chatSname = ...
prompt = promptMap[state] + chatSname + "> ";
*/
break;
}

case 4:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [accept] is wrong!" << endl;
break;
}
else
{
//做处理
//发送cmdstr就不用做头尾空格去除
if(reqTable.checkReq(cmdvec[1]) == "chat")
{
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
reqTable.deleteReq(cmdvec[1]);
}
else
cerr << "error> "<<cmdvec[1]<<" did not send a chat request!" << endl;
break;
}

case 5:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [reject] is wrong!" << endl;
break;
}
else
{
//做处理
if(reqTable.checkReq(cmdvec[1]) == "chat")
{
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
reqTable.deleteReq(cmdvec[1]);
}
else
cerr << "error> "<<cmdvec[1]<<" did not send a chat request!" << endl;
break;
}

case 6:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [break] is wrong!" << endl;
break;
}
else
{
//做处理,break也需要发送,这样服务器才能知道用户从某请求的等待中退出了
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
state = clientState::cmdLine;
prompt = promptMap[state];
break;
}

case 7:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [send] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
break;
}

case 8:
if (cmdvec.size() != 3)
{
cerr << "error> The number of parameters for [sendfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 9:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptfile] is wrong!" << endl;
break;
}
else
{
//做处理
if(reqTable.checkReq(cmdvec[1]) == "sendfile")
{
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
reqTable.deleteReq(cmdvec[1]);
string filename = reqTable.getName();
//...还需要用一个线程来接收,文件名通过请求表维护
}
else
cerr << "error> "<<cmdvec[1]<<" did not send a sendfile request!" << endl;


break;
}

case 10:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectfile] is wrong!" << endl;
break;
}
else
{
//做处理
if(reqTable.checkReq(cmdvec[1]) == "sendfile")
{
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
reqTable.deleteReq(cmdvec[1]);
}
else
cerr << "error> "<<cmdvec[1]<<" did not send a sendfile request!" << endl;
break;
}

case 11:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [getfile] is wrong!" << endl;
break;
}
else
{
//做处理
//send...waiting
//响应在接收线程处理!
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
state = clientState::isWaiting;
prompt = promptMap[state];
cout << "waiting> Waiting for a response, you can input @break to back to command line..." << endl;
break;
}

case 12:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [acceptget] is wrong!" << endl;
break;
}
else
{
//做处理
//响应在接收线程处理!
if(reqTable.checkReq(cmdvec[1]) == "getfile")
{
//还要发资源列表
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
reqTable.deleteReq(cmdvec[1]);
}
else
cerr << "error> "<<cmdvec[1]<<" did not send a getfile request!" << endl;
break;
}

case 13:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [rejectget] is wrong!" << endl;
break;
}
else
{
//做处理
if(reqTable.checkReq(cmdvec[1]) == "getfile")
{
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
reqTable.deleteReq(cmdvec[1]);
}
else
cerr << "error> "<<cmdvec[1]<<" did not send a getfile request!" << endl;
break;
}

case 14:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsid] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
//需要接收响应,因为sid是全局唯一的,可能设置失败!
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
break;
}

case 15:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [setsname] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
//这里接收响应是为了增加用户体验,服务器返回一个成功信息
//因为设置相对频率较少,这不是什么很大的负担
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
break;
}

case 16:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [re] is wrong!" << endl;
reflag = false;//虽然是re,但是命令有问题
break;
}
else//即使是错误命令也存,用户可能头铁...就想再试一次
{
//做处理
if (!strcmp(cmdtmp, "no cmd"))//如果还没有命令
{
cerr << "error> No command yet!" << endl;
}
else
{
cout << "reEX> " << cmdtmp;//不用换行,cmdtmp自然有个'\n'
//strcpy已不可用,不指定长度不安全。使用strlen要+1,因为长度不包含结束符,要补上去
strcpy_s(cmdbuf, strlen(cmdtmp) + 1, cmdtmp);
reflag = true;//表明不必把cmdbuf置零
}
break;
}

case 17:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [exit] is wrong!" << endl;
break;
}
else
{
//做处理,或许需要告知服务器退出,先这样
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
exitflag = true;
cout << "The client now exits!" << endl;
break;
}

case 18:
if (cmdvec.size() != 2)
{
cerr << "error> The number of parameters for [hisir] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
break;
}

case 20:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [oyasumi] is wrong!" << endl;
break;
}
else
{
//做处理
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的
cout << recvbuf << endl;
exitflag = true;
break;
}

}

//是否退出
if (exitflag)
break;

if (cmdvalue == 16 && reflag)//如果是re且成功的话就不用cmd,不用cmdbuf置零
continue;
else if (cmdvalue == 16)//虽然是re但失败了,不能保存re
{
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}
else
{
strcpy_s(cmdtmp, strlen(cmdbuf) + 1, cmdbuf);//暂存上一次命令,方便re调用,如果上一次是re的话,不更新
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
reflag = false;//上一次不是re自然置false
}
}


}
}

到这就改完了,先不测试了,稍后就封装起来,代码量上来了有点难受。但还是等基本完成后再一并把封装好的代码放上来。

资源列表生成

前面提到的资源列表字符串的格式,这里最后有没有空格都没关系:

1
//假设对方发送的文件资源表是[1] some.txt [2] some2.txt,即空格分隔的成对的

我们允许用户指定目录,也允许使用默认的目录,具体的:

  • 在本地目录有个配置文件config.txt,里面允许配置resource_path[空格]路径(这是新增内容)
  • 如果没有找到这个文件,就使用默认路径./resource;如果找到了就更换为该路径
  • 然后找到该文件夹,如果没有则资源列表为空;否则遍历文件,生成资源列表,既生成map也生成能直接发送的字符串。

首先有个读配置的函数,以及判断是否存在文件夹的函数;然后用windows.h的api去遍历文件名。

1
2
3
4
5
6
7
8
9
10
11
HANDLE FindFirstFile(LPCTSTR lpFileName,LPWIN32_FIND_DATA lpFindFileData);
//LPWIN32_FIND_DATA描述文件属性,如只读只写,这里获取名字
//lpFileName为/结尾的路径加通配符,比如这里遍历所有文件,就在路径后面添加了*,代表任意文件
//如果调用成功返回一个句柄,可用来做为FindNextFile或FindClose参数

bool FindNextFileA(HANDLE hFindFile,LPWIN32_FIND_DATAA lpFindFileData);
//继续对 FindFirstFile、FindFirstFileEx 或 FindFirstFileTransacted 函数的上一次调用中的文件搜索。

FindFirstFileA的返回值为HANDLE,即句柄,可与宏INVALID_HANDLE_VALUE进行比较,相等则说明失败,这个宏实际上就是-1
这里因为已经判断了文件夹是否存在,不用比较
FindNextFileA的返回值为true则匹配成功,为false则匹配失败,直接退出循环

函数和测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#include <string>//getline
#include <fstream>//文件流
#include <iostream>
#include <unordered_map>//哈希map
#include <windows.h>

using namespace std;
//假设有很多配置,这里生成一个配置映射
unordered_map<string, string> config_map;//映射表
unordered_map<int, string> myresource_map;
string myresource_str;
void setConfig()
{
const char* configfile = "./config.txt";
ifstream cfgFile(configfile, ios::in);

string line;
if (cfgFile.is_open())//如果可以打开,则获取参数
{
while (getline(cfgFile, line)) //逐行读取,直到结束
{
int pos = line.find(' ', 0);//找到空格位置
string key = line.substr(0, pos);//获取子串
string value = line.substr(pos + 1, line.size() - pos - 1);

if (value.size() != 0)//如果确实配置了
{
config_map[key] = value;//添加映射表项
//输出配置
cout << "configuration of [" << key << "]" << " is " << value << endl;
}
}
if (config_map.find("resource_path") == config_map.end())
{
config_map["resource_path"] = "./resource";
cout << "default configuration of [resource_path]" << " is " << "./resource" << endl;
}
cfgFile.close();
}
//没打开就默认
else
{
config_map["resource_path"] = "./resource";
cout << "default configuration of [resource_path]" << " is " << "./resource" << endl;
}
}

//判断文件夹是否存在
//DWORD d = GetFileAttributesA(const char* filename); #include <windows.h> 为windows系统函数,判断文件目录是否存在
bool dirExists(const std::string& dirpath)
{
DWORD ftyp = GetFileAttributesA(dirpath.c_str());
if (ftyp == INVALID_FILE_ATTRIBUTES)
return false; //something is wrong with your path!

if (ftyp & FILE_ATTRIBUTE_DIRECTORY)
return true; // this is a directory!

return false; // this is not a directory!
}

//传入config_map["resource_path"],完整文件路径为config_map["resource_path"]+'/'+myresoure_map[i]
//设置发送字符串myresource_str 以及映射表myresoure_map
void genResource(string dirpath)
{

if (!dirExists(dirpath))
{
myresource_str = "";//没有文件夹
return;
}
WIN32_FIND_DATAA fileInfo;
dirpath = dirpath + "/*";//通配符匹配所有的文件

//通常,最初的两次搜索得到的文件名为:"." 、"..",分别代表当前目录和上级目录,这里过滤掉"."然后进while过滤".."
HANDLE hFile = FindFirstFileA(dirpath.c_str(), &fileInfo);//现在定位到"."

if (hFile == INVALID_HANDLE_VALUE) {
myresource_str = "";//出错
return;
}


FindNextFileA(hFile, &fileInfo);//现在定位到".."
int index = 1;
while (FindNextFileA(hFile, &fileInfo))//再把".."过滤掉
{
myresource_str += "[" + to_string(index) + "] " + fileInfo.cFileName + " ";
myresource_map[index++] = fileInfo.cFileName;

}
FindClose(hFile);//关闭
}

int main()
{
setConfig();
genResource(config_map["resource_path"]);
cout << myresource_str << endl;
return 0;
}

文件传输

我们有时候还需要文件的发送和接收,这在另一个线程进行,下面再梳理一下这些场景:

  • 当sendfile后收到一个accept时,准备发送
  • 当使用一个acceptfile时,如果成功则准备接收
  • 当acceptget后收到一个choosefile返回命令时,准备发送
  • 当chooseflag为true时,输入选择的数字或准备接收

我们还没有定义线程的端口(ip服务器是知道的),这需要在命令里发送,我们先明确一下主被动关系:

  • 使用sendfile时,对方可能会拒绝;因此让对方acceptfile时把默认端口发来,对方进入listen,本地去connect发送
  • 使用getfile时,对方可能会拒绝;当对方acceptget时本地进入choose状态(目前还没设置能退出的命令,当如果对方资源列表为空是不会发过来的),choose后对方收到就会发文件过来,所以choose时把默认端口发去,本地进入listen,对方去connect。

因此需要两个端口号,一个用于acceptfile时开启监听接收,一个用于choose后开启监听接收。

因为对应关系明确,所以如果固定了端口就不需要在命令里再发送了,因为对方也知道;当然也允许用户自己选择端口号(通过配置),这里先不实现,给定:

  • sendfile的端口是14242
  • getfile的端口是14243

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
//发送函数,从调用者获得ip和端口和文件名
#include<iostream>
#include<cstring>
#include<WinSock2.h>//除了inet_pton
#include <WS2tcpip.h>//inet_pton
#include <stdio.h>//FILE等操作
#include <chrono>

#define buffSize 102400
const char* filelog = "fileLog.txt";//记录收发文件信息

void sendfile(const char* server_ip, const int port, std::string filename)
{
//连接服务器
SOCKET connfd = connect_S(server_ip, port);//获取套接字
if (connfd == INVALID_SOCKET || connfd == SOCKET_ERROR)
return;//连接失败退出,错误由connect_S函数输出


char sendbuf[buffSize];
memset(sendbuf, 0, sizeof(sendbuf));

//----------------------打开文件------------------------------------------------------
FILE* fp = NULL;
if (fopen_s(&fp, filename.c_str(), "rb") != 0)//要以二进制形式读写,这样兼容文件格式
{
std::cerr << "cannot open file " << filename << std::endl;
return;
}
std::chrono::system_clock::time_point time1 = std::chrono::system_clock::now();
size_t nSend = 0;
size_t totalSend = 0;
while (1)
{
size_t nRead = fread(sendbuf, 1, buffSize, fp);

if (ferror(fp) != 0)
{
std::cerr << "failed to read file " << std::endl;
return;
}

nSend = send(connfd, sendbuf, int(nRead), 0);//发送nRead个字节

if (nSend == SOCKET_ERROR)//网络断开或copy出错
{
std::cerr << "the connection to server has been failed" << std::endl;
return;
}
totalSend += nSend;

if (feof(fp))//读了,发完,再判断是否到达末尾
{
break;
}
}
fclose(fp);
std::chrono::system_clock::time_point time2 = std::chrono::system_clock::now();
int MB = int(totalSend / (1024 * 1024));
int ms = int(std::chrono::duration_cast<std::chrono::milliseconds>(time2 - time1).count());
std::string result = "send [" + filename + "] [" + std::to_string(totalSend) + "] bytes (" + std::to_string(MB) + " MB) cost [" + std::to_string(ms) + "] ms\n";

if (fopen_s(&fp, filelog, "a") != 0)//写result
{
std::cerr << "cannot open file " << filename << std::endl;
return;
}
fwrite(result.c_str(), 1, result.size(), fp);
fclose(fp);

closesocket(connfd);
}


void recvfile(const int port, std::string filename)
{
//定义socketfd,它要绑定监听的网卡地址和端口
SOCKET listenfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);//第三个参数写0也可以,这里表示创建tcp套接字

//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);//字节序转换
socketaddr.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY表示监听所有网卡地址,0.0.0.0;

//绑定套接字和地址端口信息,sockaddr_in转成sockaddr
if (bind(listenfd, (struct sockaddr*)&socketaddr, sizeof(socketaddr)) == -1)
{
std::cerr << "bind error" << std::endl;
return;
//cerr不经过缓冲而直接输出,一般用于迅速输出出错信息,是标准错误,默认情况下被关联到标准输出流,但它不被缓冲.
//也就说错误消息可以直接发送到显示器,而无需等到缓冲区或者新的换行符时,才被显示。
}

//开始监听
if (listen(listenfd, SOMAXCONN) == -1)
{
std::cerr << "listen error" << std::endl;
return;
}

///客户端套接字
char recvbuf[buffSize];
struct sockaddr_in client_addr;//获取客户的地址和端口号,连接后的不分配新端口
socklen_t len = sizeof(client_addr);//socklen_t 相当于 int,但使用int必须强制转型告知编译器

SOCKET conn = accept(listenfd, (struct sockaddr*)&client_addr, &len);//阻塞,等待连接,成功则创建连接套接字conn描述这个用户
if (conn == -1)
{
std::cerr << "connect" << std::endl;
return;
}
/*
如果队列中没有等待的连接,套接字也没有被标记为Non-blocking,accept()会阻塞调用函数直到连接出现;
如果套接字被标记为Non-blocking,队列中也没有等待的连接,accept()返回错误EAGAIN或EWOULDBLOCK。
*/

//----------------------打开文件------------------------------------------------------
FILE* fp = NULL;
if (fopen_s(&fp, filename.c_str(), "wb") != 0)//要以二进制形式读写,这样兼容文件格式
{
std::cerr << "cannot open file " << filename << std::endl;
return;
}
std::chrono::system_clock::time_point time1 = std::chrono::system_clock::now();

int totalRecv = 0;
while (1)
{
int nRecv = recv(conn, recvbuf, buffSize, 0);
if (nRecv == SOCKET_ERROR)//copy出错
{
std::cerr << "connection to client has been failed" << std::endl;
return;
}
else if (nRecv == 0)//这种情况是对端close了,此时返回0。可能是意外close,也可能是发送完毕了
{
break;
}
totalRecv += nRecv;

size_t nWrite = fwrite(recvbuf, 1, nRecv, fp);//写文件

if (nWrite != nRecv || ferror(fp) != 0)
{
std::cerr << "failed to write file" << std::endl;
return;
}
}

fclose(fp);
std::chrono::system_clock::time_point time2 = std::chrono::system_clock::now();
int MB = totalRecv / (1024 * 1024);
int ms = int(std::chrono::duration_cast<std::chrono::milliseconds>(time2 - time1).count());
std::string result = "recv [" + filename + "] [" + std::to_string(totalRecv) + "] bytes (" + std::to_string(MB) + " MB) cost [" + std::to_string(ms) + "] ms\n";

if (fopen_s(&fp, filelog, "a") != 0)//写result
{
std::cerr << "cannot open file " << filename << std::endl;
return;
}
fwrite(result.c_str(), 1, result.size(), fp);
fclose(fp);

closesocket(conn);
closesocket(listenfd);
}

现在要思考一下线程如何维护,这里主要是设计前面提到的@#break now命令,即本地用户break后没来得及通知对方,对方就accept了。因为我们设计的是让accept方发送,所以实际上接收后break now命令后只需要把acceptfile后产生的listen线程关掉就好了。

因为只设计监听一个端口,因此sendfile和getfile各自在同一时间只能接收一个文件(用线程池不方便管理,并且消耗的资源太多了;如果accept一个连接新开一个线程,对于文件名是不方便传参的,除非用线程池取任务,这些任务绑定好了参数,目前先不支持吧)。

然后这些线程不能用join,否则会在线程切换时阻塞(比如上一个文件下载完下载另一个要join,但其实不知道下载完没,就可能会阻塞),但不用join又无法释放线程资源;所以用deatch,这就需要给主线程提供是否在运行的信息,设置全局变量isRecvingGf和isRecvingSf即可。因为同一时间只有一个线程改写该全局变量,所以不用互斥。

然后,我们还要考虑虚假acceptfile的情况,这时本地会开一个listen,如果不关掉下一个文件接收就无法bind端口,因此收到break now后,就用本地的一个连接去访问,让其accept,然后判断是本地的“127.0.0.1”就直接退出(实测可以)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
void sendfile(const char* server_ip, const int port, std::string filename)
{
//连接服务器
SOCKET connfd = connect_S(server_ip, port);//获取套接字
if (connfd == INVALID_SOCKET || connfd == SOCKET_ERROR)
return;//连接失败退出,错误由connect_S函数输出


char sendbuf[buffSize];
memset(sendbuf, 0, sizeof(sendbuf));

//----------------------打开文件------------------------------------------------------
FILE* fp = NULL;
if (fopen_s(&fp, filename.c_str(), "rb") != 0)//要以二进制形式读写,这样兼容文件格式
{
std::cerr << "cannot open file " << filename << std::endl;
return;
}
std::chrono::system_clock::time_point time1 = std::chrono::system_clock::now();
size_t nSend = 0;
size_t totalSend = 0;
while (1)
{
size_t nRead = fread(sendbuf, 1, buffSize, fp);

if (ferror(fp) != 0)
{
std::cerr << "failed to read file " << std::endl;
return;
}

nSend = send(connfd, sendbuf, int(nRead), 0);//发送nRead个字节

if (nSend == SOCKET_ERROR)//网络断开或copy出错
{
std::cerr << "the connection to server has been failed" << std::endl;
return;
}
totalSend += nSend;

if (feof(fp))//读了,发完,再判断是否到达末尾
{
break;
}
}
fclose(fp);
std::chrono::system_clock::time_point time2 = std::chrono::system_clock::now();
int MB = int(totalSend / (1024 * 1024));
int ms = int(std::chrono::duration_cast<std::chrono::milliseconds>(time2 - time1).count());
std::string result = "send [" + filename + "] [" + std::to_string(totalSend) + "] bytes (" + std::to_string(MB) + " MB) cost [" + std::to_string(ms) + "] ms\n";

if (fopen_s(&fp, filelog, "a") != 0)//写result
{
std::cerr << "cannot open file " << filename << std::endl;
return;
}
fwrite(result.c_str(), 1, result.size(), fp);
fclose(fp);

closesocket(connfd);
}


void recvfile(const int port, std::string filename ,int flag)//这个filename是完整路径
{

if (flag)//1是getfile
isRecvingGf = true;
else
isRecvingSf = true;

//定义socketfd,它要绑定监听的网卡地址和端口
SOCKET listenfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);//第三个参数写0也可以,这里表示创建tcp套接字

//定义sockaddr_in
struct sockaddr_in socketaddr;
socketaddr.sin_family = AF_INET;//ipv4
socketaddr.sin_port = htons(port);//字节序转换
socketaddr.sin_addr.s_addr = htonl(INADDR_ANY);//INADDR_ANY表示监听所有网卡地址,0.0.0.0;

//绑定套接字和地址端口信息,sockaddr_in转成sockaddr
if (bind(listenfd, (struct sockaddr*)&socketaddr, sizeof(socketaddr)) == -1)
{
std::cerr << "bind error" << std::endl;
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
closesocket(listenfd);
return;
//cerr不经过缓冲而直接输出,一般用于迅速输出出错信息,是标准错误,默认情况下被关联到标准输出流,但它不被缓冲.
//也就说错误消息可以直接发送到显示器,而无需等到缓冲区或者新的换行符时,才被显示。
}

//开始监听
if (listen(listenfd, SOMAXCONN) == -1)
{
std::cerr << "listen error" << std::endl;
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
closesocket(listenfd);
return;
}

///客户端套接字
char recvbuf[buffSize];
struct sockaddr_in client_addr;//获取客户的地址和端口号,连接后的不分配新端口
socklen_t len = sizeof(client_addr);//socklen_t 相当于 int,但使用int必须强制转型告知编译器

SOCKET conn = accept(listenfd, (struct sockaddr*)&client_addr, &len);//阻塞,等待连接,成功则创建连接套接字conn描述这个用户
if (conn == -1)
{
std::cerr << "connect error" << std::endl;
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
closesocket(listenfd);
return;
}
/*
如果队列中没有等待的连接,套接字也没有被标记为Non-blocking,accept()会阻塞调用函数直到连接出现;
如果套接字被标记为Non-blocking,队列中也没有等待的连接,accept()返回错误EAGAIN或EWOULDBLOCK。
*/

char ip[20] = { '\0' };
inet_ntop(AF_INET, &client_addr.sin_addr, ip, 16);
if (strcmp(ip, "127.0.0.1") == 0)//用于跳出假连接
{
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
closesocket(listenfd);
return;
}

//----------------------打开文件------------------------------------------------------
FILE* fp = NULL;
if (fopen_s(&fp, filename.c_str(), "wb") != 0)//要以二进制形式读写,这样兼容文件格式
{
std::cerr << "cannot open file " << filename << std::endl;
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
return;
}
std::chrono::system_clock::time_point time1 = std::chrono::system_clock::now();

int totalRecv = 0;
while (1)
{
int nRecv = recv(conn, recvbuf, buffSize, 0);
if (nRecv == SOCKET_ERROR)//copy出错
{
std::cerr << "connection to client has been failed" << std::endl;
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
return;
}
else if (nRecv == 0)//这种情况是对端close了,此时返回0。可能是意外close,也可能是发送完毕了
{
break;
}
totalRecv += nRecv;

size_t nWrite = fwrite(recvbuf, 1, nRecv, fp);//写文件

if (nWrite != nRecv || ferror(fp) != 0)
{
std::cerr << "failed to write file" << std::endl;
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
return;
}
}

fclose(fp);
std::chrono::system_clock::time_point time2 = std::chrono::system_clock::now();
int MB = totalRecv / (1024 * 1024);
int ms = int(std::chrono::duration_cast<std::chrono::milliseconds>(time2 - time1).count());
std::string result = "recv [" + filename + "] [" + std::to_string(totalRecv) + "] bytes (" + std::to_string(MB) + " MB) cost [" + std::to_string(ms) + "] ms\n";

if (fopen_s(&fp, filelog, "a") != 0)//写result
{
std::cerr << "cannot open file " << filename << std::endl;
if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
return;
}
fwrite(result.c_str(), 1, result.size(), fp);
fclose(fp);

closesocket(conn);
closesocket(listenfd);

if (flag)//1是getfile
isRecvingGf = false;
else
isRecvingSf = false;
}

补充

现在消息都能发给服务器了,但是服务器不知道是聊天信息还是命令信息…所以,我们给聊天信息加一个关键字符,一方面我们改的代码少,一方面由于命令没有这个关键字符,所以只要有就能判定为聊天信息,不会冲突。这个关键字符是~,加在内容前面。

本来是这样的:

1
2
3
4
5
6
7
if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
send(connfd, cmdbuf, strlen(cmdbuf), 0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}

现在是这样的:

1
2
3
4
5
6
7
8
if (state == clientState::isChatting && cmdbuf[0] != '@')//如果在chatting,并且输入第一个不是@,判定为聊天内容直接发送
{
//send...
string charstr = "~" + string(cmdbuf);
send(connfd, charstr.c_str(), int(charstr.size()), 0);//发送
memset(cmdbuf, 0, sizeof(cmdbuf));
cout << prompt << flush;
}

加两个小功能

  • 自动登录,在配置文件里写是user_id和user_password
  • 加一个打印自己的资源列表的命令(意识到这是蛮必要的)。myresource->19。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Client::autoLogin(SOCKET connfd)
{
if (config_map.find("user_id") == config_map.end() || config_map.find("user_password") == config_map.end())
return;//如果有一个没填就不管

cout << "Login automatically......" << endl;
string cmdstr = "login " + config_map["user_id"] + " " + config_map["user_password"] + "\n";
char recvbuf[128];
send(connfd, cmdstr.c_str(), int(cmdstr.size()), 0);//发送
recv(connfd, recvbuf, sizeof(recvbuf), 0);//同步接收,是阻塞的

if (strcmp(recvbuf, "success") == 0)
{
cout << "Login successfully!" << endl;
state = clientState::cmdLine;
prompt = promptMap[state];
}
else
{
cout << recvbuf << endl;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//run里面添加命令
case 19:
if (cmdvec.size() != 1)
{
cerr << "error> The number of parameters for [myresource] is wrong!" << endl;
break;
}
else
{
//只要打印就好了
string myresolist;
for (int index = 1; index < myresource_map.size(); index += 1)
{
myresolist += "[" + to_string(index) + "]" + "\t" + myresource_map[index] + "\n";
}
cout << myresolist << flush;
break;
}

现在,客户端差不多可以收尾了。

服务器的设计在下一章博客,封装好的代码也会放出来。

Note

项目代码和测试代码和结果在GitHub上:Chen-Jin-yuan/RDT-base-on-UDP (github.com)

实现的功能如下:

  • 重新设计的报文头部;
  • 两次握手(only 1 RTT);
  • 基于ID标识(参考Quic协议);
  • 乱序确认,number递增(参考Quic协议);
  • 超时重传,基于number采样rtt(参考Quic协议);
  • 使用offset标识流而非number(参考Quic协议);
  • 基于maxoffset进行流量控制(参考Quic协议);
  • 基于心跳检测确认双方存活与退出。

Quic:RFC 9000 - QUIC: A UDP-Based Multiplexed and Secure Transport (ietf.org)

在不同内网的P2P通信可以达到2MB/s的速度,如果UDPSEGSIZE设置得大即一次发送的报文很大,可以达到10MB/s,但在高丢包率情况下会有意外情况。如果在相同内网中可以设置大一些。

因为平时比较忙,项目设计时分了好几段时间来做,所以有些地方思路会断开,代码也会有不足的地方,欢迎评论。

报文

报文生成(编辑器)

读入的文件数据buffer添加头部信息,形成新的buffer。

报文结构

最好32bits对齐

  • 编号,递增
  • 报文内容长度,字节数,一个报文大小最好不要超过MTU(1500Bytes)
  • 使用offset标识数据流
    • 这个offset是字节尾部还是头部合适呢?这涉及到两种排序的方案
      • 使用头部的话,因为头部+len就是偏移的尾部,也即下一个偏移的初始。这样我们在写入文件时要找连续的offset,头部+len就能得到下一个包的头部offset,这样可以用哈希表来找(哈希offest->报文结构)
      • 如果只有尾部的话实际上只能排序,然后判断下一个是不是连起来的(offset-len是否等于当前的offset),这样就用链表,每次放入(直接放入buffer)都是排序插入链表。
  • ID:这个ID是目标的ID
  • tag:包含ack、syn、fin、rst四位。bitset:C++ bitset类详解 (biancheng.net)
  • 接收方窗口大小
  • 暂存…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
class MyUdpSeg
{
public:
using num_type = unsigned int; //32bits
using off_type = size_t; //64bits or 32bits
using win_type = size_t; //64bits or 32bits
using len_type = unsigned short; //16bits
using id_type = unsigned short; //16bits
using tag_type = bitset<8>; //8bits,?|?|?|?|ack|syn|fin|rst
static int maxHeadSize;

private: //data members
num_type number;
off_type offset;
len_type length;
win_type window;
id_type id;
tag_type tag;
//save...
string data;

public:
//Constructor 1: Construct class objects based on data
MyUdpSeg(const char* buf, num_type Number, off_type Offset,
len_type Length, tag_type Tag, win_type Window = 0, id_type Id = 0);

//Constructor 2: Construct class objects based on the complete UDP packet segment(string)
MyUdpSeg(string& udpSeg);

//Constructor 3: Retransmit, adjust the number
MyUdpSeg(MyUdpSeg& udpSeg, num_type Number);

//Copy constructor
MyUdpSeg(const MyUdpSeg& udpSeg);
//Move constructor
MyUdpSeg(MyUdpSeg&& udpSeg) noexcept;
//operator=, copy-swap
MyUdpSeg& operator=(MyUdpSeg udpSeg);

private:
//parse the string
vector<string> parse(string& str);
//swap for copy-swap
void swap(MyUdpSeg& udpSeg);

public:
//convert to string to send data
string seg_to_string();

//return a default obj
static MyUdpSeg initSeg();

//read members only
num_type getNumber() { return number; }
off_type getOffset() { return offset; }
len_type getLength() { return length; }
win_type getWindow() { return window; }
id_type getId() { return id; }
bool getTag(size_t i) { return tag[i]; }
string& getData() { return data; }
};

/*
* The headers are converted to a string to send
* The maximum number of digits in 32-bit decimal is 10 digits: 4,294,967,296
* The maximum number of digits in 64-bit decimal is 20 digits: 18,446,744,073,709,551,616
* The maximum number of digits in 16-bit decimal is 5 digits: 65,536
* There are 8 bits of identification
* There are also 6 spaces
*/
int MyUdpSeg::maxHeadSize = 10 + 20 + 20 + 5 + 5 + 8 + 6;

//Constructor 1: Construct class objects based on data
MyUdpSeg::MyUdpSeg(const char* buf, num_type Number, off_type Offset,
len_type Length, tag_type Tag, win_type Window, id_type Id) :
data(buf), number(Number), offset(Offset), window(Window),
length(Length), id(Id), tag(Tag)
{}

//Constructor 2: Construct class objects based on the complete UDP packet segment(string)
MyUdpSeg::MyUdpSeg(string& udpSeg)
{
vector<string> vec = parse(udpSeg);
// error packet segment
if (vec.size() < 6)
{
length = 0; //Indicates that this is a useless package
return;
}
/*
* have not string to unsigned int or size_t function
* but string to unsigned long is satisfiable
* just make sure it doesn't overflow
* use forced transformation to ignore warnings
*/
number = num_type(stoul(vec[0]));
offset = off_type(stoul(vec[1]));
window = win_type(stoul(vec[2]));
length = len_type(stoul(vec[3]));
id = id_type(stoul(vec[4]));
tag = tag_type(vec[5]);
if (vec.size() > 6) //if arry data. ACK may not carry data
data = vec[6];
}

//Constructor 3: Retransmit, adjust the number
MyUdpSeg::MyUdpSeg(MyUdpSeg& udpSeg, num_type Number) :
data(udpSeg.data), number(Number), offset(udpSeg.offset), window(udpSeg.window),
length(udpSeg.length), id(udpSeg.id), tag(udpSeg.tag)
{}

//Copy constructor
MyUdpSeg::MyUdpSeg(const MyUdpSeg& udpSeg) :
data(udpSeg.data), number(udpSeg.number), offset(udpSeg.offset), window(udpSeg.window),
length(udpSeg.length), id(udpSeg.id), tag(udpSeg.tag)
{}

//Move constructor
MyUdpSeg::MyUdpSeg(MyUdpSeg&& udpSeg) noexcept :
data(udpSeg.data), number(udpSeg.number), offset(udpSeg.offset), window(udpSeg.window),
length(udpSeg.length), id(udpSeg.id), tag(udpSeg.tag)
{}

//operator=, copy-swap
MyUdpSeg& MyUdpSeg::operator=(MyUdpSeg udpSeg)
{
swap(udpSeg);
return *this;
}
//swap for copy-swap
void MyUdpSeg::swap(MyUdpSeg& udpSeg)
{
using std::swap;
swap(this->number, udpSeg.number);
swap(this->offset, udpSeg.offset);
swap(this->length, udpSeg.length);
swap(this->window, udpSeg.window);
swap(this->id, udpSeg.id);
swap(this->tag, udpSeg.tag);
swap(this->data, udpSeg.data);
}

//parse the string
vector<string> MyUdpSeg::parse(string& str)
{
//Note that there are also spaces in the data, so parse up to six times
//Data should not be sliced

int spaceNum = 6;

str = str + " "; //add an space
vector<string> res;
size_t pos = 0;
size_t pos1;
while ((pos1 = str.find(' ', pos)) != string::npos)
{
if (spaceNum-- == 0)
break;

res.push_back(str.substr(pos, pos1 - pos));
while (str[pos1] == ' ')
pos1++;
pos = pos1;
}
//Get complete data
string data = str.substr(pos);
if (data != "")
res.push_back(data);

return res; //move construction
}

//convert to string to send data
string MyUdpSeg::seg_to_string()
{
string res;
res += to_string(number) + " ";
res += to_string(offset) + " ";
res += to_string(window) + " ";
res += to_string(length) + " ";
res += to_string(id) + " ";
res += tag.to_string() + " "; //std::bitset::to_string()
res += data;
return res;
}

MyUdpSeg MyUdpSeg::initSeg()
{
return MyUdpSeg("", 0, 0, 0, MyUdpSeg::tag_type(), 0, 0);
}

ACK

返回ACK报文,并通告可用窗口边界

  • ACK会告知接收方的可用窗口边界,如果有ACK通告更小的边界,发送方忽略它们
1
2
3
4
5
6
7
MyUdpSeg sendAck(MyUdpSeg::num_type number, MyUdpSeg::off_type offset, MyUdpSeg::win_type window,
MyUdpSeg::id_type id)
{
MyUdpSeg::tag_type tag;
tag.set(3, 1); //set ack from 0 to 1
return MyUdpSeg("", number, offset, 0, tag, window, id);
}

定时器

这部分主要考虑定时怎么设计,即如何得知超时。

简单的想法是,用链表维护定时器。对于超时的定时器,我们在定时器维护一个编号,告知发送窗口设置重发即可。

对于排序,有两种手段,一种是在插入节点时候按时间从小到大排序,这样检测就能从头检测知道第一个没超时就停下;一种是直接插入,检测超时是遍历所有节点。

显然在这个场景下,插入是频繁的,可能插入很多个节点才检测一次超时,因此使用直接插入要好得多。


定时器节点内容是:

  • 包的编号
  • 记录的时间信息
  • offset(补充)
  • 是否重传过(补充)

整体用一个STL list,可以用find函数(注意list本身没有find函数),list的remove必须是节点相等,则可以用remove_if;这时可以用algorithm头文件的remove函数,使用重载==,但是时间是O(n)。对于timer来说,编号不可能重复,因此用find()+list.erase()即可。

收到ACK时要删除定时器中的对应的包的节点,采样RTT就在此时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//timer node
struct timerNode
{
MyUdpSeg::num_type number; //segment number
chrono::system_clock::time_point time; //start time
timerNode(MyUdpSeg::num_type Number):number(Number),time(chrono::system_clock::now()){}
bool operator==(MyUdpSeg::num_type Number) { return number == Number; } //for find() function or remove()
};

//use example
using timerIter = list<timerNode>::iterator;
list<timerNode> timerList;
for (MyUdpSeg::num_type i = 0; i < 20; i++)
timerList.push_back(timerNode(i));
timerIter iter = find(timerList.begin(),timerList.end(),19); //O(n)
chrono::system_clock::time_point nowtime = chrono::system_clock::now();
cout << (*iter).number << endl;
cout << chrono::duration_cast<chrono::milliseconds>(nowtime - (*iter).time).count() << endl;
timerList.erase(iter); //O(1)
iter = find(timerList.begin(), timerList.end(), 19);
if (iter == timerList.end())
cout << "remove 19" << endl;
remove(timerList.begin(), timerList.end(), 1);
iter = find(timerList.begin(), timerList.end(), 1);
if (iter == timerList.end())
cout << "remove 1" << endl;

为了支持插入的时候排序以及其他功能,设计一个类封装。(发现using node_type = struct timerNode有问题,要去掉struct)

  • 允许插入节点
  • 处理超时事件,遍历链表,找出每个超时的节点
    • 对于这些节点,需要重传,因此要返回所有结点的包的编号;重传的包的节点插入交给上层
    • 尽管这些节点需要重传,但是还是需要保留的,因为原始包超时后重传了,但可能原始包的ack随后就到了,这时要用原始包来采样RTT。而对于重传的包的定时器就可以根据offset删除了,因为不需要再处理超时任务了,同时也不采样这些RTT。
    • 所以还要根据offset删除所有节点,这就要加一个offset
  • 当收到一个ack时,根据number删除节点,并返回计算的RTT;然后这个number可能有重传的包的定时器,根据offset一并删了,因为这个数据流offset已经不被需要了。
  • 还要注意,如果一个包重传了而不更改记录的时间(为了计算RTT)的话,那么下次检测这个节点肯定会再被重传,因为包的编号不一样的,可能会重传两个甚至更多相同的数据包;因此要加一个flag,一旦检测出一次超时了,下次就不会再报超时事件了,留下的作用是采样RTT。

重新设计和封装如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
struct timerNode
{
MyUdpSeg::num_type number; //segment number
MyUdpSeg::off_type offset; //data offset
chrono::system_clock::time_point time; //start time
bool timeout; //timeout flag
timerNode(MyUdpSeg::num_type Number, MyUdpSeg::off_type Offset) :
number(Number), offset(Offset),time(chrono::system_clock::now()), timeout(false) {}
timerNode(const timerNode& node) :
number(node.number), offset(node.offset),time(node.time), timeout(false) {}
bool operator==(MyUdpSeg::num_type Number) { return number == Number; } //for find() function or remove()
};


class TimerList
{
public:
using node_type = timerNode;
using rtt_type = unsigned int;
using rto_type = double;
using timerIter = list<node_type>::iterator;
private:
list<node_type> timerList;
public:
TimerList(){}
~TimerList(){}
//insert by node
void insertTimer(const node_type& node);
//insert by number
void insertTimer(MyUdpSeg::num_type Number, MyUdpSeg::off_type Offset);

//delete by number, return RTT/ms
rtt_type deleteTimer(MyUdpSeg::num_type Number);

//deal with timeout packets, return numbers
vector<MyUdpSeg::num_type> tick(rto_type RTO);

//return size
size_t size() { return timerList.size(); }
private:
//delete all by offset, call by "rtt_type deleteTimer(MyUdpSeg::num_type Number);"
void deleteTimer_(MyUdpSeg::off_type Offset);
};

void TimerList::insertTimer(const node_type& node)
{
timerList.push_back(node); // call move or copy constructor function, not need to construct
}
void TimerList::insertTimer(MyUdpSeg::num_type Number, MyUdpSeg::off_type Offset)
{
timerList.emplace_back(Number, Offset); //call constructor function
}

TimerList::rtt_type TimerList::deleteTimer(MyUdpSeg::num_type Number)
{
//find the node
timerIter iter = find(timerList.begin(), timerList.end(), Number); //O(n)
if (iter == timerList.end()) //not found
return 0; //zero used to error detect

//sample RTT
chrono::system_clock::time_point nowtime = chrono::system_clock::now();
rtt_type RTT = rtt_type(chrono::duration_cast<chrono::milliseconds>(nowtime - (*iter).time).count());

//get offset
MyUdpSeg::off_type offset = (*iter).offset;

//delete
timerList.erase(iter); //O(1)

//delete node with the same offset
deleteTimer_(offset);

return RTT;
}

void TimerList::deleteTimer_(MyUdpSeg::off_type Offset)
{
for (timerIter iter = timerList.begin(); iter != timerList.end();)
{
if ((*iter).offset == Offset)
iter = timerList.erase(iter); //erase return next iterator
else
iter++;
}
}

vector<MyUdpSeg::num_type> TimerList::tick(rto_type RTO)
{
if (RTO <= 0) return{};

vector<MyUdpSeg::num_type> number_retrans;
for (timerIter iter = timerList.begin(); iter != timerList.end(); iter++)
{
if ((*iter).timeout == true)
continue;
else
{
chrono::system_clock::time_point nowtime = chrono::system_clock::now();
rtt_type interval = rtt_type(chrono::duration_cast<chrono::milliseconds>(nowtime - (*iter).time).count());
if (interval > RTO) //need to retransmit
{
number_retrans.push_back((*iter).number);
(*iter).timeout = true;
}
}
}
return number_retrans;
}

缓冲(窗口)

用于建立接收窗口和发送窗口,并维护它们的发送与接收

TCP的缓冲区是一个双向链表,所以这里也使用双向链表,这插入和删除上还是很方便的。

对于缓冲区节点,收到一个ack时,可以知道编号,删除该节点和定时器并计算RTT;问题是可能有重传的包,编号是不一样的,此时用offset找所有重传的包,找到就删了,因为这个包是否收到已经不重要了(RTT也不采样这个)。

而节点只能重载一个operator==(因为offset和number参数类型可能是一样的,重载失败),前面要find编号,可以重载编号。然后因为要根据offset删所有重传的包,这时就要多实现一个函数,然后用remove_if了。这个函数在remove_if内传参是传入节点,我们还要一个参数offset,所以要用函数对象bind。(当然也可以遍历链表自己删,定时器用的是自己遍历的)

1
auto bindFunc1 = bind(lambda,std::placeholders::_1,std::placeholders::_2);

对于发送和接收窗口来说,通用的设计如下:

链表的设计是:STL list。

节点的设计是:

  • 有一个报文对象MyUdpSeg
  • flag标识这个节点发过了没

类的设计是:

  • 有添加和删除节点的功能
  • 有查找节点并返回其引用的功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//buffer node
struct bufferNode
{
MyUdpSeg udpSeg; //segment object
bool isSent;

//Constructor 1: construct by lvalue
bufferNode(MyUdpSeg& UdpSeg) :udpSeg(UdpSeg), isSent(false) {}
//Constructor 2: construct by rvalue
bufferNode(MyUdpSeg&& UdpSeg) noexcept :udpSeg(UdpSeg), isSent(false) {}
//Constructor 3: Retransmit, adjust the number
bufferNode(MyUdpSeg& UdpSeg, MyUdpSeg::num_type Number) :udpSeg(UdpSeg, Number), isSent(false) {}

//copy constructor
bufferNode(const bufferNode& node):udpSeg(node.udpSeg),isSent(node.isSent){}

bool operator==(MyUdpSeg::num_type Number) { return udpSeg.getNumber() == Number; } //for find() function
};
1
2
3
4
5
6
7
8
9
10
11
//lambda for remove_if
auto pred_lambda = [](bufferNode& bufNode, MyUdpSeg::off_type offset) -> bool // "-> bool" is omittable
{
return bufNode.udpSeg.getOffset() == offset;
};
auto pred = bind(pred_lambda,std::placeholders::_1, myOffset);

//the simplified form is as follows
auto pred = bind([](bufferNode& bufNode, MyUdpSeg::off_type offset) { //lambda
return bufNode.udpSeg.getOffset() == offset; },
placeholders::_1, myOffset); //parameters

类的设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
//buffer node
struct bufferNode
{
MyUdpSeg udpSeg; //segment object
bool isSent;

//Constructor 1: construct by lvalue
bufferNode(MyUdpSeg& UdpSeg) :udpSeg(UdpSeg), isSent(false) {}
//Constructor 2: construct by rvalue
bufferNode(MyUdpSeg&& UdpSeg) noexcept :udpSeg(UdpSeg), isSent(false) {}
//Constructor 3: Retransmit, adjust the number
bufferNode(MyUdpSeg& UdpSeg, MyUdpSeg::num_type Number) :udpSeg(UdpSeg, Number), isSent(false) {}

//copy constructor
bufferNode(const bufferNode& node):udpSeg(node.udpSeg),isSent(node.isSent){}

bool operator==(MyUdpSeg::num_type Number) { return udpSeg.getNumber() == Number; } //for find() function
};



class BufferList
{
public:
using node_type = bufferNode;
using bufferIter = list<node_type>::iterator;
using node_return_type = pair<node_type&, bool>;
private:
list<node_type> bufferList;
public:
BufferList(){}
~BufferList(){}

//insert node
void insertNode(MyUdpSeg& UdpSeg);
void insertNode(MyUdpSeg&& UdpSeg);
void insertNode(MyUdpSeg& UdpSeg, MyUdpSeg::num_type Number);
void sortInsertNode(MyUdpSeg& UdpSeg); //insert sorted by offset(used by recvbuf)

//delete by number
void deleteNode(MyUdpSeg::num_type Number);

//get node by number, return pair: reference、bool (to check node)
node_return_type getNode(MyUdpSeg::num_type Number);
/*
* Note for getNode function:
* For each return value, initialize it with a new variable
* Do not assign a value to pair again after initialization, it will act on the initialized node
*/

//return size
size_t size() { return bufferList.size(); }

bufferIter begin() { return bufferList.begin(); }
bufferIter end() { return bufferList.end(); }
void deleteFront() { bufferList.erase(begin()); }

//return a default node to ues getNode function
static node_type initNode();

private:
void deleteNode_(MyUdpSeg::off_type Offset);
};

void BufferList::insertNode(MyUdpSeg& UdpSeg)
{
bufferList.emplace_back(UdpSeg);
}
void BufferList::insertNode(MyUdpSeg&& UdpSeg)
{
bufferList.emplace_back(move(UdpSeg));
}
void BufferList::insertNode(MyUdpSeg& UdpSeg, MyUdpSeg::num_type Number)
{
bufferList.emplace_back(UdpSeg, Number);
}
void BufferList::sortInsertNode(MyUdpSeg& UdpSeg)
{
MyUdpSeg::off_type offset = UdpSeg.getOffset();
bufferIter iter = bufferList.begin();
for (; iter != bufferList.end(); iter++)
{
if ((*iter).udpSeg.getOffset() < offset)
continue;
else if ((*iter).udpSeg.getOffset() == offset) //A package with the same data
return;
else
break;
}
bufferList.insert(iter,UdpSeg);

}
void BufferList::deleteNode(MyUdpSeg::num_type Number)
{
//find the node
bufferIter iter = find(bufferList.begin(), bufferList.end(), Number); //O(n)
if (iter == bufferList.end()) //not found
{
cout << "no delete" << endl;
return; //do nothing
}


//get offset
MyUdpSeg::off_type offset = (*iter).udpSeg.getOffset();

//remove
bufferList.erase(iter); //O(1)

//remove all nodes with the same offset
deleteNode_(offset);

}
void BufferList::deleteNode_(MyUdpSeg::off_type Offset)
{
bufferList.remove_if(bind([](bufferNode& bufNode, MyUdpSeg::off_type offset) {
return bufNode.udpSeg.getOffset() == offset; },
placeholders::_1, Offset));

}
BufferList::node_return_type BufferList::getNode(MyUdpSeg::num_type Number)
{

//find the node
bufferIter iter = find(bufferList.begin(), bufferList.end(), Number); //O(n)
if (iter == bufferList.end()) //not found
{
node_type node = initNode(); //must check bool value firstly, node may be inexistent
return node_return_type(node, false);
}

return node_return_type(*iter,true);
}

BufferList::node_type BufferList::initNode()
{
return node_type(MyUdpSeg::initSeg());
}

发送窗口

功能

  • 发送窗口里有缓冲链表对象和定时器链表对象
  • 加载数据
    • 将传入的数据封装成节点放到缓冲链表里(支持普通发送)
    • 读取文件流,如果直到把缓冲链表对象读满(支持文件发送)
  • 发送数据,标记为已发送,并注册定时器;最后一个数据进行标记,发送结束
  • 根据定时器tick后需要重发的包编号向量,重新插入新的节点数据。
  • 收到ack,删除节点(及其重传的节点)
  • 握手,syn;发送完毕是fin

能发一条消息也能发文件,因此要解耦。对于加载数据来说用一个goOn来指示,文件读完就goOn为false否则为true即需要继续发;对于自行调用发送信息也是如此,指示即可;默认为false。

变量

  • 递增的number序列

  • 数据偏移量offset

  • window的大小,窗口能发送的字节数,窗口更新:

    • 因为是最多能发送的字节数,所以包括重传在内的所有包都不能太多,因此bufferList.size()* SegDataSize < window才允许继续发;
    • 更新时最开始想使用window = MyUdpSeg::win_type(offset) + Window,Window是接收窗口传来的还能接收的字节数recvWindow-maxOffset,用这个来更新是因为当一切正常时,offset和maxOffset是相等的,这个更新符合直觉。但是这样更新的话,window只会越来越大,肯定是错误的。
    • 后来打算用window = MyUdpSeg::win_type(bufferList.size() * SegDataSize) + Window,因为结合前面的判断,能新发送的字节数就是Window这么多,也符合直觉。但这可能导致重传的包很多也能继续发,导致越来越堵。
    • 因此再定义一个maxWindow,这个和接收窗口的大小一样,window = min(updateWindow, maxWindow),即如果已经很满了就不增大了。
  • 动态更新的RTO

接收窗口

功能

  • 接收窗口有缓冲链表对象
  • 接收数据
    • 把收到的一个数据包放入缓冲链表,注意插入链表要按offset排序插入
    • 发送ack和当前的剩余窗口
  • 写入数据,检测到一连串的offset超过阈值就写入;
    • 写入后更新窗口
    • 如果最后一个数据包里有结束标记,就通知关闭窗口,然后进行关闭挥手
  • 握手,syn+ack;接收完毕是fin+ack

变量

  • 最大offset偏移
  • window的大小,最大能接收的字节数

退出(基于心跳检测)

如何沟通退出呢,当接收会话得知自己接收完了会返回true,如果此时就退出了,那么发送会话可能因为ack丢了而无法退出也无法检测,因为udp是无连接的,对端close了也不会通知。

需要一个可靠的措施,它不会像tcp一样复杂,确保接收器不需要定时器来重传某些包。

这里的考量是,发送器为了确保对方存活,用一个额外的线程去进行心跳检测,下面是一般的心跳检测步骤:

  • 1.客户端每隔一个时间间隔发生一个探测包给服务器(秒级别的较长一段时间)
  • 2.客户端发包时启动一个超时定时器
  • 3.服务器端接收到检测包,应该回应一个包
  • 4.如果客户机收到服务器的应答包,则说明服务器正常,删除超时定时器
  • 5.如果客户端的超时定时器超时,依然没有收到应答包,则说明服务器挂了

这里的心跳检测主要是用于保活,但我这里是为了退出,可以直接复用接收器发回的ack包,发送器也不需要再进行发送包,就检测最近一次接收ack到当前时间的间隔即可,间隔多长结束呢,这里感觉好一点是4个rto(2个rto太容易发生了)。

接收器也是如此,但是接收器没有rto。所以接收器和发送器都约定好用1秒钟。发送方退出的时候就是检测到没有接收方响应的时候,此时接收方可能已经接受完文件退出、或者出错。接收方退出的时候是处理完所有数据的时候,这不需要心跳检测;也可能在心跳检测发现发送方出错时退出。因此发送方退出情况只用一种、接收方退出有两种(或的关系)

  • 在开始时握手阶段,因为双方开启的时候不是同步的,所以一开始握手检测时间要长一些(10~30s),这里设置为30s。当发送方成功从握手退出时(此时收到了syn+ack),可以修改为1s的心跳;而接收方收到syn时,还不能直接修改,因为可能丢包,要等第一个不是握手包到来才修改为1s。
  • 当接收方认为对方退出时,自己还不能退出,因为可能数据接收完整但是还没写完。这里的逻辑是:写完一次后收到后面的包,然后也许等了许久(别管为什么,只是也许)导致超时才再次准备写,但这次写之前因为超时了所以没去写。这时只需要再执行一次写数据的任务即可,如果完整就可以一次写完;如果是其他情况也没什么损失。

因为用到一些线程,要加锁。这里面发送器线程之间共享sendOver和lastAckTime,用一个互斥锁即可。

UDP会话

socket需要设置成非阻塞,否则recvfrom可能无法退出sendto不管怎么样都不会阻塞,因为sendto没有缓冲区,不需要拷贝:

send 和 sendto 函数在 UDP 层没有概念上的输出缓冲区(总要拷贝数据,但这个缓冲区和TCP的缓冲区在概念上不同),在 TCP 层有输出缓冲区,recv 和recvfrom 无论在 UDP 层还是 TCP 层都有接收缓冲区。

更详细的介绍是:

  • udp sendto 函数,它的作用和tcp一样,是拷贝到缓冲区,但是请注意udp栈底层实现的原理是:
  • 针对每个udp包如果目前有物理连路带宽可以发送,那么立即发送;如果没有,那么直接丢弃该udp包
  • 这个过程是非常非常快的,而且因为是在内核态执行,因此优先级高于普通操作
  • 从这个意义上来说,sendto函数根本不会阻塞,事实上也不会阻塞,因为只有2个结果:立即发送出去,或者直接丢包

总结一下就是:UDP不可靠,它不必保存应用程序的数据拷贝,因此无需真正的发送缓冲区(应用进程的数据在沿协议栈往下传递,以某种形式拷贝到内核缓冲区,然而数据链路层在送出数据之后将丢弃该拷贝)。

发送器

发送文件:

  • 首先建立一个发送窗口,窗口中有多个buffer;并且打开文件(fp);
  • 每个buffer依次读取文件,然后编辑成报文后发送;
  • 等待ACK,更新发送窗口;
  • 如果发送窗口可用,继续读取文件、发送

这里的问题是,整个流程是串行的吗?需要思考的是发送文件和接收ACK是怎么个交流法。

  • 如果是串行的,那么每次把发送窗口发完,然后等待ACK;这里的问题是一旦有一个ACK来了,是不继续等直接更新窗口(这样更新太频繁了)还是继续等(要等多少个呢?)没更新发送窗口时,假设数据不会发送,那么等ACK就可以一直等待直到发送窗口能增大一个阈值为止。这里的问题是实际上有数据需要重传,如果窗口一直没更新就会导致数据重传不了,这是致命的问题。
  • 因此需要两个线程,一个维护发送一个维护接收,它们动态更新发送窗口

接收器

接收文件:

  • 首先建立一个接收窗口,这里文件名由于在主项目里能根据服务器得到,所以这里不设置udp交流得到文件名,然后打开文件
  • 对于收到的每个包,返回一个ack确认,这样可以乱序确认,可以做到tcp中sack的优化(TCP乱序确认就不能快速重传)。接收的是一个char的buffer,要进行解析恢复成报文的数据结构。
  • 写入文件,要按序写,所以从接收窗口左边界对连续的包进行检测,找到连续的段就写入(基于quic的话是1/2的最大窗口再写入),然后更新窗口。

还是那个问题,流程是否是串行的?

  • 首先要一直接收,接收到就发一个ack这个没问题。但是如果接收到一个包就检测能不能写入文件就太慢了,会导致ack延迟太多。
  • 所以另开一个线程不断检测能否写入文件和更新窗口。